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