Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1624,17 +1624,57 @@ def framework_root() -> Path | None:
return None


#: The plugin that ships this engine, as named in a marketplace's plugin cache.
ENGINE_PLUGIN = "magpie-agent-guard"


def installed_plugin_guard_dirs() -> list[Path]:
"""Skill guards of the family plugins installed beside this engine's plugin.

A marketplace install caches every plugin on its own, as
``<marketplace>/<plugin>/<version>/``, so the plugins whose skills own guards
are siblings of this one rather than a framework tree above it. Each sibling's
copy at this engine's version is used, since the families are released
together; failing that, its most recently installed version. Empty when the
engine is not running from such a cache.
"""
for plugin_root in Path(__file__).resolve().parents:
if (plugin_root / ".claude-plugin").is_dir():
break
else:
return []
if plugin_root.parent.name != ENGINE_PLUGIN:
return []
dirs: list[Path] = []
for plugin in sorted(plugin_root.parent.parent.iterdir()):
if plugin.name == ENGINE_PLUGIN or not plugin.is_dir():
continue
installed = [v for v in plugin.iterdir() if (v / ".claude-plugin").is_dir()]
same = plugin / plugin_root.name
if same in installed:
chosen = same
elif installed:
chosen = max(installed, key=lambda v: v.stat().st_mtime)
else:
continue
dirs.extend(sorted(chosen.glob("skills/*/guards")))
return dirs


def guard_dirs() -> list[Path]:
"""Directories scanned for contributed guards: ``$MAGPIE_GUARD_DIRS`` entries,
then every ``plugins/*/skills/*/guards`` and ``skills/*/guards`` in the framework
tree this file runs from, then the ``guards.d`` sibling of this file."""
tree this file runs from, or, from a marketplace install, the skill guards of
the sibling family plugins; then the ``guards.d`` sibling of this file."""
dirs: list[Path] = []
env = os.environ.get(GUARD_DIRS_ENV)
if env:
dirs.extend(Path(p) for p in env.split(os.pathsep) if p)
if (root := framework_root()) is not None:
dirs.extend(sorted((root / "plugins").glob("*/skills/*/guards")))
dirs.extend(sorted((root / "skills").glob("*/guards")))
else:
dirs.extend(installed_plugin_guard_dirs())
dirs.append(Path(__file__).resolve().parent / "guards.d")
seen: set[Path] = set()
out: list[Path] = []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@
for installs that do not carry the framework source (see `isolated.py`).
"""

FRAMEWORK_FINGERPRINT = "sha256:56c99611e46a3af6"
FRAMEWORK_FINGERPRINT = "sha256:f11451b59daac4bf"
8 changes: 7 additions & 1 deletion tools/agent-guard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,10 @@ guard is active in it the moment the plugin is installed.
Skill-owned guards work the same way: the engine discovers every
`skills/<skill>/guards` in the framework tree it is running from, so a skill's
guards take effect where they live and nothing collects or syncs them.
A marketplace install has no such tree — it caches each plugin on its own, as
`<marketplace>/<plugin>/<version>/` — so the engine scans the
`skills/*/guards` of the family plugins installed beside its own, at its own
version (they are released together), else at their most recently installed one.

The guard is its own plugin rather than a hook on `magpie` or on each family
plugin because Claude Code merges hooks from **every** enabled plugin — wiring it
Expand Down Expand Up @@ -490,7 +494,9 @@ for Claude Code setups — the file is the same and works for all three modes).
The hook is **wired once**. Beyond the two bundled guards, additional guards are
discovered at runtime from every `*.py` in a discovered guard directory — any
directory listed in `$MAGPIE_GUARD_DIRS` (colon-separated), then every
`skills/*/guards` in the framework tree the engine runs from, then the
`plugins/*/skills/*/guards` and `skills/*/guards` in the framework tree the
engine runs from (or, from a marketplace install, in the family plugins cached
beside it), then the
`guards.d` sibling of the running script. **No `settings.json` change and no
install step is needed to add a guard.**

Expand Down
42 changes: 41 additions & 1 deletion tools/agent-guard/src/agent_guard/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1624,17 +1624,57 @@ def framework_root() -> Path | None:
return None


#: The plugin that ships this engine, as named in a marketplace's plugin cache.
ENGINE_PLUGIN = "magpie-agent-guard"


def installed_plugin_guard_dirs() -> list[Path]:
"""Skill guards of the family plugins installed beside this engine's plugin.

A marketplace install caches every plugin on its own, as
``<marketplace>/<plugin>/<version>/``, so the plugins whose skills own guards
are siblings of this one rather than a framework tree above it. Each sibling's
copy at this engine's version is used, since the families are released
together; failing that, its most recently installed version. Empty when the
engine is not running from such a cache.
"""
for plugin_root in Path(__file__).resolve().parents:
if (plugin_root / ".claude-plugin").is_dir():
break
else:
return []
if plugin_root.parent.name != ENGINE_PLUGIN:
return []
dirs: list[Path] = []
for plugin in sorted(plugin_root.parent.parent.iterdir()):
if plugin.name == ENGINE_PLUGIN or not plugin.is_dir():
continue
installed = [v for v in plugin.iterdir() if (v / ".claude-plugin").is_dir()]
same = plugin / plugin_root.name
if same in installed:
chosen = same
elif installed:
chosen = max(installed, key=lambda v: v.stat().st_mtime)
else:
continue
dirs.extend(sorted(chosen.glob("skills/*/guards")))
return dirs


