From bc2c79b6d4559b471d23a9e3d2d8321e605aa3c5 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Fri, 23 Oct 2026 01:43:36 +0200 Subject: [PATCH] fix(agent-guard): discover skill guards on a marketplace install A marketplace caches every plugin on its own, as ///, so the published magpie-agent-guard plugin has no framework tree above it and framework_root() is None there. The skill-owned guards (mention, mark-ready, security-language) were therefore never discovered on a marketplace install, leaving only the bundled guards.d active. When there is no framework tree, the engine now scans the skills/*/guards of the family plugins cached beside its own, using each one's copy at the engine's own version (the families are released together), else its most recently installed version. Generated-by: Claude Opus 5 --- .../agent-guard/src/agent_guard/__init__.py | 42 ++++++++++- .../setup_preflight/isolated_fingerprint.py | 2 +- tools/agent-guard/README.md | 8 ++- tools/agent-guard/src/agent_guard/__init__.py | 42 ++++++++++- tools/agent-guard/tests/test_discovery.py | 71 +++++++++++++++++++ .../specs/marketplace-distribution.md | 4 ++ 6 files changed, 165 insertions(+), 4 deletions(-) diff --git a/plugins/magpie-agent-guard/tools/agent-guard/src/agent_guard/__init__.py b/plugins/magpie-agent-guard/tools/agent-guard/src/agent_guard/__init__.py index bb94debac..2829711f0 100644 --- a/plugins/magpie-agent-guard/tools/agent-guard/src/agent_guard/__init__.py +++ b/plugins/magpie-agent-guard/tools/agent-guard/src/agent_guard/__init__.py @@ -1624,10 +1624,48 @@ 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 + ``///``, 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: @@ -1635,6 +1673,8 @@ def guard_dirs() -> list[Path]: 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] = [] diff --git a/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py b/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py index 32190d8c6..b37695e8e 100644 --- a/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py +++ b/plugins/magpie-setup/skills/setup/setup_preflight/isolated_fingerprint.py @@ -21,4 +21,4 @@ for installs that do not carry the framework source (see `isolated.py`). """ -FRAMEWORK_FINGERPRINT = "sha256:56c99611e46a3af6" +FRAMEWORK_FINGERPRINT = "sha256:f11451b59daac4bf" diff --git a/tools/agent-guard/README.md b/tools/agent-guard/README.md index 00bd986b6..e5d46857b 100644 --- a/tools/agent-guard/README.md +++ b/tools/agent-guard/README.md @@ -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//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 +`///` — 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 @@ -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.** diff --git a/tools/agent-guard/src/agent_guard/__init__.py b/tools/agent-guard/src/agent_guard/__init__.py index bb94debac..2829711f0 100644 --- a/tools/agent-guard/src/agent_guard/__init__.py +++ b/tools/agent-guard/src/agent_guard/__init__.py @@ -1624,10 +1624,48 @@ 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 + ``///``, 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: @@ -1635,6 +1673,8 @@ def guard_dirs() -> list[Path]: 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] = [] diff --git a/tools/agent-guard/tests/test_discovery.py b/tools/agent-guard/tests/test_discovery.py index 30dd7c6ef..5331c9259 100644 --- a/tools/agent-guard/tests/test_discovery.py +++ b/tools/agent-guard/tests/test_discovery.py @@ -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: ``//``.""" + 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() == [] diff --git a/tools/spec-loop/specs/marketplace-distribution.md b/tools/spec-loop/specs/marketplace-distribution.md index f60f64399..7d7aaeff7 100644 --- a/tools/spec-loop/specs/marketplace-distribution.md +++ b/tools/spec-loop/specs/marketplace-distribution.md @@ -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 + `///`, 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