From 5f0becd0d836cc80138c141ec6e6f9950299c172 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 23:07:05 +0000 Subject: [PATCH] plugin: the generated plugins page tells a gate from a guard chock marketplace build wrote one fixed paragraph per tree describing every enforcing package as a PreToolUse guard script that can deny a shell command. Since 0.11.0 a package can carry a policy's gate instead, which judges what a turn writes rather than what it runs. The published PLUGINS.md in every distribution repo therefore misdescribed five of its fourteen enforcing packages, and named PreToolUse even in the Cursor tree, whose guards hook beforeShellExecution. The paragraph is now derived from the hooks each package publishes. A --guard command makes a guard package and a --gate command a gate package, and each kind is described with the events its own hooks file wires, in that client's spelling. Where a client records no write-tool vocabulary (Codex, Devin) the page says the gate runs at the turn's end only and the write is not judged. Devin keeps its best-effort framing as a closing caveat. The renderer moves to chock.plugin.catalog_page: marketplace_core had grown past the 300-line review budget, and the page is its own activity. Its three new tests fail against the old emitter and pass against this one. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Claude --- CHANGELOG.md | 14 ++ src/chock/plugin/catalog_page.py | 192 +++++++++++++++++++++++++++ src/chock/plugin/marketplace.py | 4 +- src/chock/plugin/marketplace_core.py | 98 -------------- tests/test_catalog_page.py | 99 ++++++++++++++ 5 files changed, 306 insertions(+), 101 deletions(-) create mode 100644 src/chock/plugin/catalog_page.py create mode 100644 tests/test_catalog_page.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 920983c..900586a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Chock changelog +## Unreleased + +- **The generated plugins page tells a gate from a guard.** `chock marketplace build` wrote one + fixed paragraph per tree describing every enforcing package as a `PreToolUse` guard script + that denies a shell command. Since 0.11.0 a package can carry a policy's gate instead, which + judges what a turn writes rather than what it runs, so the published `PLUGINS.md` in every + distribution repo misdescribed five of its fourteen enforcing packages -- and named + `PreToolUse` even in the Cursor tree, whose guards hook `beforeShellExecution`. The paragraph + is now derived from the hooks each package publishes: a `--guard` command makes a guard + package, a `--gate` command a gate package, and each kind is described with the events its + own hooks file wires, in that client's spelling. Where a client records no write-tool + vocabulary the page says the gate runs at the turn's end only and the write itself is not + judged. The page renderer moves to its own module, `chock.plugin.catalog_page`. + ## 0.11.0 — A policy's gate rides in its plugin, and Cursor gates a write and reports at the turn's end - **Cursor gates a write and reports at the turn's end.** agentseam 0.3.3 records what a live diff --git a/src/chock/plugin/catalog_page.py b/src/chock/plugin/catalog_page.py new file mode 100644 index 0000000..6c8bf56 --- /dev/null +++ b/src/chock/plugin/catalog_page.py @@ -0,0 +1,192 @@ +"""The generated catalog page: how many packages enforce, which ones, and how each kind does. + +Every sentence the page says about an enforcing package is derived from the hooks that package +actually publishes -- a guard's `--guard` command and the events it wires, a gate's `--gate` +command and its own -- so the page cannot describe a mechanism the tree does not ship. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from agentseam import packaging + +from chock import vendors +from chock.plugin.marketplace_core import CLAUDE_TREE, NEWLINE, _manifest_rel +from chock.vendors import CHOCK_AGENT + +CATALOG_PAGE = "PLUGINS.md" + +CATALOG_DOCS = "https://github.com/open-coder-ai/chock-catalog/blob/main/docs" + +#: Summaries longer than this are truncated (at _SUMMARY_TRUNCATE_AT) with an ellipsis. +_SUMMARY_MAX_LEN = 99 +_SUMMARY_TRUNCATE_AT = 96 + + +def _summary(description: str) -> str: + """First sentence of the description, with the bracketed posture note stripped.""" + text = description.split("[", maxsplit=1)[0].strip() + first = text.split(". ")[0].strip().rstrip(".") + return (first[:_SUMMARY_TRUNCATE_AT].rstrip() + "...") if len(first) > _SUMMARY_MAX_LEN else first + + +def _hooks_rel(tree: str) -> str: + """This tree's hooks-file path, relative to a package directory (root or nested).""" + return packaging.supports(CHOCK_AGENT[tree], packaging.HOOKS) + + +#: The flag a hook command carries for each kind of enforcing package. A guard judges a shell +#: command before the client runs it; a gate judges what a turn writes. The page says which is +#: which by reading the published hooks, never by assuming every enforcing package is a guard. +_GUARD_FLAG = "--guard" +_GATE_FLAG = "--gate" + +#: Per-tree catalog-page vocabulary. Devin gets its own row/summary words and a closing caveat +#: so the page never claims "enforce"/"block" for a fail-open, best-effort client. +_CATALOG_WORDS: dict[str, dict[str, str]] = { + "devin": { + "row": "best-effort", + "summary": "{enforcing} are best-effort in this client, {advisory} are advisory", + "caveat": ( + "Devin's own docs call plugin hooks fail-open by design -- a hook that fails to load " + "or run lets the session continue without it -- so none of this is a guarantee." + ), + }, +} +_DEFAULT_CATALOG_WORDS = { + "row": "enforces", + "summary": "{enforcing} enforce in this client, {advisory} are advisory", + "caveat": "", +} + + +def _hook_commands(node: Any) -> list[str]: + """Every `command` string anywhere in a hooks document, nested or flat.""" + if isinstance(node, dict): + found = [node["command"]] if isinstance(node.get("command"), str) else [] + return found + [c for value in node.values() for c in _hook_commands(value)] + if isinstance(node, list): + return [c for item in node for c in _hook_commands(item)] + return [] + + +def _hook_events(doc: Any) -> list[str]: + """The events a hooks document wires, in the order it wires them.""" + events = doc.get("hooks", doc) if isinstance(doc, dict) else {} + return [event for event, entries in events.items() if isinstance(entries, list)] if isinstance(events, dict) else [] + + +def _package_kind(hooks_path: Path) -> tuple[str | None, list[str]]: + """('gate' | 'guard' | None, the events wired) for one published package's hooks file.""" + doc = json.loads(hooks_path.read_text(encoding="utf-8")) + commands = _hook_commands(doc) + if any(_GATE_FLAG in command for command in commands): + return "gate", _hook_events(doc) + if any(_GUARD_FLAG in command for command in commands): + return "guard", _hook_events(doc) + return None, _hook_events(doc) + + +def _event_list(events: list[str]) -> str: + """`A`, `A` and `B`, or `A`, `B` and `C`.""" + quoted = [f"`{event}`" for event in events] + return quoted[0] if len(quoted) == 1 else ", ".join(quoted[:-1]) + " and " + quoted[-1] + + +def _explain(tree: str, guards: int, guard_events: list[str], gates: int, gate_events: list[str]) -> str: + """What an enforcing package here ships and does, derived from what was published.""" + parts: list[str] = [] + if guards: + parts.append( + f"A guard package ships a guard script and a stdlib-only adapter, hooked at " + f"{_event_list(guard_events)}, and can deny a shell command before the client runs it. " + "It fails open when `python3` or a usable `bash` is unavailable, and asks -- on Codex " + "CLI, denies -- when the guard crashes." + ) + if gates: + judges_write = vendors.pre_tool_event(CHOCK_AGENT[tree]) in gate_events + reach = ( + "judging the file a write would create and then re-reading what the turn left on disk" + if judges_write + else "re-reading what the turn left on disk: this client records no file-writing tool " + "vocabulary, so the write itself is not judged" + ) + parts.append( + f"A gate package ships the policy's gate and a stdlib-only runner instead, hooked at " + f"{_event_list(gate_events)}, {reach}. It needs `python3`; without it a fail-open client " + "allows silently, and a gate that cannot reach a decision refuses rather than allowing " + "one it never judged." + ) + parts.append("An advisory package ships skill text; nothing stops a violation.") + caveat = _CATALOG_WORDS.get(tree, _DEFAULT_CATALOG_WORDS)["caveat"] + return " ".join(parts + ([caveat] if caveat else [])) + + +def _merge_events(into: list[str], events: list[str]) -> None: + """Append each event not already listed, keeping first-seen order.""" + into.extend(event for event in events if event not in into) + + +def render_catalog_page(dist_root: Path, tree: str = CLAUDE_TREE) -> str: + """The generated catalog: how many packages enforce, how many advise, which, and how.""" + dist_root = Path(dist_root) + words = _CATALOG_WORDS.get(tree, _DEFAULT_CATALOG_WORDS) + rows = [] + enforcing = guards = gates = 0 + guard_events: list[str] = [] + gate_events: list[str] = [] + manifest_rel = _manifest_rel(tree) + hooks_rel = _hooks_rel(tree) + for manifest_path in sorted(dist_root.glob(f"{tree}/*/{manifest_rel}")): + pkg = manifest_path.parent.parent + data = json.loads(manifest_path.read_text(encoding="utf-8")) + hooks_path = pkg / hooks_rel + has_hook = hooks_path.exists() + if has_hook: + enforcing += 1 + kind, events = _package_kind(hooks_path) + if kind == "gate": + gates += 1 + _merge_events(gate_events, events) + elif kind == "guard": + guards += 1 + _merge_events(guard_events, events) + posture = words["row"] if has_hook else "advisory" + name = data["name"] + rows.append( + f"| [`{name}`]({CATALOG_DOCS}/{name}/README.md) " + f"| {data.get('version', '-')} | {posture} | {_summary(data.get('description', ''))} |" + ) + + total = len(rows) + summary = words["summary"].format(enforcing=enforcing, advisory=total - enforcing) + lines = [ + "# Published plugins", + "", + "", + "", + f"**{total} policies are published here: {summary}.**", + "", + _explain(tree, guards, guard_events, gates, gate_events), + "", + "| plugin | version | in this client | what it does |", + "| :--- | :--- | :--- | :--- |", + *rows, + "", + f"Each name links to its full policy page in the [catalog]({CATALOG_DOCS}): what it", + "solves, how it works, and its honest reach.", + "", + ] + return NEWLINE.join(lines) + + +def catalog_page_differences(dist_root: Path, tree: str = CLAUDE_TREE) -> list[str]: + """Report a catalog page that disagrees with the tree it describes.""" + dest = Path(dist_root) / CATALOG_PAGE + content = render_catalog_page(dist_root, tree) + if not dest.exists(): + return [f"missing: {CATALOG_PAGE}"] + return [] if dest.read_text(encoding="utf-8") == content else [f"differs: {CATALOG_PAGE}"] diff --git a/src/chock/plugin/marketplace.py b/src/chock/plugin/marketplace.py index 4d4755c..b60a835 100644 --- a/src/chock/plugin/marketplace.py +++ b/src/chock/plugin/marketplace.py @@ -8,8 +8,8 @@ from pathlib import Path from chock.emit import write_generated +from chock.plugin.catalog_page import CATALOG_PAGE, catalog_page_differences, render_catalog_page from chock.plugin.marketplace_core import ( - CATALOG_PAGE, CLAUDE_TREE, DESCRIPTION, INDEX_PATHS, @@ -18,11 +18,9 @@ TREES, build_index, build_lock, - catalog_page_differences, collect_entries, index_differences, lock_differences, - render_catalog_page, ) from chock.plugin.marketplace_devin import ( DEVIN_ROOT_MANIFEST_REL, diff --git a/src/chock/plugin/marketplace_core.py b/src/chock/plugin/marketplace_core.py index 366bec1..e7fc3ea 100644 --- a/src/chock/plugin/marketplace_core.py +++ b/src/chock/plugin/marketplace_core.py @@ -113,104 +113,6 @@ def lock_differences(dist_root: Path) -> list[str]: return [] if dest.read_text(encoding="utf-8") == content else [f"differs: {LOCKFILE_NAME}"] -CATALOG_PAGE = "PLUGINS.md" - -CATALOG_DOCS = "https://github.com/open-coder-ai/chock-catalog/blob/main/docs" - -#: Summaries longer than this are truncated (at _SUMMARY_TRUNCATE_AT) with an ellipsis. -_SUMMARY_MAX_LEN = 99 -_SUMMARY_TRUNCATE_AT = 96 - - -def _summary(description: str) -> str: - """First sentence of the description, with the bracketed posture note stripped.""" - text = description.split("[", maxsplit=1)[0].strip() - first = text.split(". ")[0].strip().rstrip(".") - return (first[:_SUMMARY_TRUNCATE_AT].rstrip() + "...") if len(first) > _SUMMARY_MAX_LEN else first - - -def _hooks_rel(tree: str) -> str: - """This tree's hooks-file path, relative to a package directory (root or nested).""" - return packaging.supports(CHOCK_AGENT[tree], packaging.HOOKS) - - -#: Per-tree catalog-page vocabulary. Devin gets its own row/summary/explain text so the page -#: never claims "enforce"/"block" for a fail-open, best-effort client; others keep the original. -_CATALOG_WORDS: dict[str, dict[str, str]] = { - "devin": { - "row": "best-effort", - "summary": "{enforcing} are best-effort in this client, {advisory} are advisory", - "explain": ( - "A best-effort package ships a `PreToolUse` hook, a guard script and a stdlib-only " - "adapter. Devin's own docs call plugin hooks fail-open by design -- a hook that " - "fails to load or run lets the session continue without it -- so this is not a " - "guarantee. An advisory package ships skill text; nothing stops a violation." - ), - }, -} -_DEFAULT_CATALOG_WORDS = { - "row": "enforces", - "summary": "{enforcing} enforce in this client, {advisory} are advisory", - "explain": ( - "An enforcing package ships a `PreToolUse` hook, a guard script and a stdlib-only " - "adapter, and can deny a shell command before the client runs it. It fails open when " - "`python3` or a usable `bash` is unavailable, and asks -- on Codex CLI, denies -- when " - "the guard crashes. An advisory package ships skill text; nothing stops a violation." - ), -} - - -def render_catalog_page(dist_root: Path, tree: str = CLAUDE_TREE) -> str: - """The generated catalog: how many packages enforce, how many advise, and which.""" - dist_root = Path(dist_root) - words = _CATALOG_WORDS.get(tree, _DEFAULT_CATALOG_WORDS) - rows = [] - enforcing = 0 - manifest_rel = _manifest_rel(tree) - hooks_rel = _hooks_rel(tree) - for manifest_path in sorted(dist_root.glob(f"{tree}/*/{manifest_rel}")): - pkg = manifest_path.parent.parent - data = json.loads(manifest_path.read_text(encoding="utf-8")) - has_hook = (pkg / hooks_rel).exists() - enforcing += 1 if has_hook else 0 - posture = words["row"] if has_hook else "advisory" - name = data["name"] - rows.append( - f"| [`{name}`]({CATALOG_DOCS}/{name}/README.md) " - f"| {data.get('version', '-')} | {posture} | {_summary(data.get('description', ''))} |" - ) - - total = len(rows) - summary = words["summary"].format(enforcing=enforcing, advisory=total - enforcing) - lines = [ - "# Published plugins", - "", - "", - "", - f"**{total} policies are published here: {summary}.**", - "", - *words["explain"].splitlines(), - "", - "| plugin | version | in this client | what it does |", - "| :--- | :--- | :--- | :--- |", - *rows, - "", - f"Each name links to its full policy page in the [catalog]({CATALOG_DOCS}): what it", - "solves, how it works, and its honest reach.", - "", - ] - return NEWLINE.join(lines) - - -def catalog_page_differences(dist_root: Path, tree: str = CLAUDE_TREE) -> list[str]: - """Report a catalog page that disagrees with the tree it describes.""" - dest = Path(dist_root) / CATALOG_PAGE - content = render_catalog_page(dist_root, tree) - if not dest.exists(): - return [f"missing: {CATALOG_PAGE}"] - return [] if dest.read_text(encoding="utf-8") == content else [f"differs: {CATALOG_PAGE}"] - - def index_differences(dist_root: Path, name: str, tree: str = CLAUDE_TREE) -> list[str]: """Report where the on-disk index files disagree with the plugin tree.""" content = json.dumps(build_index(dist_root, name, tree), indent=2) + "\n" diff --git a/tests/test_catalog_page.py b/tests/test_catalog_page.py new file mode 100644 index 0000000..d3d811c --- /dev/null +++ b/tests/test_catalog_page.py @@ -0,0 +1,99 @@ +"""The catalog page describes each kind of enforcing package from what that package publishes.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +from chock.plugin.cli import main as plugin_main +from chock.plugin.marketplace import CATALOG_PAGE +from chock.plugin.marketplace import main as marketplace_main + +GUARD_MANIFEST = { + "id": "block-destructive-commands", + "name": "Block Destructive Commands", + "version": "0.0.2", + "description": "Block rm -rf and friends before they run.", + "artifact": "hook", + "enforcement": "block", +} +ADVISORY_MANIFEST = { + "id": "code-safety", + "name": "Code Safety Rule", + "version": "0.0.1", + "description": "Advisory rule with no gate.", + "artifact": "rule", + "enforcement": "advise", + "rule": {"text": "never(commit): secrets"}, +} + +GATE_MANIFEST = { + "id": "no-todo", + "name": "No TODO", + "version": "0.0.1", + "description": "Refuse a TODO as it is written.", + "artifact": "hook", + "enforcement": "block", + "hook": { + "gate": { + "kind": "content_regex", + "on": ["commit", "tool_use"], + "action": "block", + "message": "TODO added.", + "params": {"content_pattern": "TODO"}, + } + }, +} + + +@pytest.fixture +def gate_dist(tmp_path: Path) -> Path: + """A built tree holding one guard, one gate and one advisory policy.""" + for manifest in [GUARD_MANIFEST, ADVISORY_MANIFEST, GATE_MANIFEST]: + pack = tmp_path / ".agents" / "policies" / manifest["id"] + pack.mkdir(parents=True) + (pack / "manifest.yaml").write_text(yaml.safe_dump(manifest), encoding="utf-8") + if manifest["id"] == "block-destructive-commands": + impl = pack / "implementations" + impl.mkdir() + (impl / f"{manifest['id']}.sh").write_text("#!/usr/bin/env bash\nexit 0\n", encoding="utf-8") + out = tmp_path / "dist" + assert plugin_main(["build", "--repo", str(tmp_path), "--format", "all", "--out-dir", str(out)]) == 0 + return out + + +def test_catalog_page_tells_a_gate_from_a_guard(gate_dist: Path) -> None: + """A gate judges what a turn writes; the page must not call it a shell-command guard.""" + marketplace_main(["build", "--dist", str(gate_dist)]) + body = (gate_dist / CATALOG_PAGE).read_text(encoding="utf-8") + + assert "3 policies are published here: 2 enforce in this client, 1 are advisory." in body + assert "A guard package ships a guard script and a stdlib-only adapter, hooked at `PreToolUse`" in body + assert ( + "A gate package ships the policy's gate and a stdlib-only runner instead, hooked at `PreToolUse` and `Stop`" + in body + ) + assert "judging the file a write would create" in body + assert "refuses rather than allowing one it never judged" in body + + +def test_catalog_page_says_when_a_client_cannot_judge_the_write(gate_dist: Path) -> None: + """Where the vendor records no write vocabulary, the gate runs at the turn's end only.""" + marketplace_main(["build", "--dist", str(gate_dist), "--tree", "codex"]) + body = (gate_dist / CATALOG_PAGE).read_text(encoding="utf-8") + + assert "hooked at `Stop`, re-reading what the turn left on disk" in body + assert "so the write itself is not judged" in body + assert "judging the file a write would create" not in body + + +def test_catalog_page_names_each_client_s_own_events(gate_dist: Path) -> None: + """The events on the page are the ones the published hooks wire, in that client's spelling.""" + marketplace_main(["build", "--dist", str(gate_dist), "--tree", "cursor"]) + body = (gate_dist / CATALOG_PAGE).read_text(encoding="utf-8") + + assert "hooked at `beforeShellExecution`" in body + assert "hooked at `preToolUse` and `stop`" in body + assert "`PreToolUse`" not in body