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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
192 changes: 192 additions & 0 deletions src/chock/plugin/catalog_page.py
Original file line number Diff line number Diff line change
@@ -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",
"",
"<!-- generated by `chock marketplace build`; edits are overwritten -->",
"",
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}"]
4 changes: 1 addition & 3 deletions src/chock/plugin/marketplace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
98 changes: 0 additions & 98 deletions src/chock/plugin/marketplace_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
"",
"<!-- generated by `chock marketplace build`; edits are overwritten -->",
"",
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"
Expand Down
Loading
Loading