def guard_dirs() -> list[Path]:
"""Directories scanned for contributed guards: ``$MAGPIE_GUARD_DIRS`` entries,
then every ``plugins/*/skills/*/guards`` and ``skills/*/guards`` in the framework
tree this file runs from, then the ``guards.d`` sibling of this file."""
tree this file runs from, or, from a marketplace install, the skill guards of
the sibling family plugins; then the ``guards.d`` sibling of this file."""
dirs: list[Path] = []
env = os.environ.get(GUARD_DIRS_ENV)
if env:
dirs.extend(Path(p) for p in env.split(os.pathsep) if p)
if (root := framework_root()) is not None:
dirs.extend(sorted((root / "plugins").glob("*/skills/*/guards")))
dirs.extend(sorted((root / "skills").glob("*/guards")))
else:
dirs.extend(installed_plugin_guard_dirs())
dirs.append(Path(__file__).resolve().parent / "guards.d")
seen: set[Path] = set()
out: list[Path] = []
Expand Down
71 changes: 71 additions & 0 deletions tools/agent-guard/tests/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,74 @@ def test_guard_dirs_deduplicates_symlinks(tmp_path, monkeypatch):
monkeypatch.setenv(agent_guard.GUARD_DIRS_ENV, f"{real_dir}{os.pathsep}{sym_dir}")
resolved_dirs = [d.resolve() for d in agent_guard.guard_dirs()]
assert resolved_dirs.count(real_dir.resolve()) == 1


def _install(cache: Path, plugin: str, version: str, *guard_skills: str) -> Path:
"""Lay out one plugin the way a marketplace caches it: ``<plugin>/<version>/``."""
root = cache / plugin / version
(root / ".claude-plugin").mkdir(parents=True)
for skill in guard_skills:
(root / "skills" / skill / "guards").mkdir(parents=True)
return root


def _load_engine_from(plugin_root: Path):
"""Copy the engine into a cached plugin and import it from there, so
``__file__`` sits inside the cache with no framework tree above it."""
package = plugin_root / "tools" / "agent-guard" / "src" / "agent_guard"
package.mkdir(parents=True)
entry = package / "__init__.py"
entry.write_bytes(Path(agent_guard.__file__).read_bytes())
spec = importlib.util.spec_from_file_location("_cached_agent_guard", entry)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module


def test_marketplace_install_discovers_sibling_family_plugin_guards(tmp_path):
"""A marketplace install caches each plugin separately, so the family plugins
owning skill guards are siblings of the engine's plugin, not a tree above it."""
cache = tmp_path / "cache" / "apache-magpie"
engine = _install(cache, "magpie-agent-guard", "1.2.0")
_install(cache, "magpie-security", "1.2.0", "issue-fix")
_install(cache, "magpie-pr-management", "1.2.0", "pr-triage")
_install(cache, "magpie-utilities", "1.2.0")
module = _load_engine_from(engine)

assert module.framework_root() is None
assert module.guard_dirs()[:2] == [
cache / "magpie-pr-management" / "1.2.0" / "skills" / "pr-triage" / "guards",
cache / "magpie-security" / "1.2.0" / "skills" / "issue-fix" / "guards",
]


def test_marketplace_install_prefers_the_engines_own_version(tmp_path):
"""Several versions of a plugin can sit in the cache at once; the one released
with the engine wins over a newer-looking leftover."""
cache = tmp_path / "cache" / "apache-magpie"
engine = _install(cache, "magpie-agent-guard", "1.2.0")
_install(cache, "magpie-security", "1.2.0", "issue-fix")
_install(cache, "magpie-security", "1.3.0", "issue-fix")
module = _load_engine_from(engine)

assert module.installed_plugin_guard_dirs() == [
cache / "magpie-security" / "1.2.0" / "skills" / "issue-fix" / "guards"
]


def test_marketplace_install_falls_back_to_the_latest_installed_version(tmp_path):
cache = tmp_path / "cache" / "apache-magpie"
engine = _install(cache, "magpie-agent-guard", "1.2.0")
old = _install(cache, "magpie-security", "1.0.0", "issue-fix")
new = _install(cache, "magpie-security", "1.1.0", "issue-fix")
os.utime(old, (1_000, 1_000))
os.utime(new, (2_000, 2_000))
module = _load_engine_from(engine)

assert module.installed_plugin_guard_dirs() == [new / "skills" / "issue-fix" / "guards"]


def test_framework_checkout_does_not_scan_a_plugin_cache():
"""In a checkout the plugins sit under ``plugins/``, not a marketplace cache."""
assert agent_guard.installed_plugin_guard_dirs() == []
4 changes: 4 additions & 0 deletions tools/spec-loop/specs/marketplace-distribution.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,10 @@ adopter-facing page.
the manifest, with the engine resolved through
`${GROK_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}` so Claude Code and Grok Build
share one registration.
With no framework tree above it in the plugin cache, the engine discovers
skill-owned guards in the sibling family plugins cached as
`<marketplace>/<plugin>/<version>/`, preferring each one's copy at the
engine's own version, else its most recently installed one.
The others are the `SessionStart` hooks of vetted-ops (`tools/vetted-ops/hooks/link-stable-path.sh`,
#1406) and adversarial-review (`tools/adversarial-review/hooks/link-stable-path.sh`),
which point the fixed paths `~/.claude/magpie/vetted-ops` and
Expand Down
Loading