From 1fd7b945e1bf43494fbfe6c8d1a164f56b41568d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 23:33:21 +0000 Subject: [PATCH 1/3] feat(plugin): a policy's gate rides in its Claude plugin, and a policy's own skill files ride in its skill A plugin installs at the agent, not in a repository, so it carried a command guard or nothing: a gate lived only where chock sync compiled it, and a policy like java-security was advisory in the marketplace while enforcing in every repo install. chock plugin build --format claude now packages a policy whose gate declares tool_use: the compiled scripts/gate.json, the runner beside it as scripts/gate.py, and for kind: script the whole implementations/ under scripts/, so a program that imports from beside itself still does. hooks/hooks.json runs the same adapter with --gate on the vendor's recorded write tools at PreToolUse and, with no matcher, at Stop. The package states the posture and the skill claims its hooks; a gate that declares only commit stays advisory, since a hook that could only refuse is not installed. The bundled runtime looks for the runner beside the gate before the repository layout, requires the compiled layout before deriving a root from it, and otherwise takes the repository from the event's working directory -- where a policy's own config, such as a selection file, is read from. A packaged script gate says script_base: gate, which the runner reads as beside me. Runtime goldens and this repo's vendored runtimes regenerate. A policy may also carry a skill/ folder: body.md joins its rendered SKILL.md after the constraint block and every other file rides in the skill's directory, in both the Agent Plugins and the Claude package. --check treats a changed or removed file as drift, and the Claude store owns skills/ so a rebuild removes what a policy stopped shipping. The hooks-file builders move from in_agent.py to in_agent_hooks.py (re-exported) to keep the emitter inside the line budget. Signed-off-by: Claude --- .chock/bin/claude_code.py | 26 +- .chock/bin/codex_cli.py | 26 +- .chock/bin/cursor.py | 26 +- .chock/bin/devin.py | 26 +- .chock/bin/gate.py | 13 +- .chock/bin/gemini_cli.py | 26 +- .chock/bin/grok.py | 26 +- .chock/bin/tabnine.py | 26 +- .chock/bin/vscode_copilot.py | 26 +- .chock/bin/windsurf.py | 26 +- CHANGELOG.md | 21 ++ docs/agent-plugins.md | 21 +- docs/authoring-policies.md | 1 + spec/gate-dsl.md | 5 + spec/policy-spec.md | 2 +- src/chock/compile/emitters/in_agent.py | 65 +---- src/chock/compile/emitters/in_agent_hooks.py | 69 +++++ src/chock/gate/runner.py | 13 +- src/chock/gate/write_gate.py | 27 +- src/chock/plugin/build.py | 30 ++ src/chock/plugin/claude.py | 93 ++++++- src/chock/plugin/cli.py | 3 +- src/chock/plugin/data/stores/claude.json | 2 +- tests/fixtures/runtime_goldens/antigravity.py | 26 +- tests/fixtures/runtime_goldens/claude_code.py | 26 +- tests/fixtures/runtime_goldens/codex_cli.py | 26 +- tests/fixtures/runtime_goldens/cursor.py | 26 +- tests/fixtures/runtime_goldens/devin.py | 26 +- tests/fixtures/runtime_goldens/gemini_cli.py | 26 +- tests/fixtures/runtime_goldens/grok.py | 26 +- tests/fixtures/runtime_goldens/tabnine.py | 26 +- .../runtime_goldens/vscode_copilot.py | 26 +- tests/fixtures/runtime_goldens/windsurf.py | 26 +- tests/test_plugin_gate.py | 261 ++++++++++++++++++ tests/test_plugin_skill_assets.py | 80 ++++++ 35 files changed, 1015 insertions(+), 185 deletions(-) create mode 100644 src/chock/compile/emitters/in_agent_hooks.py create mode 100644 tests/test_plugin_gate.py create mode 100644 tests/test_plugin_skill_assets.py diff --git a/.chock/bin/claude_code.py b/.chock/bin/claude_code.py index d8f56f9..2f67f78 100755 --- a/.chock/bin/claude_code.py +++ b/.chock/bin/claude_code.py @@ -844,6 +844,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _RUNNER_PARTS = ('bin', 'gate.py') +_PACKAGED_RUNNER = 'gate.py' + _GIT = 'git' _DELETED = 'D' @@ -867,7 +869,10 @@ def gate_path_from_argv(argv): return None def runner_for(gate): - """The vendored gate runner beside this compiled gate, or None when it is not there.""" + """The gate runner: beside the gate in a plugin, under .chock/bin in a repository, else None.""" + packaged = gate.resolve().parent / _PACKAGED_RUNNER + if packaged.exists(): + return packaged parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None @@ -919,13 +924,13 @@ def writes_from_worktree(repo_root): continue return writes -def run_gate(gate, writes, event): +def run_gate(gate, writes, event, root=None): """Ask the vendored runner. Returns (outcome, message) and never decides for itself.""" runner = runner_for(gate) if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: @@ -943,16 +948,25 @@ def root_for(gate): parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None + if parents[_GATE_DEPTH_TO_CHOCK - 1].name != 'compiled' or parents[_GATE_DEPTH_TO_CHOCK].name != '.chock': + return None return parents[_GATE_DEPTH_TO_CHOCK].parent +def repo_root_for(event, gate): + """The repository under judgement: the compiled layout's root, else where the agent works.""" + root = root_for(gate) + if root is not None: + return root + cwd = getattr(event, 'cwd', None) + return _chock_Path(cwd) if cwd else _chock_Path.cwd() + def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) if (event.raw or {}).get('stop_hook_active'): return {} - root = root_for(gate) - return writes_from_worktree(root) if root is not None else {} + return writes_from_worktree(repo_root_for(event, gate)) def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" @@ -963,7 +977,7 @@ def evaluate_gate(argv, event): writes = writes_for(event, gate) if not writes: return None - outcome, message = run_gate(gate, writes, name) + outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate)) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/.chock/bin/codex_cli.py b/.chock/bin/codex_cli.py index bdea4c5..20bd657 100755 --- a/.chock/bin/codex_cli.py +++ b/.chock/bin/codex_cli.py @@ -830,6 +830,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _RUNNER_PARTS = ('bin', 'gate.py') +_PACKAGED_RUNNER = 'gate.py' + _GIT = 'git' _DELETED = 'D' @@ -853,7 +855,10 @@ def gate_path_from_argv(argv): return None def runner_for(gate): - """The vendored gate runner beside this compiled gate, or None when it is not there.""" + """The gate runner: beside the gate in a plugin, under .chock/bin in a repository, else None.""" + packaged = gate.resolve().parent / _PACKAGED_RUNNER + if packaged.exists(): + return packaged parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None @@ -905,13 +910,13 @@ def writes_from_worktree(repo_root): continue return writes -def run_gate(gate, writes, event): +def run_gate(gate, writes, event, root=None): """Ask the vendored runner. Returns (outcome, message) and never decides for itself.""" runner = runner_for(gate) if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: @@ -929,16 +934,25 @@ def root_for(gate): parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None + if parents[_GATE_DEPTH_TO_CHOCK - 1].name != 'compiled' or parents[_GATE_DEPTH_TO_CHOCK].name != '.chock': + return None return parents[_GATE_DEPTH_TO_CHOCK].parent +def repo_root_for(event, gate): + """The repository under judgement: the compiled layout's root, else where the agent works.""" + root = root_for(gate) + if root is not None: + return root + cwd = getattr(event, 'cwd', None) + return _chock_Path(cwd) if cwd else _chock_Path.cwd() + def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) if (event.raw or {}).get('stop_hook_active'): return {} - root = root_for(gate) - return writes_from_worktree(root) if root is not None else {} + return writes_from_worktree(repo_root_for(event, gate)) def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" @@ -949,7 +963,7 @@ def evaluate_gate(argv, event): writes = writes_for(event, gate) if not writes: return None - outcome, message = run_gate(gate, writes, name) + outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate)) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/.chock/bin/cursor.py b/.chock/bin/cursor.py index a4716f3..10d48db 100755 --- a/.chock/bin/cursor.py +++ b/.chock/bin/cursor.py @@ -749,6 +749,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _RUNNER_PARTS = ('bin', 'gate.py') +_PACKAGED_RUNNER = 'gate.py' + _GIT = 'git' _DELETED = 'D' @@ -772,7 +774,10 @@ def gate_path_from_argv(argv): return None def runner_for(gate): - """The vendored gate runner beside this compiled gate, or None when it is not there.""" + """The gate runner: beside the gate in a plugin, under .chock/bin in a repository, else None.""" + packaged = gate.resolve().parent / _PACKAGED_RUNNER + if packaged.exists(): + return packaged parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None @@ -824,13 +829,13 @@ def writes_from_worktree(repo_root): continue return writes -def run_gate(gate, writes, event): +def run_gate(gate, writes, event, root=None): """Ask the vendored runner. Returns (outcome, message) and never decides for itself.""" runner = runner_for(gate) if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: @@ -848,16 +853,25 @@ def root_for(gate): parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None + if parents[_GATE_DEPTH_TO_CHOCK - 1].name != 'compiled' or parents[_GATE_DEPTH_TO_CHOCK].name != '.chock': + return None return parents[_GATE_DEPTH_TO_CHOCK].parent +def repo_root_for(event, gate): + """The repository under judgement: the compiled layout's root, else where the agent works.""" + root = root_for(gate) + if root is not None: + return root + cwd = getattr(event, 'cwd', None) + return _chock_Path(cwd) if cwd else _chock_Path.cwd() + def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) if (event.raw or {}).get('stop_hook_active'): return {} - root = root_for(gate) - return writes_from_worktree(root) if root is not None else {} + return writes_from_worktree(repo_root_for(event, gate)) def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" @@ -868,7 +882,7 @@ def evaluate_gate(argv, event): writes = writes_for(event, gate) if not writes: return None - outcome, message = run_gate(gate, writes, name) + outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate)) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/.chock/bin/devin.py b/.chock/bin/devin.py index cb1e666..53e3899 100755 --- a/.chock/bin/devin.py +++ b/.chock/bin/devin.py @@ -812,6 +812,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _RUNNER_PARTS = ('bin', 'gate.py') +_PACKAGED_RUNNER = 'gate.py' + _GIT = 'git' _DELETED = 'D' @@ -835,7 +837,10 @@ def gate_path_from_argv(argv): return None def runner_for(gate): - """The vendored gate runner beside this compiled gate, or None when it is not there.""" + """The gate runner: beside the gate in a plugin, under .chock/bin in a repository, else None.""" + packaged = gate.resolve().parent / _PACKAGED_RUNNER + if packaged.exists(): + return packaged parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None @@ -887,13 +892,13 @@ def writes_from_worktree(repo_root): continue return writes -def run_gate(gate, writes, event): +def run_gate(gate, writes, event, root=None): """Ask the vendored runner. Returns (outcome, message) and never decides for itself.""" runner = runner_for(gate) if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: @@ -911,16 +916,25 @@ def root_for(gate): parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None + if parents[_GATE_DEPTH_TO_CHOCK - 1].name != 'compiled' or parents[_GATE_DEPTH_TO_CHOCK].name != '.chock': + return None return parents[_GATE_DEPTH_TO_CHOCK].parent +def repo_root_for(event, gate): + """The repository under judgement: the compiled layout's root, else where the agent works.""" + root = root_for(gate) + if root is not None: + return root + cwd = getattr(event, 'cwd', None) + return _chock_Path(cwd) if cwd else _chock_Path.cwd() + def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) if (event.raw or {}).get('stop_hook_active'): return {} - root = root_for(gate) - return writes_from_worktree(root) if root is not None else {} + return writes_from_worktree(repo_root_for(event, gate)) def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" @@ -931,7 +945,7 @@ def evaluate_gate(argv, event): writes = writes_for(event, gate) if not writes: return None - outcome, message = run_gate(gate, writes, name) + outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate)) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/.chock/bin/gate.py b/.chock/bin/gate.py index a043d6c..afa69c3 100755 --- a/.chock/bin/gate.py +++ b/.chock/bin/gate.py @@ -454,6 +454,17 @@ def _log_outcome(gate_path: Path, event: str, spec: dict, result: GateResult) -> AGENT_EVENTS = ("pre-tool-use", "stop") +#: `script_base` value naming the gate file's own directory as where `params.script` lives. +SCRIPT_BASE_GATE = "gate" + + +def _params(gate_path: Path, spec: dict) -> dict: + """The gate's params, with a packaged script gate's program located beside the gate file.""" + params = dict(spec.get("params", {})) + if spec.get("script_base") == SCRIPT_BASE_GATE: + params["script"] = str(gate_path.resolve().parent / str(params.get("script", ""))) + return params + def _context( event: str, @@ -518,7 +529,7 @@ def run( file=sys.stderr, ) return 2 - result = kind(ctx, spec.get("params", {}), name) + result = kind(ctx, _params(gate_path, spec), name) _log_outcome(gate_path, name, spec, result) if not result.allowed: print(result.message or spec.get("message", ""), file=sys.stderr) diff --git a/.chock/bin/gemini_cli.py b/.chock/bin/gemini_cli.py index 9e671e0..b5d9b11 100755 --- a/.chock/bin/gemini_cli.py +++ b/.chock/bin/gemini_cli.py @@ -812,6 +812,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _RUNNER_PARTS = ('bin', 'gate.py') +_PACKAGED_RUNNER = 'gate.py' + _GIT = 'git' _DELETED = 'D' @@ -835,7 +837,10 @@ def gate_path_from_argv(argv): return None def runner_for(gate): - """The vendored gate runner beside this compiled gate, or None when it is not there.""" + """The gate runner: beside the gate in a plugin, under .chock/bin in a repository, else None.""" + packaged = gate.resolve().parent / _PACKAGED_RUNNER + if packaged.exists(): + return packaged parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None @@ -887,13 +892,13 @@ def writes_from_worktree(repo_root): continue return writes -def run_gate(gate, writes, event): +def run_gate(gate, writes, event, root=None): """Ask the vendored runner. Returns (outcome, message) and never decides for itself.""" runner = runner_for(gate) if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: @@ -911,16 +916,25 @@ def root_for(gate): parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None + if parents[_GATE_DEPTH_TO_CHOCK - 1].name != 'compiled' or parents[_GATE_DEPTH_TO_CHOCK].name != '.chock': + return None return parents[_GATE_DEPTH_TO_CHOCK].parent +def repo_root_for(event, gate): + """The repository under judgement: the compiled layout's root, else where the agent works.""" + root = root_for(gate) + if root is not None: + return root + cwd = getattr(event, 'cwd', None) + return _chock_Path(cwd) if cwd else _chock_Path.cwd() + def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) if (event.raw or {}).get('stop_hook_active'): return {} - root = root_for(gate) - return writes_from_worktree(root) if root is not None else {} + return writes_from_worktree(repo_root_for(event, gate)) def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" @@ -931,7 +945,7 @@ def evaluate_gate(argv, event): writes = writes_for(event, gate) if not writes: return None - outcome, message = run_gate(gate, writes, name) + outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate)) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/.chock/bin/grok.py b/.chock/bin/grok.py index d61208d..a742bd2 100755 --- a/.chock/bin/grok.py +++ b/.chock/bin/grok.py @@ -795,6 +795,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _RUNNER_PARTS = ('bin', 'gate.py') +_PACKAGED_RUNNER = 'gate.py' + _GIT = 'git' _DELETED = 'D' @@ -818,7 +820,10 @@ def gate_path_from_argv(argv): return None def runner_for(gate): - """The vendored gate runner beside this compiled gate, or None when it is not there.""" + """The gate runner: beside the gate in a plugin, under .chock/bin in a repository, else None.""" + packaged = gate.resolve().parent / _PACKAGED_RUNNER + if packaged.exists(): + return packaged parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None @@ -870,13 +875,13 @@ def writes_from_worktree(repo_root): continue return writes -def run_gate(gate, writes, event): +def run_gate(gate, writes, event, root=None): """Ask the vendored runner. Returns (outcome, message) and never decides for itself.""" runner = runner_for(gate) if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: @@ -894,16 +899,25 @@ def root_for(gate): parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None + if parents[_GATE_DEPTH_TO_CHOCK - 1].name != 'compiled' or parents[_GATE_DEPTH_TO_CHOCK].name != '.chock': + return None return parents[_GATE_DEPTH_TO_CHOCK].parent +def repo_root_for(event, gate): + """The repository under judgement: the compiled layout's root, else where the agent works.""" + root = root_for(gate) + if root is not None: + return root + cwd = getattr(event, 'cwd', None) + return _chock_Path(cwd) if cwd else _chock_Path.cwd() + def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) if (event.raw or {}).get('stop_hook_active'): return {} - root = root_for(gate) - return writes_from_worktree(root) if root is not None else {} + return writes_from_worktree(repo_root_for(event, gate)) def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" @@ -914,7 +928,7 @@ def evaluate_gate(argv, event): writes = writes_for(event, gate) if not writes: return None - outcome, message = run_gate(gate, writes, name) + outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate)) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/.chock/bin/tabnine.py b/.chock/bin/tabnine.py index b29b527..04aa4fe 100755 --- a/.chock/bin/tabnine.py +++ b/.chock/bin/tabnine.py @@ -795,6 +795,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _RUNNER_PARTS = ('bin', 'gate.py') +_PACKAGED_RUNNER = 'gate.py' + _GIT = 'git' _DELETED = 'D' @@ -818,7 +820,10 @@ def gate_path_from_argv(argv): return None def runner_for(gate): - """The vendored gate runner beside this compiled gate, or None when it is not there.""" + """The gate runner: beside the gate in a plugin, under .chock/bin in a repository, else None.""" + packaged = gate.resolve().parent / _PACKAGED_RUNNER + if packaged.exists(): + return packaged parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None @@ -870,13 +875,13 @@ def writes_from_worktree(repo_root): continue return writes -def run_gate(gate, writes, event): +def run_gate(gate, writes, event, root=None): """Ask the vendored runner. Returns (outcome, message) and never decides for itself.""" runner = runner_for(gate) if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: @@ -894,16 +899,25 @@ def root_for(gate): parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None + if parents[_GATE_DEPTH_TO_CHOCK - 1].name != 'compiled' or parents[_GATE_DEPTH_TO_CHOCK].name != '.chock': + return None return parents[_GATE_DEPTH_TO_CHOCK].parent +def repo_root_for(event, gate): + """The repository under judgement: the compiled layout's root, else where the agent works.""" + root = root_for(gate) + if root is not None: + return root + cwd = getattr(event, 'cwd', None) + return _chock_Path(cwd) if cwd else _chock_Path.cwd() + def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) if (event.raw or {}).get('stop_hook_active'): return {} - root = root_for(gate) - return writes_from_worktree(root) if root is not None else {} + return writes_from_worktree(repo_root_for(event, gate)) def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" @@ -914,7 +928,7 @@ def evaluate_gate(argv, event): writes = writes_for(event, gate) if not writes: return None - outcome, message = run_gate(gate, writes, name) + outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate)) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/.chock/bin/vscode_copilot.py b/.chock/bin/vscode_copilot.py index d0d8d0b..cdba2e6 100755 --- a/.chock/bin/vscode_copilot.py +++ b/.chock/bin/vscode_copilot.py @@ -620,6 +620,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _RUNNER_PARTS = ('bin', 'gate.py') +_PACKAGED_RUNNER = 'gate.py' + _GIT = 'git' _DELETED = 'D' @@ -643,7 +645,10 @@ def gate_path_from_argv(argv): return None def runner_for(gate): - """The vendored gate runner beside this compiled gate, or None when it is not there.""" + """The gate runner: beside the gate in a plugin, under .chock/bin in a repository, else None.""" + packaged = gate.resolve().parent / _PACKAGED_RUNNER + if packaged.exists(): + return packaged parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None @@ -695,13 +700,13 @@ def writes_from_worktree(repo_root): continue return writes -def run_gate(gate, writes, event): +def run_gate(gate, writes, event, root=None): """Ask the vendored runner. Returns (outcome, message) and never decides for itself.""" runner = runner_for(gate) if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: @@ -719,16 +724,25 @@ def root_for(gate): parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None + if parents[_GATE_DEPTH_TO_CHOCK - 1].name != 'compiled' or parents[_GATE_DEPTH_TO_CHOCK].name != '.chock': + return None return parents[_GATE_DEPTH_TO_CHOCK].parent +def repo_root_for(event, gate): + """The repository under judgement: the compiled layout's root, else where the agent works.""" + root = root_for(gate) + if root is not None: + return root + cwd = getattr(event, 'cwd', None) + return _chock_Path(cwd) if cwd else _chock_Path.cwd() + def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) if (event.raw or {}).get('stop_hook_active'): return {} - root = root_for(gate) - return writes_from_worktree(root) if root is not None else {} + return writes_from_worktree(repo_root_for(event, gate)) def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" @@ -739,7 +753,7 @@ def evaluate_gate(argv, event): writes = writes_for(event, gate) if not writes: return None - outcome, message = run_gate(gate, writes, name) + outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate)) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/.chock/bin/windsurf.py b/.chock/bin/windsurf.py index cd17905..3dfd4a5 100755 --- a/.chock/bin/windsurf.py +++ b/.chock/bin/windsurf.py @@ -709,6 +709,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _RUNNER_PARTS = ('bin', 'gate.py') +_PACKAGED_RUNNER = 'gate.py' + _GIT = 'git' _DELETED = 'D' @@ -732,7 +734,10 @@ def gate_path_from_argv(argv): return None def runner_for(gate): - """The vendored gate runner beside this compiled gate, or None when it is not there.""" + """The gate runner: beside the gate in a plugin, under .chock/bin in a repository, else None.""" + packaged = gate.resolve().parent / _PACKAGED_RUNNER + if packaged.exists(): + return packaged parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None @@ -784,13 +789,13 @@ def writes_from_worktree(repo_root): continue return writes -def run_gate(gate, writes, event): +def run_gate(gate, writes, event, root=None): """Ask the vendored runner. Returns (outcome, message) and never decides for itself.""" runner = runner_for(gate) if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: @@ -808,16 +813,25 @@ def root_for(gate): parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None + if parents[_GATE_DEPTH_TO_CHOCK - 1].name != 'compiled' or parents[_GATE_DEPTH_TO_CHOCK].name != '.chock': + return None return parents[_GATE_DEPTH_TO_CHOCK].parent +def repo_root_for(event, gate): + """The repository under judgement: the compiled layout's root, else where the agent works.""" + root = root_for(gate) + if root is not None: + return root + cwd = getattr(event, 'cwd', None) + return _chock_Path(cwd) if cwd else _chock_Path.cwd() + def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) if (event.raw or {}).get('stop_hook_active'): return {} - root = root_for(gate) - return writes_from_worktree(root) if root is not None else {} + return writes_from_worktree(repo_root_for(event, gate)) def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" @@ -828,7 +842,7 @@ def evaluate_gate(argv, event): writes = writes_for(event, gate) if not writes: return None - outcome, message = run_gate(gate, writes, name) + outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate)) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ce951a..f5860b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Chock changelog +## Unreleased + +- **A policy's gate rides in its Claude plugin.** A plugin installs at the agent, not in a + repository, so it carried a command guard or nothing: a gate lived only where `chock sync` + compiled it. `chock plugin build --format claude` now packages a policy whose gate declares + `tool_use` -- the compiled `scripts/gate.json`, the runner beside it as `scripts/gate.py`, + and for `kind: script` the policy's whole `implementations/` under `scripts/`, so a program + that imports from beside itself still does -- wired in `hooks/hooks.json` to the vendor's + recorded write tools at `PreToolUse` and, with no matcher, at `Stop`. The package states the + posture (judges the write, re-reads the turn, refuses when it cannot decide, needs python3) + and the skill claims its hooks. The bundled runtime looks for the runner beside the gate + before the repository layout, and takes the repository from the event's working directory + when the gate is packaged; a packaged script gate says `script_base: gate`, which the runner + reads as "beside me" instead of "under the repository root". Runtime goldens regenerate + (an emitter change, so a minor release). +- **A policy's own skill files.** A policy may carry a `skill/` folder: `skill/body.md` is + appended to its rendered `SKILL.md` after the constraint block, and every other file there + rides in the skill's directory in both the Agent Plugins and the Claude package -- a guided + setup page beside the skill that opens it. `--check` sees a changed or removed file; the + Claude store now owns `skills/`, so a rebuild removes what the policy stopped shipping. + ## 0.10.0 — A script gate's evals replay, and its ambient line names the script by file - **A script gate's evals replay.** `chock check --only evals` runs a staged-files case against diff --git a/docs/agent-plugins.md b/docs/agent-plugins.md index 33e0e5a..1ab6d4b 100644 --- a/docs/agent-plugins.md +++ b/docs/agent-plugins.md @@ -37,11 +37,18 @@ moves; `manifest.yaml` stays the single source of truth and `plugin.json` is gen base/scan-secrets/ ├── manifest.yaml # canonical — hand-authored ├── evals/ # unchanged +├── skill/ # optional, hand-authored: body.md + files the skill ships ├── plugin.json # generated └── skills/scan-secrets/ - └── SKILL.md # generated + ├── SKILL.md # generated; skill/body.md appended after the constraint block + └── ... # every other file under skill/, copied as it is ``` +`skill/` is the one hand-authored input beside the manifest. A policy whose skill needs more +than its rendered constraints -- a guided setup page, a reference document -- puts the words +in `skill/body.md` and the files beside it; the builder renders the first and copies the rest, +and `chock plugin build --check` treats a changed or removed file as drift like any other. + `plugin.json` derives every field from the manifest — `name` from `id`, `license`, `repository` and `author` from `provenance`, `keywords` from `artifact`, `enforcement` and any `compliance.owasp_asi` tags. Nothing is invented, and no field is emitted where the manifest is silent. @@ -194,7 +201,17 @@ real install**: | [chock-codex-plugins](https://github.com/open-coder-ai/chock-codex-plugins) | Codex (after its per-hook trust review) | exit 0 + `permissionDecision` JSON | One guard, one adapter, byte-identical across all four — only the envelope each client -reads differs. The deny dialects are pinned by `tests/test_pretooluse_protocol.py` (every +reads differs. + +The Claude format also carries a policy's **gate** when the gate declares `tool_use`: the +compiled `scripts/gate.json`, the stdlib runner beside it as `scripts/gate.py`, and for +`kind: script` the policy's whole `implementations/` under `scripts/`. `hooks/hooks.json` +runs the same adapter with `--gate` on the vendor's recorded write tools at `PreToolUse` +and, with no matcher, at `Stop`, so a file written through a shell heredoc is judged at the +turn's end. The runtime finds the runner beside the gate and takes the repository from the +event's working directory, which is where `.chock/` config such as a policy's selection file +is read from. A gate that declares only `commit` stays advisory in the plugin: a hook that +could only refuse is not installed. The deny dialects are pinned by `tests/test_pretooluse_protocol.py` (every case there reproduces a witnessed failure), and the probe evidence is recorded in the 0.4.0 CHANGELOG entry. Codex additionally installs every hook **untrusted** until a human approves it, and that trust is bound to a hash of the hook command, so a plugin update silently diff --git a/docs/authoring-policies.md b/docs/authoring-policies.md index d9f7761..854088f 100644 --- a/docs/authoring-policies.md +++ b/docs/authoring-policies.md @@ -9,6 +9,7 @@ types you'll write most. ```text .agents/policies/block-console-log/ ├── manifest.yaml # manifest — always present +├── skill/ # optional: body.md, and any file the packaged skill should ship └── evals/ └── suite.yaml # trigger / negative / behavior / adversarial cases ``` diff --git a/spec/gate-dsl.md b/spec/gate-dsl.md index e286d00..71babce 100644 --- a/spec/gate-dsl.md +++ b/spec/gate-dsl.md @@ -114,6 +114,11 @@ program the same material every kind above reads, as JSON on stdin: {"event": "tool_use", "repo_root": "/path/to/repo", "writes": {"src/App.java": ""}} ``` +A compiled gate may carry `script_base: gate` beside `params`, which the runner reads as "the +script lives beside this gate file" instead of "under the repository root". Only a packaged +plugin writes it: there the gate, the runner and the script travel together and no repository +holds them. + `event` is `commit`, `push` or `tool_use`. `writes` is the staged blobs at `commit` and `push`, and the write itself at `tool_use` -- the file a tool call is about to write, or what the turn left on disk at its end -- so one script serves every surface, and it reaches the diff --git a/spec/policy-spec.md b/spec/policy-spec.md index ae94ba5..a53df28 100644 --- a/spec/policy-spec.md +++ b/spec/policy-spec.md @@ -42,7 +42,7 @@ wiring: .agents/policies// ├── manifest.yaml # contains hook.gate ├── implementations/ # optional: only if the hook needs scripts -└── evals/suite.yaml +└── evals/suite.yaml # + skill/ (optional): body.md joins SKILL.md, other files ship beside it ``` ### rule diff --git a/src/chock/compile/emitters/in_agent.py b/src/chock/compile/emitters/in_agent.py index 7d969b0..9e2a8ee 100644 --- a/src/chock/compile/emitters/in_agent.py +++ b/src/chock/compile/emitters/in_agent.py @@ -9,6 +9,15 @@ from chock import vendors from chock.compile.emitters import DATA_DIR, GUARD_SUFFIXES, policy_rel_path from chock.compile.emitters.advisory import repo_root_from_output +from chock.compile.emitters.in_agent_hooks import ( # noqa: F401 -- re-exported for the plugin emitters + TIMEOUT_SECONDS, + cursor_entry, + cursor_hooks_file, + gate_hooks_map_file, + generic_hooks_file, + hook_entry, + hooks_map_file, +) from chock.emit import write_generated_json from chock.gate.build import build_gate_json from chock.gate.runner import WRITE_PATH_KINDS @@ -34,8 +43,6 @@ def _guard_script(policy_dir: Path, policy_id: str) -> str | None: return None -TIMEOUT_SECONDS = 30 - #: claude_code's own recorded shell vocabulary, used for its claude-plugin hooks file. MATCHER = vendors.shell_matcher("claude_code") assert MATCHER is not None # noqa: S101 -- import-time upstream-data invariant, not request handling @@ -77,59 +84,13 @@ def _compiled_rel(policy_id: str) -> str: GENERIC_VENDORS = tuple(v for v in vendors.in_agent_vendors() if v not in BESPOKE_VENDORS) -def generic_hooks_file(vendor: str, command: str) -> dict[str, Any]: - """`vendor`'s full hook-config document for one guard command, agentseam's rendering. - - Paths inside `command` are repo-relative: no repo-root token is recorded upstream for - these vendors (the `${CLAUDE_PROJECT_DIR}` gap), so the entry resolves only where the - vendor runs hooks from the repo root -- the same condition under which the relative - adapter path resolves at all. - """ - return vendors.pre_tool_hook_config(vendor, command, matcher=vendors.shell_matcher(vendor)) - - -def hook_entry(command: str, *, matcher: str | None = None) -> dict[str, Any]: - """One hooks-map entry (agentseam's `hooks_map` wrapper shape) plus chock's timeout.""" - entry: dict[str, Any] = {} - if matcher is not None: - entry["matcher"] = matcher - entry["hooks"] = [{"type": "command", "command": command, "timeout": TIMEOUT_SECONDS}] - return entry - - -def hooks_map_file(vendor: str, command: str) -> dict[str, Any]: - """A hooks file under `vendor`'s own pre-tool event spelling. - - Wrapped in a top-level `hooks` key (the claude-plugin format) unless `vendor`'s own - hook_entry is bare -- Devin's native `hooks.json` at the plugin root is the event map - itself, with no wrapper, unlike the nested `hooks/hooks.json` every other format here - shares. - """ - matcher = vendors.shell_matcher(vendor) - event_map = {vendors.pre_tool_event(vendor): [hook_entry(command, matcher=matcher)]} - return event_map if vendors.hook_entry_bare(vendor) else {"hooks": event_map} - - -def cursor_entry(command: str) -> dict[str, Any]: - """One cursor hook entry: the flat `cursor` wrapper shape plus chock's timeout.""" - return {"command": command, "timeout": TIMEOUT_SECONDS} - - -def cursor_hooks_file(command: str) -> dict[str, Any]: - """A cursor-format hooks file: envelope and shell-gate event from the vendor entry.""" - return { - **vendors.config_envelope("cursor"), - "hooks": {vendors.shell_gate_event("cursor"): [cursor_entry(command)]}, - } - - GATE_FILE = "gate.json" #: A gate reaches this surface only when the policy asked for this event by name. TOOL_USE = "tool_use" -def _tool_use_gate(policy_dir: Path, output_dir: Path) -> dict[str, Any] | None: +def tool_use_gate_spec(policy_dir: Path, repo_root: Path) -> dict[str, Any] | None: """The compiled gate this policy wants run at tool use, or None when it wants none. Three ways to want none, each the policy's own statement rather than a judgement made @@ -137,12 +98,16 @@ def _tool_use_gate(policy_dir: Path, output_dir: Path) -> dict[str, Any] | None: asking a question a write cannot answer. The runner refuses that last case anyway, so emitting a hook certain to refuse would be installing noise. """ - spec = build_gate_json(policy_dir, repo_root_from_output(output_dir)) + spec = build_gate_json(policy_dir, repo_root) if spec is None or TOOL_USE not in spec.get("on", []): return None return spec if spec.get("kind") in WRITE_PATH_KINDS else None +def _tool_use_gate(policy_dir: Path, output_dir: Path) -> dict[str, Any] | None: + return tool_use_gate_spec(policy_dir, repo_root_from_output(output_dir)) + + def _guard_fragments(policy_dir: Path, script: str, output_dir: Path) -> list[Path]: """The shell-command fragments, one per wired vendor. Behaviour unchanged.""" rel = policy_rel_path(policy_dir) diff --git a/src/chock/compile/emitters/in_agent_hooks.py b/src/chock/compile/emitters/in_agent_hooks.py new file mode 100644 index 0000000..b661b89 --- /dev/null +++ b/src/chock/compile/emitters/in_agent_hooks.py @@ -0,0 +1,69 @@ +"""The hook-config documents an in-agent surface installs: one entry shape, per vendor dialect.""" + +from __future__ import annotations + +from typing import Any + +from chock import vendors + +TIMEOUT_SECONDS = 30 + + +def generic_hooks_file(vendor: str, command: str) -> dict[str, Any]: + """`vendor`'s full hook-config document for one guard command, agentseam's rendering. + + Paths inside `command` are repo-relative: no repo-root token is recorded upstream for + these vendors (the `${CLAUDE_PROJECT_DIR}` gap), so the entry resolves only where the + vendor runs hooks from the repo root -- the same condition under which the relative + adapter path resolves at all. + """ + return vendors.pre_tool_hook_config(vendor, command, matcher=vendors.shell_matcher(vendor)) + + +def hook_entry(command: str, *, matcher: str | None = None) -> dict[str, Any]: + """One hooks-map entry (agentseam's `hooks_map` wrapper shape) plus chock's timeout.""" + entry: dict[str, Any] = {} + if matcher is not None: + entry["matcher"] = matcher + entry["hooks"] = [{"type": "command", "command": command, "timeout": TIMEOUT_SECONDS}] + return entry + + +def hooks_map_file(vendor: str, command: str) -> dict[str, Any]: + """A hooks file under `vendor`'s own pre-tool event spelling. + + Wrapped in a top-level `hooks` key (the claude-plugin format) unless `vendor`'s own + hook_entry is bare -- Devin's native `hooks.json` at the plugin root is the event map + itself, with no wrapper, unlike the nested `hooks/hooks.json` every other format here + shares. + """ + matcher = vendors.shell_matcher(vendor) + event_map = {vendors.pre_tool_event(vendor): [hook_entry(command, matcher=matcher)]} + return event_map if vendors.hook_entry_bare(vendor) else {"hooks": event_map} + + +def cursor_entry(command: str) -> dict[str, Any]: + """One cursor hook entry: the flat `cursor` wrapper shape plus chock's timeout.""" + return {"command": command, "timeout": TIMEOUT_SECONDS} + + +def cursor_hooks_file(command: str) -> dict[str, Any]: + """A cursor-format hooks file: envelope and shell-gate event from the vendor entry.""" + return { + **vendors.config_envelope("cursor"), + "hooks": {vendors.shell_gate_event("cursor"): [cursor_entry(command)]}, + } + + +def gate_hooks_map_file(vendor: str, command: str) -> dict[str, Any]: + """A hooks file running `command` on `vendor`'s recorded write tools and at the turn's end. + + The write matcher is the vendor's own recorded vocabulary; a vendor recording none gets + no write entry rather than an invented one. The turn-end entry carries no matcher. + """ + entries: dict[str, list[dict[str, Any]]] = {} + matcher = vendors.write_matcher(vendor) + if matcher is not None: + entries[vendors.pre_tool_event(vendor)] = [hook_entry(command, matcher=matcher)] + entries[vendors.stop_event(vendor)] = [hook_entry(command)] + return entries if vendors.hook_entry_bare(vendor) else {"hooks": entries} diff --git a/src/chock/gate/runner.py b/src/chock/gate/runner.py index a043d6c..afa69c3 100644 --- a/src/chock/gate/runner.py +++ b/src/chock/gate/runner.py @@ -454,6 +454,17 @@ def _log_outcome(gate_path: Path, event: str, spec: dict, result: GateResult) -> AGENT_EVENTS = ("pre-tool-use", "stop") +#: `script_base` value naming the gate file's own directory as where `params.script` lives. +SCRIPT_BASE_GATE = "gate" + + +def _params(gate_path: Path, spec: dict) -> dict: + """The gate's params, with a packaged script gate's program located beside the gate file.""" + params = dict(spec.get("params", {})) + if spec.get("script_base") == SCRIPT_BASE_GATE: + params["script"] = str(gate_path.resolve().parent / str(params.get("script", ""))) + return params + def _context( event: str, @@ -518,7 +529,7 @@ def run( file=sys.stderr, ) return 2 - result = kind(ctx, spec.get("params", {}), name) + result = kind(ctx, _params(gate_path, spec), name) _log_outcome(gate_path, name, spec, result) if not result.allowed: print(result.message or spec.get("message", ""), file=sys.stderr) diff --git a/src/chock/gate/write_gate.py b/src/chock/gate/write_gate.py index ef3390c..bf3b331 100644 --- a/src/chock/gate/write_gate.py +++ b/src/chock/gate/write_gate.py @@ -25,6 +25,9 @@ #: a walk upwards could find a different repository's .chock on a nested checkout. _GATE_DEPTH_TO_CHOCK = 3 _RUNNER_PARTS = ("bin", "gate.py") +#: A plugin package lays the runner beside its gate instead; the repository root is then the +#: agent's working directory, which the event carries. +_PACKAGED_RUNNER = "gate.py" _GIT = "git" #: git status codes: a deletion leaves no content to judge, a rename is followed by its old path. @@ -48,7 +51,10 @@ def gate_path_from_argv(argv): def runner_for(gate): - """The vendored gate runner beside this compiled gate, or None when it is not there.""" + """The gate runner: beside the gate in a plugin, under .chock/bin in a repository, else None.""" + packaged = gate.resolve().parent / _PACKAGED_RUNNER + if packaged.exists(): + return packaged parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None @@ -110,7 +116,7 @@ def writes_from_worktree(repo_root): return writes -def run_gate(gate, writes, event): +def run_gate(gate, writes, event, root=None): """Ask the vendored runner. Returns (outcome, message) and never decides for itself.""" runner = runner_for(gate) if runner is None: @@ -123,6 +129,7 @@ def run_gate(gate, writes, event): text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, + cwd=str(root) if root is not None else None, ) except (OSError, subprocess.SubprocessError) as exc: return GATE_ERRORED, str(exc) @@ -146,9 +153,20 @@ def root_for(gate): parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None + if parents[_GATE_DEPTH_TO_CHOCK - 1].name != "compiled" or parents[_GATE_DEPTH_TO_CHOCK].name != ".chock": + return None return parents[_GATE_DEPTH_TO_CHOCK].parent +def repo_root_for(event, gate): + """The repository under judgement: the compiled layout's root, else where the agent works.""" + root = root_for(gate) + if root is not None: + return root + cwd = getattr(event, "cwd", None) + return Path(cwd) if cwd else Path.cwd() + + def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: @@ -156,8 +174,7 @@ def writes_for(event, gate): if (event.raw or {}).get("stop_hook_active"): # A refusal that re-entered its own stop hook would never terminate. return {} - root = root_for(gate) - return writes_from_worktree(root) if root is not None else {} + return writes_from_worktree(repo_root_for(event, gate)) def evaluate_gate(argv, event): @@ -169,7 +186,7 @@ def evaluate_gate(argv, event): writes = writes_for(event, gate) if not writes: return None - outcome, message = run_gate(gate, writes, name) + outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate)) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f"Blocked by chock policy: {gate.parent.parent.name}") if outcome == GATE_ERRORED: diff --git a/src/chock/plugin/build.py b/src/chock/plugin/build.py index 735897c..af25170 100644 --- a/src/chock/plugin/build.py +++ b/src/chock/plugin/build.py @@ -112,6 +112,31 @@ def build_manifest(manifest: dict[str, Any], policy_dir: Path) -> dict[str, Any] ) +#: A policy's own words for its skill, and the files it wants beside them: `skill/body.md` +#: is appended to the rendered `SKILL.md`; every other file under `skill/` is copied into +#: the skill's directory as it is. Optional, and most policies carry neither. +SKILL_DIR = "skill" +SKILL_BODY = "body.md" + + +def skill_body(policy_dir: Path) -> str: + """The policy's own skill section, or empty when it wrote none.""" + body = Path(policy_dir) / SKILL_DIR / SKILL_BODY + return body.read_text(encoding="utf-8").strip() if body.is_file() else "" + + +def skill_assets(policy_dir: Path) -> dict[Path, str]: + """Files the policy ships beside its `SKILL.md`, keyed by their path inside the skill.""" + root = Path(policy_dir) / SKILL_DIR + if not root.is_dir(): + return {} + return { + path.relative_to(root): path.read_text(encoding="utf-8") + for path in sorted(root.rglob("*")) + if path.is_file() and path.name != SKILL_BODY and "__pycache__" not in path.parts + } + + def build_skill(policy_dir: Path, manifest: dict[str, Any], repo_root: Path, hooks: str | None = None) -> str: """Render `SKILL.md` for a policy.""" policy_id = manifest.get("id") or Path(policy_dir).name @@ -122,6 +147,8 @@ def build_skill(policy_dir: Path, manifest: dict[str, Any], repo_root: Path, hoo coverage_line = f" chock.hooks: {hooks}\n" if hooks else " chock.coverage_without_chock: advisory\n" advisory_note = _ADVISORY_NOTE_HOOK if (manifest.get("artifact") == "hook") else _ADVISORY_NOTE_RULE + own = skill_body(policy_dir) + own_section = f"{own}\n\n" if own else "" return ( "---\n" f"name: {policy_id}\n" @@ -140,6 +167,7 @@ def build_skill(policy_dir: Path, manifest: dict[str, Any], repo_root: Path, hoo f"{body}\n" "```\n" "\n" + f"{own_section}" f"{advisory_note}\n" ) @@ -154,6 +182,8 @@ def plugin_files( Path("plugin.json"): json.dumps(build_manifest(manifest, policy_dir), indent=2) + "\n", Path("skills") / name / "SKILL.md": build_skill(policy_dir, manifest, repo_root), } + for rel, content in skill_assets(policy_dir).items(): + files[Path("skills") / name / rel] = content licence = license_text(manifest) if packaged else None if licence: files[LICENSE_REL] = licence diff --git a/src/chock/plugin/claude.py b/src/chock/plugin/claude.py index 685d7d4..8785bad 100644 --- a/src/chock/plugin/claude.py +++ b/src/chock/plugin/claude.py @@ -8,8 +8,15 @@ from agentseam import packaging -from chock.compile.emitters.in_agent import _guard_script, hooks_map_file +from chock.compile.emitters.in_agent import ( + GATE_FILE, + _guard_script, + gate_hooks_map_file, + hooks_map_file, + tool_use_gate_spec, +) from chock.gate import runtime_bundle +from chock.gate.runner import SCRIPT_BASE_GATE from chock.plugin import store from chock.plugin.build import ( _ADVISORY_NOTE_HOOK, @@ -21,6 +28,7 @@ build_skill, license_text, plugin_name, + skill_assets, ) from chock.plugin.store import SCRIPTS_TEMPLATE as _SCRIPTS_TEMPLATE @@ -32,6 +40,13 @@ "disable the python3 Store alias or install Python. If the guard itself crashes or times " "out, the hook asks for confirmation rather than allowing silently." ) +POSTURE_ENFORCED_GATE = ( + "Session-enforced via PreToolUse and Stop hooks; needs python3. PreToolUse judges the file " + "a tool call would write; Stop re-reads what the turn actually left on disk, so a file " + "written through a shell heredoc is judged too. Without python3, fail-open clients allow " + "silently. A gate that cannot reach a decision refuses rather than allowing one it never " + "judged. Enforcement at every commit and in CI still needs chock installed in the repo." +) POSTURE_ADVISORY = "Advisory skill only; enforcement needs chock installed in the repo." _ENFORCED_NOTE = ( @@ -40,6 +55,19 @@ "enforcement across every commit and in CI still needs `chock sync`. " "See https://github.com/open-coder-ai/chock" ) +_ENFORCED_GATE_NOTE = ( + "This policy is enforced in this client by the PreToolUse and Stop hooks installed with " + "the plugin, subject to the fail conditions stated in the plugin description. Repo-wide " + "enforcement across every commit and in CI still needs `chock sync`. " + "See https://github.com/open-coder-ai/chock" +) + +#: Where a packaged gate and everything it runs live inside the plugin: the compiled gate, +#: the runner beside it (write_gate looks there first), and a script gate's own program with +#: the files it imports, copied whole so a `sys.path` it sets on its own directory still holds. +_GATE_REL = _SCRIPTS_TEMPLATE.format(name=GATE_FILE) +_RUNNER_REL = _SCRIPTS_TEMPLATE.format(name="gate.py") +_IMPLEMENTATIONS = "implementations" def _adapter_source(agent: str = "claude_code") -> str: @@ -54,11 +82,42 @@ def _hook_command(script: str) -> str: return f'python3 "{adapter}" --guard "{guard}"' -def build_claude_manifest(manifest: dict[str, Any], policy_dir: Path, *, enforced: bool) -> dict[str, Any]: +def _gate_command() -> str: + """The same adapter, handed the packaged gate instead of a guard.""" + adapter = packaging.executable_ref("claude_code", _SCRIPTS_TEMPLATE.format(name="claude_code.py")) + gate = packaging.executable_ref("claude_code", _GATE_REL) + return f'python3 "{adapter}" --gate "{gate}"' + + +def _runner_source() -> str: + """The stdlib-only gate runner, verbatim -- the one `chock sync` vendors under .chock/bin.""" + return (Path(runtime_bundle.__file__).resolve().parent / "runner.py").read_text(encoding="utf-8") + + +def _packaged_gate(policy_dir: Path, spec: dict[str, Any]) -> tuple[dict[str, Any], dict[Path, str]]: + """The gate as the plugin carries it, plus the files a script gate needs beside it.""" + packaged = {key: value for key, value in spec.items() if key != "params"} + packaged["params"] = dict(spec.get("params") or {}) + files: dict[Path, str] = {} + if spec.get("kind") == "script": + name = Path(str(packaged["params"].get("script", ""))).name + packaged["params"]["script"] = f"{_IMPLEMENTATIONS}/{name}" + packaged["script_base"] = SCRIPT_BASE_GATE + root = Path(policy_dir) / _IMPLEMENTATIONS + for path in sorted(root.rglob("*")): + if path.is_file() and "__pycache__" not in path.parts: + rel = Path(_IMPLEMENTATIONS) / path.relative_to(root) + files[Path(_SCRIPTS_TEMPLATE.format(name=rel.as_posix()))] = path.read_text(encoding="utf-8") + return packaged, files + + +def build_claude_manifest( + manifest: dict[str, Any], policy_dir: Path, *, enforced: bool, gate: bool = False +) -> dict[str, Any]: """Derive `.claude-plugin/plugin.json` from a policy manifest.""" policy_id = manifest.get("id") or Path(policy_dir).name provenance = manifest.get("provenance") or {} - posture = POSTURE_ENFORCED if enforced else POSTURE_ADVISORY + posture = (POSTURE_ENFORCED_GATE if gate else POSTURE_ENFORCED) if enforced else POSTURE_ADVISORY data: dict[str, Any] = { "name": plugin_name(str(policy_id)), @@ -83,28 +142,44 @@ def claude_plugin_files(policy_dir: Path, manifest: dict[str, Any], repo_root: P policy_id = manifest.get("id") or policy_dir.name name = plugin_name(str(policy_id)) script = _guard_script(policy_dir, str(policy_id)) + gate = None if script else tool_use_gate_spec(policy_dir, Path(repo_root)) + enforced = script is not None or gate is not None - skill = build_skill(policy_dir, manifest, Path(repo_root), hooks="hooks/hooks.json" if script else None) + skill = build_skill(policy_dir, manifest, Path(repo_root), hooks="hooks/hooks.json" if enforced else None) if script: skill = skill.replace(_ADVISORY_NOTE_RULE, _ENFORCED_NOTE).replace(_ADVISORY_NOTE_HOOK, _ENFORCED_NOTE) + elif gate: + skill = skill.replace(_ADVISORY_NOTE_RULE, _ENFORCED_GATE_NOTE).replace( + _ADVISORY_NOTE_HOOK, _ENFORCED_GATE_NOTE + ) + skill_rel = Path(packaging.supports("claude_code", packaging.SKILL).format(name=name)) files: dict[Path, str] = { Path(_MANIFEST_REL): json.dumps( - build_claude_manifest(manifest, policy_dir, enforced=script is not None), indent=2 + build_claude_manifest(manifest, policy_dir, enforced=enforced, gate=gate is not None), indent=2 ) + "\n", - Path(packaging.supports("claude_code", packaging.SKILL).format(name=name)): skill, + skill_rel: skill, } + for rel, content in skill_assets(policy_dir).items(): + files[skill_rel.parent / rel] = content licence = license_text(manifest) if licence: files[LICENSE_REL] = licence + hooks_rel = Path(packaging.supports("claude_code", packaging.HOOKS)) if script: - hooks_rel = packaging.supports("claude_code", packaging.HOOKS) - files[Path(hooks_rel)] = json.dumps(hooks_map_file("claude_code", _hook_command(script)), indent=2) + "\n" + files[hooks_rel] = json.dumps(hooks_map_file("claude_code", _hook_command(script)), indent=2) + "\n" files[Path(_SCRIPTS_TEMPLATE.format(name="claude_code.py"))] = _adapter_source("claude_code") - files[Path(_SCRIPTS_TEMPLATE.format(name=script))] = (policy_dir / "implementations" / script).read_text( + files[Path(_SCRIPTS_TEMPLATE.format(name=script))] = (policy_dir / _IMPLEMENTATIONS / script).read_text( encoding="utf-8" ) + elif gate: + packaged, carried = _packaged_gate(policy_dir, gate) + files[hooks_rel] = json.dumps(gate_hooks_map_file("claude_code", _gate_command()), indent=2) + "\n" + files[Path(_SCRIPTS_TEMPLATE.format(name="claude_code.py"))] = _adapter_source("claude_code") + files[Path(_RUNNER_REL)] = _runner_source() + files[Path(_GATE_REL)] = json.dumps(packaged, indent=2) + "\n" + files.update(carried) return files diff --git a/src/chock/plugin/cli.py b/src/chock/plugin/cli.py index ae063c3..f8db481 100644 --- a/src/chock/plugin/cli.py +++ b/src/chock/plugin/cli.py @@ -210,7 +210,8 @@ def main(argv: list[str] | None = None) -> int: print(f" plugin.json + skills//SKILL.md per policy; enforcement metadata under {NAMESPACE}") if "claude" in formats: print( - " .claude-plugin/plugin.json + hooks/ + scripts/ per guard policy; fail posture stated in each description" + " .claude-plugin/plugin.json + hooks/ + scripts/ per guard policy and per policy gating tool_use; " + "fail posture stated in each description" ) if "copilot" in formats: print(" root plugin.json + com.github.copilot/hooks/ per guard policy; same posture discipline") diff --git a/src/chock/plugin/data/stores/claude.json b/src/chock/plugin/data/stores/claude.json index 9ca6320..b97d8c7 100644 --- a/src/chock/plugin/data/stores/claude.json +++ b/src/chock/plugin/data/stores/claude.json @@ -1,3 +1,3 @@ { - "owned_subtrees": ["hooks", "scripts"] + "owned_subtrees": ["hooks", "scripts", "skills"] } diff --git a/tests/fixtures/runtime_goldens/antigravity.py b/tests/fixtures/runtime_goldens/antigravity.py index b2c7877..16f5823 100644 --- a/tests/fixtures/runtime_goldens/antigravity.py +++ b/tests/fixtures/runtime_goldens/antigravity.py @@ -778,6 +778,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _RUNNER_PARTS = ('bin', 'gate.py') +_PACKAGED_RUNNER = 'gate.py' + _GIT = 'git' _DELETED = 'D' @@ -801,7 +803,10 @@ def gate_path_from_argv(argv): return None def runner_for(gate): - """The vendored gate runner beside this compiled gate, or None when it is not there.""" + """The gate runner: beside the gate in a plugin, under .chock/bin in a repository, else None.""" + packaged = gate.resolve().parent / _PACKAGED_RUNNER + if packaged.exists(): + return packaged parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None @@ -853,13 +858,13 @@ def writes_from_worktree(repo_root): continue return writes -def run_gate(gate, writes, event): +def run_gate(gate, writes, event, root=None): """Ask the vendored runner. Returns (outcome, message) and never decides for itself.""" runner = runner_for(gate) if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: @@ -877,16 +882,25 @@ def root_for(gate): parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None + if parents[_GATE_DEPTH_TO_CHOCK - 1].name != 'compiled' or parents[_GATE_DEPTH_TO_CHOCK].name != '.chock': + return None return parents[_GATE_DEPTH_TO_CHOCK].parent +def repo_root_for(event, gate): + """The repository under judgement: the compiled layout's root, else where the agent works.""" + root = root_for(gate) + if root is not None: + return root + cwd = getattr(event, 'cwd', None) + return _chock_Path(cwd) if cwd else _chock_Path.cwd() + def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) if (event.raw or {}).get('stop_hook_active'): return {} - root = root_for(gate) - return writes_from_worktree(root) if root is not None else {} + return writes_from_worktree(repo_root_for(event, gate)) def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" @@ -897,7 +911,7 @@ def evaluate_gate(argv, event): writes = writes_for(event, gate) if not writes: return None - outcome, message = run_gate(gate, writes, name) + outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate)) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/tests/fixtures/runtime_goldens/claude_code.py b/tests/fixtures/runtime_goldens/claude_code.py index d8f56f9..2f67f78 100644 --- a/tests/fixtures/runtime_goldens/claude_code.py +++ b/tests/fixtures/runtime_goldens/claude_code.py @@ -844,6 +844,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _RUNNER_PARTS = ('bin', 'gate.py') +_PACKAGED_RUNNER = 'gate.py' + _GIT = 'git' _DELETED = 'D' @@ -867,7 +869,10 @@ def gate_path_from_argv(argv): return None def runner_for(gate): - """The vendored gate runner beside this compiled gate, or None when it is not there.""" + """The gate runner: beside the gate in a plugin, under .chock/bin in a repository, else None.""" + packaged = gate.resolve().parent / _PACKAGED_RUNNER + if packaged.exists(): + return packaged parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None @@ -919,13 +924,13 @@ def writes_from_worktree(repo_root): continue return writes -def run_gate(gate, writes, event): +def run_gate(gate, writes, event, root=None): """Ask the vendored runner. Returns (outcome, message) and never decides for itself.""" runner = runner_for(gate) if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: @@ -943,16 +948,25 @@ def root_for(gate): parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None + if parents[_GATE_DEPTH_TO_CHOCK - 1].name != 'compiled' or parents[_GATE_DEPTH_TO_CHOCK].name != '.chock': + return None return parents[_GATE_DEPTH_TO_CHOCK].parent +def repo_root_for(event, gate): + """The repository under judgement: the compiled layout's root, else where the agent works.""" + root = root_for(gate) + if root is not None: + return root + cwd = getattr(event, 'cwd', None) + return _chock_Path(cwd) if cwd else _chock_Path.cwd() + def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) if (event.raw or {}).get('stop_hook_active'): return {} - root = root_for(gate) - return writes_from_worktree(root) if root is not None else {} + return writes_from_worktree(repo_root_for(event, gate)) def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" @@ -963,7 +977,7 @@ def evaluate_gate(argv, event): writes = writes_for(event, gate) if not writes: return None - outcome, message = run_gate(gate, writes, name) + outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate)) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/tests/fixtures/runtime_goldens/codex_cli.py b/tests/fixtures/runtime_goldens/codex_cli.py index bdea4c5..20bd657 100644 --- a/tests/fixtures/runtime_goldens/codex_cli.py +++ b/tests/fixtures/runtime_goldens/codex_cli.py @@ -830,6 +830,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _RUNNER_PARTS = ('bin', 'gate.py') +_PACKAGED_RUNNER = 'gate.py' + _GIT = 'git' _DELETED = 'D' @@ -853,7 +855,10 @@ def gate_path_from_argv(argv): return None def runner_for(gate): - """The vendored gate runner beside this compiled gate, or None when it is not there.""" + """The gate runner: beside the gate in a plugin, under .chock/bin in a repository, else None.""" + packaged = gate.resolve().parent / _PACKAGED_RUNNER + if packaged.exists(): + return packaged parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None @@ -905,13 +910,13 @@ def writes_from_worktree(repo_root): continue return writes -def run_gate(gate, writes, event): +def run_gate(gate, writes, event, root=None): """Ask the vendored runner. Returns (outcome, message) and never decides for itself.""" runner = runner_for(gate) if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: @@ -929,16 +934,25 @@ def root_for(gate): parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None + if parents[_GATE_DEPTH_TO_CHOCK - 1].name != 'compiled' or parents[_GATE_DEPTH_TO_CHOCK].name != '.chock': + return None return parents[_GATE_DEPTH_TO_CHOCK].parent +def repo_root_for(event, gate): + """The repository under judgement: the compiled layout's root, else where the agent works.""" + root = root_for(gate) + if root is not None: + return root + cwd = getattr(event, 'cwd', None) + return _chock_Path(cwd) if cwd else _chock_Path.cwd() + def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) if (event.raw or {}).get('stop_hook_active'): return {} - root = root_for(gate) - return writes_from_worktree(root) if root is not None else {} + return writes_from_worktree(repo_root_for(event, gate)) def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" @@ -949,7 +963,7 @@ def evaluate_gate(argv, event): writes = writes_for(event, gate) if not writes: return None - outcome, message = run_gate(gate, writes, name) + outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate)) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/tests/fixtures/runtime_goldens/cursor.py b/tests/fixtures/runtime_goldens/cursor.py index a4716f3..10d48db 100644 --- a/tests/fixtures/runtime_goldens/cursor.py +++ b/tests/fixtures/runtime_goldens/cursor.py @@ -749,6 +749,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _RUNNER_PARTS = ('bin', 'gate.py') +_PACKAGED_RUNNER = 'gate.py' + _GIT = 'git' _DELETED = 'D' @@ -772,7 +774,10 @@ def gate_path_from_argv(argv): return None def runner_for(gate): - """The vendored gate runner beside this compiled gate, or None when it is not there.""" + """The gate runner: beside the gate in a plugin, under .chock/bin in a repository, else None.""" + packaged = gate.resolve().parent / _PACKAGED_RUNNER + if packaged.exists(): + return packaged parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None @@ -824,13 +829,13 @@ def writes_from_worktree(repo_root): continue return writes -def run_gate(gate, writes, event): +def run_gate(gate, writes, event, root=None): """Ask the vendored runner. Returns (outcome, message) and never decides for itself.""" runner = runner_for(gate) if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: @@ -848,16 +853,25 @@ def root_for(gate): parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None + if parents[_GATE_DEPTH_TO_CHOCK - 1].name != 'compiled' or parents[_GATE_DEPTH_TO_CHOCK].name != '.chock': + return None return parents[_GATE_DEPTH_TO_CHOCK].parent +def repo_root_for(event, gate): + """The repository under judgement: the compiled layout's root, else where the agent works.""" + root = root_for(gate) + if root is not None: + return root + cwd = getattr(event, 'cwd', None) + return _chock_Path(cwd) if cwd else _chock_Path.cwd() + def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) if (event.raw or {}).get('stop_hook_active'): return {} - root = root_for(gate) - return writes_from_worktree(root) if root is not None else {} + return writes_from_worktree(repo_root_for(event, gate)) def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" @@ -868,7 +882,7 @@ def evaluate_gate(argv, event): writes = writes_for(event, gate) if not writes: return None - outcome, message = run_gate(gate, writes, name) + outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate)) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/tests/fixtures/runtime_goldens/devin.py b/tests/fixtures/runtime_goldens/devin.py index cb1e666..53e3899 100644 --- a/tests/fixtures/runtime_goldens/devin.py +++ b/tests/fixtures/runtime_goldens/devin.py @@ -812,6 +812,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _RUNNER_PARTS = ('bin', 'gate.py') +_PACKAGED_RUNNER = 'gate.py' + _GIT = 'git' _DELETED = 'D' @@ -835,7 +837,10 @@ def gate_path_from_argv(argv): return None def runner_for(gate): - """The vendored gate runner beside this compiled gate, or None when it is not there.""" + """The gate runner: beside the gate in a plugin, under .chock/bin in a repository, else None.""" + packaged = gate.resolve().parent / _PACKAGED_RUNNER + if packaged.exists(): + return packaged parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None @@ -887,13 +892,13 @@ def writes_from_worktree(repo_root): continue return writes -def run_gate(gate, writes, event): +def run_gate(gate, writes, event, root=None): """Ask the vendored runner. Returns (outcome, message) and never decides for itself.""" runner = runner_for(gate) if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: @@ -911,16 +916,25 @@ def root_for(gate): parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None + if parents[_GATE_DEPTH_TO_CHOCK - 1].name != 'compiled' or parents[_GATE_DEPTH_TO_CHOCK].name != '.chock': + return None return parents[_GATE_DEPTH_TO_CHOCK].parent +def repo_root_for(event, gate): + """The repository under judgement: the compiled layout's root, else where the agent works.""" + root = root_for(gate) + if root is not None: + return root + cwd = getattr(event, 'cwd', None) + return _chock_Path(cwd) if cwd else _chock_Path.cwd() + def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) if (event.raw or {}).get('stop_hook_active'): return {} - root = root_for(gate) - return writes_from_worktree(root) if root is not None else {} + return writes_from_worktree(repo_root_for(event, gate)) def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" @@ -931,7 +945,7 @@ def evaluate_gate(argv, event): writes = writes_for(event, gate) if not writes: return None - outcome, message = run_gate(gate, writes, name) + outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate)) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/tests/fixtures/runtime_goldens/gemini_cli.py b/tests/fixtures/runtime_goldens/gemini_cli.py index 9e671e0..b5d9b11 100644 --- a/tests/fixtures/runtime_goldens/gemini_cli.py +++ b/tests/fixtures/runtime_goldens/gemini_cli.py @@ -812,6 +812,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _RUNNER_PARTS = ('bin', 'gate.py') +_PACKAGED_RUNNER = 'gate.py' + _GIT = 'git' _DELETED = 'D' @@ -835,7 +837,10 @@ def gate_path_from_argv(argv): return None def runner_for(gate): - """The vendored gate runner beside this compiled gate, or None when it is not there.""" + """The gate runner: beside the gate in a plugin, under .chock/bin in a repository, else None.""" + packaged = gate.resolve().parent / _PACKAGED_RUNNER + if packaged.exists(): + return packaged parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None @@ -887,13 +892,13 @@ def writes_from_worktree(repo_root): continue return writes -def run_gate(gate, writes, event): +def run_gate(gate, writes, event, root=None): """Ask the vendored runner. Returns (outcome, message) and never decides for itself.""" runner = runner_for(gate) if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: @@ -911,16 +916,25 @@ def root_for(gate): parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None + if parents[_GATE_DEPTH_TO_CHOCK - 1].name != 'compiled' or parents[_GATE_DEPTH_TO_CHOCK].name != '.chock': + return None return parents[_GATE_DEPTH_TO_CHOCK].parent +def repo_root_for(event, gate): + """The repository under judgement: the compiled layout's root, else where the agent works.""" + root = root_for(gate) + if root is not None: + return root + cwd = getattr(event, 'cwd', None) + return _chock_Path(cwd) if cwd else _chock_Path.cwd() + def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) if (event.raw or {}).get('stop_hook_active'): return {} - root = root_for(gate) - return writes_from_worktree(root) if root is not None else {} + return writes_from_worktree(repo_root_for(event, gate)) def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" @@ -931,7 +945,7 @@ def evaluate_gate(argv, event): writes = writes_for(event, gate) if not writes: return None - outcome, message = run_gate(gate, writes, name) + outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate)) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/tests/fixtures/runtime_goldens/grok.py b/tests/fixtures/runtime_goldens/grok.py index d61208d..a742bd2 100644 --- a/tests/fixtures/runtime_goldens/grok.py +++ b/tests/fixtures/runtime_goldens/grok.py @@ -795,6 +795,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _RUNNER_PARTS = ('bin', 'gate.py') +_PACKAGED_RUNNER = 'gate.py' + _GIT = 'git' _DELETED = 'D' @@ -818,7 +820,10 @@ def gate_path_from_argv(argv): return None def runner_for(gate): - """The vendored gate runner beside this compiled gate, or None when it is not there.""" + """The gate runner: beside the gate in a plugin, under .chock/bin in a repository, else None.""" + packaged = gate.resolve().parent / _PACKAGED_RUNNER + if packaged.exists(): + return packaged parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None @@ -870,13 +875,13 @@ def writes_from_worktree(repo_root): continue return writes -def run_gate(gate, writes, event): +def run_gate(gate, writes, event, root=None): """Ask the vendored runner. Returns (outcome, message) and never decides for itself.""" runner = runner_for(gate) if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: @@ -894,16 +899,25 @@ def root_for(gate): parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None + if parents[_GATE_DEPTH_TO_CHOCK - 1].name != 'compiled' or parents[_GATE_DEPTH_TO_CHOCK].name != '.chock': + return None return parents[_GATE_DEPTH_TO_CHOCK].parent +def repo_root_for(event, gate): + """The repository under judgement: the compiled layout's root, else where the agent works.""" + root = root_for(gate) + if root is not None: + return root + cwd = getattr(event, 'cwd', None) + return _chock_Path(cwd) if cwd else _chock_Path.cwd() + def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) if (event.raw or {}).get('stop_hook_active'): return {} - root = root_for(gate) - return writes_from_worktree(root) if root is not None else {} + return writes_from_worktree(repo_root_for(event, gate)) def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" @@ -914,7 +928,7 @@ def evaluate_gate(argv, event): writes = writes_for(event, gate) if not writes: return None - outcome, message = run_gate(gate, writes, name) + outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate)) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/tests/fixtures/runtime_goldens/tabnine.py b/tests/fixtures/runtime_goldens/tabnine.py index b29b527..04aa4fe 100644 --- a/tests/fixtures/runtime_goldens/tabnine.py +++ b/tests/fixtures/runtime_goldens/tabnine.py @@ -795,6 +795,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _RUNNER_PARTS = ('bin', 'gate.py') +_PACKAGED_RUNNER = 'gate.py' + _GIT = 'git' _DELETED = 'D' @@ -818,7 +820,10 @@ def gate_path_from_argv(argv): return None def runner_for(gate): - """The vendored gate runner beside this compiled gate, or None when it is not there.""" + """The gate runner: beside the gate in a plugin, under .chock/bin in a repository, else None.""" + packaged = gate.resolve().parent / _PACKAGED_RUNNER + if packaged.exists(): + return packaged parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None @@ -870,13 +875,13 @@ def writes_from_worktree(repo_root): continue return writes -def run_gate(gate, writes, event): +def run_gate(gate, writes, event, root=None): """Ask the vendored runner. Returns (outcome, message) and never decides for itself.""" runner = runner_for(gate) if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: @@ -894,16 +899,25 @@ def root_for(gate): parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None + if parents[_GATE_DEPTH_TO_CHOCK - 1].name != 'compiled' or parents[_GATE_DEPTH_TO_CHOCK].name != '.chock': + return None return parents[_GATE_DEPTH_TO_CHOCK].parent +def repo_root_for(event, gate): + """The repository under judgement: the compiled layout's root, else where the agent works.""" + root = root_for(gate) + if root is not None: + return root + cwd = getattr(event, 'cwd', None) + return _chock_Path(cwd) if cwd else _chock_Path.cwd() + def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) if (event.raw or {}).get('stop_hook_active'): return {} - root = root_for(gate) - return writes_from_worktree(root) if root is not None else {} + return writes_from_worktree(repo_root_for(event, gate)) def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" @@ -914,7 +928,7 @@ def evaluate_gate(argv, event): writes = writes_for(event, gate) if not writes: return None - outcome, message = run_gate(gate, writes, name) + outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate)) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/tests/fixtures/runtime_goldens/vscode_copilot.py b/tests/fixtures/runtime_goldens/vscode_copilot.py index d0d8d0b..cdba2e6 100644 --- a/tests/fixtures/runtime_goldens/vscode_copilot.py +++ b/tests/fixtures/runtime_goldens/vscode_copilot.py @@ -620,6 +620,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _RUNNER_PARTS = ('bin', 'gate.py') +_PACKAGED_RUNNER = 'gate.py' + _GIT = 'git' _DELETED = 'D' @@ -643,7 +645,10 @@ def gate_path_from_argv(argv): return None def runner_for(gate): - """The vendored gate runner beside this compiled gate, or None when it is not there.""" + """The gate runner: beside the gate in a plugin, under .chock/bin in a repository, else None.""" + packaged = gate.resolve().parent / _PACKAGED_RUNNER + if packaged.exists(): + return packaged parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None @@ -695,13 +700,13 @@ def writes_from_worktree(repo_root): continue return writes -def run_gate(gate, writes, event): +def run_gate(gate, writes, event, root=None): """Ask the vendored runner. Returns (outcome, message) and never decides for itself.""" runner = runner_for(gate) if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: @@ -719,16 +724,25 @@ def root_for(gate): parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None + if parents[_GATE_DEPTH_TO_CHOCK - 1].name != 'compiled' or parents[_GATE_DEPTH_TO_CHOCK].name != '.chock': + return None return parents[_GATE_DEPTH_TO_CHOCK].parent +def repo_root_for(event, gate): + """The repository under judgement: the compiled layout's root, else where the agent works.""" + root = root_for(gate) + if root is not None: + return root + cwd = getattr(event, 'cwd', None) + return _chock_Path(cwd) if cwd else _chock_Path.cwd() + def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) if (event.raw or {}).get('stop_hook_active'): return {} - root = root_for(gate) - return writes_from_worktree(root) if root is not None else {} + return writes_from_worktree(repo_root_for(event, gate)) def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" @@ -739,7 +753,7 @@ def evaluate_gate(argv, event): writes = writes_for(event, gate) if not writes: return None - outcome, message = run_gate(gate, writes, name) + outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate)) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/tests/fixtures/runtime_goldens/windsurf.py b/tests/fixtures/runtime_goldens/windsurf.py index cd17905..3dfd4a5 100644 --- a/tests/fixtures/runtime_goldens/windsurf.py +++ b/tests/fixtures/runtime_goldens/windsurf.py @@ -709,6 +709,8 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _RUNNER_PARTS = ('bin', 'gate.py') +_PACKAGED_RUNNER = 'gate.py' + _GIT = 'git' _DELETED = 'D' @@ -732,7 +734,10 @@ def gate_path_from_argv(argv): return None def runner_for(gate): - """The vendored gate runner beside this compiled gate, or None when it is not there.""" + """The gate runner: beside the gate in a plugin, under .chock/bin in a repository, else None.""" + packaged = gate.resolve().parent / _PACKAGED_RUNNER + if packaged.exists(): + return packaged parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None @@ -784,13 +789,13 @@ def writes_from_worktree(repo_root): continue return writes -def run_gate(gate, writes, event): +def run_gate(gate, writes, event, root=None): """Ask the vendored runner. Returns (outcome, message) and never decides for itself.""" runner = runner_for(gate) if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False, cwd=str(root) if root is not None else None) except (OSError, _chock_subprocess.SubprocessError) as exc: return (GATE_ERRORED, str(exc)) if proc.returncode == 0: @@ -808,16 +813,25 @@ def root_for(gate): parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None + if parents[_GATE_DEPTH_TO_CHOCK - 1].name != 'compiled' or parents[_GATE_DEPTH_TO_CHOCK].name != '.chock': + return None return parents[_GATE_DEPTH_TO_CHOCK].parent +def repo_root_for(event, gate): + """The repository under judgement: the compiled layout's root, else where the agent works.""" + root = root_for(gate) + if root is not None: + return root + cwd = getattr(event, 'cwd', None) + return _chock_Path(cwd) if cwd else _chock_Path.cwd() + def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) if (event.raw or {}).get('stop_hook_active'): return {} - root = root_for(gate) - return writes_from_worktree(root) if root is not None else {} + return writes_from_worktree(repo_root_for(event, gate)) def evaluate_gate(argv, event): """The decision this event earns from a compiled gate, or None when it has nothing to say.""" @@ -828,7 +842,7 @@ def evaluate_gate(argv, event): writes = writes_for(event, gate) if not writes: return None - outcome, message = run_gate(gate, writes, name) + outcome, message = run_gate(gate, writes, name, repo_root_for(event, gate)) if outcome == GATE_BLOCKED: return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') if outcome == GATE_ERRORED: diff --git a/tests/test_plugin_gate.py b/tests/test_plugin_gate.py new file mode 100644 index 0000000..de4363e --- /dev/null +++ b/tests/test_plugin_gate.py @@ -0,0 +1,261 @@ +"""A policy's gate rides in its Claude plugin, and a policy's own skill files ride in its skill. + +A plugin installs at the agent, not in a repository, so until now it carried a command guard +or nothing: a gate lived only where `chock sync` compiled it. A gate that declares `tool_use` +is packaged now -- the compiled gate, the runner beside it, and a script gate's program with +the files it imports -- wired to the vendor's recorded write tools and to the turn's end. The +bundled runtime finds the runner beside the gate and takes the repository from the event. +""" + +from __future__ import annotations + +import json +import subprocess +import textwrap +from pathlib import Path +from types import SimpleNamespace + +import pytest +import yaml +from conftest import init_repo + +from chock.gate import runner, runtime_bundle +from chock.plugin.claude import ( + POSTURE_ADVISORY, + POSTURE_ENFORCED_GATE, + build_claude_plugin, + claude_plugin_differences, + claude_plugin_files, +) + +POLICY_ID = "scripted" +SCRIPT = "scripted-gate.py" + +#: Refuses any write carrying FORBIDDEN, through a helper it imports from beside itself -- +#: the shape a real engine takes, so the package must carry the sibling too. +GATE_SCRIPT = textwrap.dedent( + """\ + import json, sys + from pathlib import Path + sys.path.insert(0, str(Path(__file__).resolve().parent)) + from helper import hits + payload = json.load(sys.stdin) + found = hits(payload["writes"]) + if found: + print("scripted: forbidden marker in " + ", ".join(found), file=sys.stderr) + sys.exit(1) + sys.exit(0) + """ +) +HELPER = "def hits(writes):\n return sorted(p for p, t in writes.items() if 'FORBIDDEN' in t)\n" + + +def _manifest(kind: str = "script", on=("commit", "tool_use")) -> dict: + params = {"script": SCRIPT} if kind == "script" else {"content_pattern": "FORBIDDEN"} + return { + "id": POLICY_ID, + "name": "Scripted", + "version": "0.0.1", + "description": "Refuses the marker.", + "artifact": "hook", + "enforcement": "block", + "hook": {"gate": {"kind": kind, "on": list(on), "action": "block", "message": "m", "params": params}}, + "provenance": {"author": "t", "license": "Apache-2.0"}, + "lifecycle": {"status": "draft"}, + } + + +@pytest.fixture +def policy(tmp_path: Path): + def _make(manifest: dict, *, skill_files: dict[str, str] | None = None) -> Path: + 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["hook"]["gate"]["kind"] == "script": + impl = pack / "implementations" + impl.mkdir() + (impl / SCRIPT).write_text(GATE_SCRIPT, encoding="utf-8") + (impl / "helper.py").write_text(HELPER, encoding="utf-8") + (impl / "__pycache__").mkdir() + (impl / "__pycache__" / "helper.pyc").write_bytes(b"\x00") + for rel, text in (skill_files or {}).items(): + dest = pack / "skill" / rel + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(text, encoding="utf-8") + return pack + + return _make + + +def _build(policy: Path, manifest: dict, tmp_path: Path) -> Path: + out = tmp_path / "dist" / "claude" / manifest["id"] + build_claude_plugin(policy, manifest, tmp_path, out) + return out + + +# --- what the package carries ------------------------------------------------------------------- + + +def test_a_tool_use_gate_ships_hooks_runner_gate_and_its_program(policy, tmp_path: Path) -> None: + manifest = _manifest() + out = _build(policy(manifest), manifest, tmp_path) + + hooks = json.loads((out / "hooks" / "hooks.json").read_text(encoding="utf-8"))["hooks"] + command = 'python3 "${CLAUDE_PLUGIN_ROOT}/scripts/claude_code.py" --gate "${CLAUDE_PLUGIN_ROOT}/scripts/gate.json"' + assert hooks["PreToolUse"][0]["matcher"] == "Write|Edit|MultiEdit|NotebookEdit" + assert hooks["PreToolUse"][0]["hooks"][0]["command"] == command + assert "matcher" not in hooks["Stop"][0] + assert hooks["Stop"][0]["hooks"][0]["command"] == command + + gate = json.loads((out / "scripts" / "gate.json").read_text(encoding="utf-8")) + assert gate["kind"] == "script" + assert gate["script_base"] == runner.SCRIPT_BASE_GATE + assert gate["params"]["script"] == f"implementations/{SCRIPT}" + + runner_src = Path(runner.__file__).read_text(encoding="utf-8") + assert (out / "scripts" / "gate.py").read_text(encoding="utf-8") == runner_src + assert (out / "scripts" / "implementations" / SCRIPT).read_text(encoding="utf-8") == GATE_SCRIPT + assert (out / "scripts" / "implementations" / "helper.py").read_text(encoding="utf-8") == HELPER + assert not (out / "scripts" / "implementations" / "__pycache__").exists() + + +def test_the_package_states_the_gate_posture_and_the_skill_claims_its_hooks(policy, tmp_path: Path) -> None: + manifest = _manifest() + out = _build(policy(manifest), manifest, tmp_path) + data = json.loads((out / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8")) + assert POSTURE_ENFORCED_GATE in data["description"] + skill = (out / "skills" / POLICY_ID / "SKILL.md").read_text(encoding="utf-8") + assert "chock.hooks: hooks/hooks.json" in skill + assert "PreToolUse and Stop hooks" in skill + assert "advisory" not in skill.lower() + + +def test_a_declarative_gate_packages_without_a_program(policy, tmp_path: Path) -> None: + manifest = _manifest(kind="content_regex") + out = _build(policy(manifest), manifest, tmp_path) + gate = json.loads((out / "scripts" / "gate.json").read_text(encoding="utf-8")) + assert gate["kind"] == "content_regex" and "script_base" not in gate + assert (out / "scripts" / "gate.py").exists() + assert not (out / "scripts" / "implementations").exists() + + +def test_a_commit_only_gate_stays_advisory(policy, tmp_path: Path) -> None: + """The policy did not ask for the write path; a hook that could only refuse is not installed.""" + manifest = _manifest(on=("commit",)) + files = claude_plugin_files(policy(manifest), manifest, tmp_path) + assert not any(p.parts[0] in ("hooks", "scripts") for p in files) + data = json.loads(files[Path(".claude-plugin/plugin.json")]) + assert POSTURE_ADVISORY in data["description"] + + +def test_output_is_byte_stable_and_check_sees_drift(policy, tmp_path: Path) -> None: + manifest = _manifest() + pack = policy(manifest) + out = _build(pack, manifest, tmp_path) + assert claude_plugin_differences(pack, manifest, tmp_path, out) == [] + (pack / "implementations" / "helper.py").write_text("def hits(writes):\n return []\n", encoding="utf-8") + assert any("helper.py" in d for d in claude_plugin_differences(pack, manifest, tmp_path, out)) + + +# --- and it runs: the bundled runtime with the packaged layout ----------------------------------- + + +def _runtime() -> dict: + ns: dict = {"__name__": "chock_runtime_under_test"} + exec(compile(runtime_bundle.render("claude_code"), "", "exec"), ns) # noqa: S102 -- the rendered runtime is the unit under test + return ns + + +def _write_event(repo: Path, path: str, content: str) -> SimpleNamespace: + return SimpleNamespace( + event="pre_tool", tool="Write", command=None, path=path, content=content, cwd=str(repo), raw={} + ) + + +def _stop_event(repo: Path) -> SimpleNamespace: + return SimpleNamespace(event="stop", tool=None, command=None, path=None, content=None, cwd=str(repo), raw={}) + + +def test_the_packaged_gate_refuses_a_write_and_lets_a_clean_one_through(policy, tmp_path: Path) -> None: + manifest = _manifest() + out = _build(policy(manifest), manifest, tmp_path) + repo = tmp_path / "project" + repo.mkdir() + init_repo(repo) + ns = _runtime() + argv = ["--gate", str(out / "scripts" / "gate.json")] + + refused = ns["evaluate_gate"](argv, _write_event(repo, "src/App.java", "FORBIDDEN here")) + assert refused is not None and refused[0] == "deny" + assert "forbidden marker in src/App.java" in refused[1] + + assert ns["evaluate_gate"](argv, _write_event(repo, "src/App.java", "fine")) is None + + +def test_the_packaged_gate_reads_the_turn_from_where_the_agent_works(policy, tmp_path: Path) -> None: + """Stop has no file in hand; the worktree it scans is the event's cwd, not the plugin's.""" + manifest = _manifest() + out = _build(policy(manifest), manifest, tmp_path) + repo = tmp_path / "project" + repo.mkdir() + init_repo(repo) + (repo / "Leak.java").write_text("FORBIDDEN\n", encoding="utf-8") + ns = _runtime() + refused = ns["evaluate_gate"](["--gate", str(out / "scripts" / "gate.json")], _stop_event(repo)) + assert refused is not None and "Leak.java" in refused[1] + + +def test_a_package_without_its_runner_refuses_rather_than_allowing(policy, tmp_path: Path) -> None: + manifest = _manifest() + out = _build(policy(manifest), manifest, tmp_path) + (out / "scripts" / "gate.py").unlink() + repo = tmp_path / "project" + repo.mkdir() + init_repo(repo) + ns = _runtime() + refused = ns["evaluate_gate"](["--gate", str(out / "scripts" / "gate.json")], _write_event(repo, "a.java", "x")) + assert refused is not None and refused[0] == "deny" and "runner" in refused[1] + + +def test_the_runner_resolves_a_gate_based_script_beside_the_gate(tmp_path: Path) -> None: + """`script_base: gate` is the packaged layout's statement; the repository root plays no part.""" + gate_dir = tmp_path / "plugin" / "scripts" + (gate_dir / "implementations").mkdir(parents=True) + (gate_dir / "implementations" / SCRIPT).write_text(GATE_SCRIPT, encoding="utf-8") + (gate_dir / "implementations" / "helper.py").write_text(HELPER, encoding="utf-8") + spec = { + "kind": "script", + "on": ["tool_use"], + "action": "block", + "message": "m", + "params": {"script": f"implementations/{SCRIPT}"}, + "script_base": "gate", + } + gate = gate_dir / "gate.json" + gate.write_text(json.dumps(spec), encoding="utf-8") + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + assert runner.run(gate, "pre-tool-use", None, elsewhere, writes={"a.java": "FORBIDDEN"}) == 1 + assert runner.run(gate, "pre-tool-use", None, elsewhere, writes={"a.java": "ok"}) == 0 + + +def test_the_packaged_runner_is_invoked_the_way_the_hook_would(policy, tmp_path: Path) -> None: + """Belt and braces: the copied runner file runs as a program against the copied gate.""" + manifest = _manifest() + out = _build(policy(manifest), manifest, tmp_path) + proc = subprocess.run( + [ + "python3", + str(out / "scripts" / "gate.py"), + "run", + "--gate", + str(out / "scripts" / "gate.json"), + "--event", + "pre-tool-use", + ], + input=json.dumps({"writes": {"a.java": "FORBIDDEN"}}), + capture_output=True, + text=True, + check=False, + ) + assert proc.returncode == 1 and "forbidden marker" in proc.stderr diff --git a/tests/test_plugin_skill_assets.py b/tests/test_plugin_skill_assets.py new file mode 100644 index 0000000..f7acc22 --- /dev/null +++ b/tests/test_plugin_skill_assets.py @@ -0,0 +1,80 @@ +"""A policy's own words and files ride in its skill, in both package formats. + +`skill/body.md` joins the rendered `SKILL.md` after the constraint block; every other file +under `skill/` is copied beside it. A guided setup page can then live where the skill that +opens it lives, and a change to either is drift the check sees. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml +from test_plugin_gate import GATE_SCRIPT, HELPER, POLICY_ID, SCRIPT, _build, _manifest + +from chock.plugin.build import build_plugin, plugin_differences +from chock.plugin.claude import build_claude_plugin, claude_plugin_differences + + +@pytest.fixture +def gate_policy(tmp_path: Path): + """A script-gate policy, optionally carrying a `skill/` folder.""" + + def _make(manifest: dict, *, skill_files: dict[str, str] | None = None) -> Path: + pack = tmp_path / ".agents" / "policies" / manifest["id"] + (pack / "implementations").mkdir(parents=True) + (pack / "manifest.yaml").write_text(yaml.safe_dump(manifest), encoding="utf-8") + (pack / "implementations" / SCRIPT).write_text(GATE_SCRIPT, encoding="utf-8") + (pack / "implementations" / "helper.py").write_text(HELPER, encoding="utf-8") + for rel, text in (skill_files or {}).items(): + dest = pack / "skill" / rel + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(text, encoding="utf-8") + return pack + + return _make + + +BODY = "## Guided setup\n\nOpen `setup.html` beside this file." +PAGE = "setup" + + +def test_skill_body_and_assets_ride_in_both_package_formats(gate_policy, tmp_path: Path) -> None: + manifest = _manifest() + pack = gate_policy(manifest, skill_files={"body.md": BODY, "setup.html": PAGE, "references/contract.json": "{}"}) + + build_plugin(pack, manifest, tmp_path) + skill = (pack / "skills" / POLICY_ID / "SKILL.md").read_text(encoding="utf-8") + assert BODY in skill + assert skill.index("```\n\n" + BODY) < skill.index("This skill is advisory") + assert (pack / "skills" / POLICY_ID / "setup.html").read_text(encoding="utf-8") == PAGE + assert (pack / "skills" / POLICY_ID / "references" / "contract.json").exists() + assert not (pack / "skills" / POLICY_ID / "body.md").exists() + assert plugin_differences(pack, manifest, tmp_path) == [] + + out = _build(pack, manifest, tmp_path) + assert (out / "skills" / POLICY_ID / "setup.html").read_text(encoding="utf-8") == PAGE + assert BODY in (out / "skills" / POLICY_ID / "SKILL.md").read_text(encoding="utf-8") + + +def test_a_changed_or_removed_asset_is_drift_and_a_rebuild_removes_it(gate_policy, tmp_path: Path) -> None: + manifest = _manifest() + pack = gate_policy(manifest, skill_files={"setup.html": PAGE}) + out = _build(pack, manifest, tmp_path) + (pack / "skill" / "setup.html").write_text(PAGE + "", encoding="utf-8") + assert any("setup.html" in d for d in claude_plugin_differences(pack, manifest, tmp_path, out)) + (pack / "skill" / "setup.html").unlink() + assert any("setup.html" in d for d in claude_plugin_differences(pack, manifest, tmp_path, out)) + build_claude_plugin(pack, manifest, tmp_path, out) + assert not (out / "skills" / POLICY_ID / "setup.html").exists() + + +def test_a_policy_without_a_skill_folder_renders_exactly_as_before(gate_policy, tmp_path: Path) -> None: + manifest = _manifest() + pack = gate_policy(manifest) + build_plugin(pack, manifest, tmp_path) + skill = (pack / "skills" / POLICY_ID / "SKILL.md").read_text(encoding="utf-8") + assert skill.endswith( + "```\n\nThis skill is advisory: the client reading it has no mechanism to enforce it. The same policy compiled by `chock` becomes a git hook that exits non-zero. See https://github.com/open-coder-ai/chock\n" + ) From 5d37494ac935b5379a7939a7b7a009007cb15876 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 23:51:52 +0000 Subject: [PATCH 2/3] refactor(emitters): import the hooks-file builders from their module CodeQL flagged the re-export block in in_agent.py: three of the names it carried were imported for other modules' benefit and used by nothing in the file. The plugin emitters and the wire-facts test now import from in_agent_hooks directly, and in_agent keeps only what it calls. Signed-off-by: Claude --- src/chock/compile/emitters/in_agent.py | 10 +--------- src/chock/plugin/claude.py | 9 ++------- src/chock/plugin/codex.py | 3 ++- src/chock/plugin/copilot.py | 3 ++- src/chock/plugin/cursor.py | 3 ++- src/chock/plugin/devin.py | 3 ++- tests/test_vendor_wire_facts.py | 8 ++++---- 7 files changed, 15 insertions(+), 24 deletions(-) diff --git a/src/chock/compile/emitters/in_agent.py b/src/chock/compile/emitters/in_agent.py index 9e2a8ee..3a38f04 100644 --- a/src/chock/compile/emitters/in_agent.py +++ b/src/chock/compile/emitters/in_agent.py @@ -9,15 +9,7 @@ from chock import vendors from chock.compile.emitters import DATA_DIR, GUARD_SUFFIXES, policy_rel_path from chock.compile.emitters.advisory import repo_root_from_output -from chock.compile.emitters.in_agent_hooks import ( # noqa: F401 -- re-exported for the plugin emitters - TIMEOUT_SECONDS, - cursor_entry, - cursor_hooks_file, - gate_hooks_map_file, - generic_hooks_file, - hook_entry, - hooks_map_file, -) +from chock.compile.emitters.in_agent_hooks import TIMEOUT_SECONDS, cursor_entry, generic_hooks_file, hook_entry from chock.emit import write_generated_json from chock.gate.build import build_gate_json from chock.gate.runner import WRITE_PATH_KINDS diff --git a/src/chock/plugin/claude.py b/src/chock/plugin/claude.py index 8785bad..f8c25e7 100644 --- a/src/chock/plugin/claude.py +++ b/src/chock/plugin/claude.py @@ -8,13 +8,8 @@ from agentseam import packaging -from chock.compile.emitters.in_agent import ( - GATE_FILE, - _guard_script, - gate_hooks_map_file, - hooks_map_file, - tool_use_gate_spec, -) +from chock.compile.emitters.in_agent import GATE_FILE, _guard_script, tool_use_gate_spec +from chock.compile.emitters.in_agent_hooks import gate_hooks_map_file, hooks_map_file from chock.gate import runtime_bundle from chock.gate.runner import SCRIPT_BASE_GATE from chock.plugin import store diff --git a/src/chock/plugin/codex.py b/src/chock/plugin/codex.py index 1fb7e8c..bb52c70 100644 --- a/src/chock/plugin/codex.py +++ b/src/chock/plugin/codex.py @@ -8,7 +8,8 @@ from agentseam import packaging -from chock.compile.emitters.in_agent import _guard_script, hooks_map_file +from chock.compile.emitters.in_agent import _guard_script +from chock.compile.emitters.in_agent_hooks import hooks_map_file from chock.plugin import posture, store from chock.plugin.build import ( _ADVISORY_NOTE_HOOK, diff --git a/src/chock/plugin/copilot.py b/src/chock/plugin/copilot.py index c6f3f24..6c711cc 100644 --- a/src/chock/plugin/copilot.py +++ b/src/chock/plugin/copilot.py @@ -8,7 +8,8 @@ from agentseam import packaging -from chock.compile.emitters.in_agent import _guard_script, hooks_map_file +from chock.compile.emitters.in_agent import _guard_script +from chock.compile.emitters.in_agent_hooks import hooks_map_file from chock.plugin import store from chock.plugin.build import ( _ADVISORY_NOTE_HOOK, diff --git a/src/chock/plugin/cursor.py b/src/chock/plugin/cursor.py index b62709a..5d34356 100644 --- a/src/chock/plugin/cursor.py +++ b/src/chock/plugin/cursor.py @@ -8,7 +8,8 @@ from agentseam import packaging -from chock.compile.emitters.in_agent import _guard_script, cursor_hooks_file +from chock.compile.emitters.in_agent import _guard_script +from chock.compile.emitters.in_agent_hooks import cursor_hooks_file from chock.plugin import posture, store from chock.plugin.build import ( _ADVISORY_NOTE_HOOK, diff --git a/src/chock/plugin/devin.py b/src/chock/plugin/devin.py index 7ea571e..6d51766 100644 --- a/src/chock/plugin/devin.py +++ b/src/chock/plugin/devin.py @@ -8,7 +8,8 @@ from agentseam import packaging -from chock.compile.emitters.in_agent import _guard_script, hooks_map_file +from chock.compile.emitters.in_agent import _guard_script +from chock.compile.emitters.in_agent_hooks import hooks_map_file from chock.plugin import posture, store from chock.plugin.build import ( _ADVISORY_NOTE_HOOK, diff --git a/tests/test_vendor_wire_facts.py b/tests/test_vendor_wire_facts.py index 0caf211..87d40bb 100644 --- a/tests/test_vendor_wire_facts.py +++ b/tests/test_vendor_wire_facts.py @@ -15,7 +15,7 @@ from agentseam.vendor_config import SCHEMA, VENDOR_CONFIG from chock import evidence, vendors -from chock.compile.emitters import in_agent +from chock.compile.emitters import in_agent, in_agent_hooks from chock.hooks.in_agent_install import WIRED_VENDORS, agent_hooks_rel @@ -55,7 +55,7 @@ def test_shell_vocabulary_is_derived_per_vendor_not_borrowed() -> None: "vscode_copilot's own derived matcher happens to equal claude_code's -- " "pick a different vendor to prove derivation, not the borrowed constant" ) - (rendered_entry,) = in_agent.hooks_map_file("vscode_copilot", "CMD")["hooks"][ + (rendered_entry,) = in_agent_hooks.hooks_map_file("vscode_copilot", "CMD")["hooks"][ vendors.pre_tool_event("vscode_copilot") ] assert rendered_entry["matcher"] == vscode_matcher, ( @@ -104,8 +104,8 @@ def test_cursor_fail_closed_stays_unset_pending_the_owner_decision() -> None: witnessed run), never a silent flag-flip. chock's cursor wire bytes carry no failClosed; the flag's one source, when decided, is agentseam's public fail_closed accessor. """ - assert "failClosed" not in json.dumps(in_agent.cursor_hooks_file("CMD")) - assert "failClosed" not in json.dumps(in_agent.cursor_entry("CMD")) + assert "failClosed" not in json.dumps(in_agent_hooks.cursor_hooks_file("CMD")) + assert "failClosed" not in json.dumps(in_agent_hooks.cursor_entry("CMD")) rendered = adapters.get("cursor").hook_config(("pre_tool",), "CMD", fail_closed=True) (entry,) = rendered["hooks"]["preToolUse"] assert entry.get("failClosed") is True From 6053d8ac95c567bd3839c2f00be795db6c343998 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 00:15:56 +0000 Subject: [PATCH 3/3] feat(plugin): the packaged gate in every hook-carrying store, reaching what the vendor records The Claude emitter packaged a policy's gate; the four other stores still packaged command guards only. The packaging moves to plugin/gate_package and every store uses it, wired to the surfaces agentseam records for the vendor and no other: Claude Code gates its recorded write tools at PreToolUse and the turn's end at Stop; Codex, Devin and Copilot record no write-tool vocabulary but block at the turn's end, so their packages carry the gate at Stop alone and say the write itself is not judged; Cursor records neither, so its package stays advisory rather than installing a hook that could only refuse. Each store's posture keeps its own caveat (Codex's trust review, Devin's best-effort hooks, Copilot's namespace and plugin-root condition). A policy's skill/ files ride in every store's package, and every store owns skills/ so a rebuild removes what a policy stopped shipping. A test pins which vendors a gate reaches as agentseam's answer, so a change upstream surfaces rather than silently widening a package. Signed-off-by: Claude --- CHANGELOG.md | 32 +++-- docs/agent-plugins.md | 26 ++-- src/chock/compile/emitters/in_agent_hooks.py | 14 -- src/chock/plugin/claude.py | 55 +------- src/chock/plugin/cli.py | 12 +- src/chock/plugin/codex.py | 45 +++++- src/chock/plugin/copilot.py | 49 ++++++- src/chock/plugin/cursor.py | 38 ++++- src/chock/plugin/data/stores/codex.json | 7 +- src/chock/plugin/data/stores/copilot.json | 6 +- src/chock/plugin/data/stores/cursor.json | 6 +- src/chock/plugin/data/stores/devin.json | 6 +- src/chock/plugin/devin.py | 39 +++++- src/chock/plugin/gate_package.py | 118 ++++++++++++++++ tests/test_plugin_gate_stores.py | 137 +++++++++++++++++++ 15 files changed, 470 insertions(+), 120 deletions(-) create mode 100644 src/chock/plugin/gate_package.py create mode 100644 tests/test_plugin_gate_stores.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f5860b4..d30b679 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,24 +2,28 @@ ## Unreleased -- **A policy's gate rides in its Claude plugin.** A plugin installs at the agent, not in a - repository, so it carried a command guard or nothing: a gate lived only where `chock sync` - compiled it. `chock plugin build --format claude` now packages a policy whose gate declares +- **A policy's gate rides in its plugin, in every hook-carrying format.** A plugin installs at + the agent, not in a repository, so it carried a command guard or nothing: a gate lived only + where `chock sync` compiled it. `chock plugin build` now packages a policy whose gate declares `tool_use` -- the compiled `scripts/gate.json`, the runner beside it as `scripts/gate.py`, and for `kind: script` the policy's whole `implementations/` under `scripts/`, so a program - that imports from beside itself still does -- wired in `hooks/hooks.json` to the vendor's - recorded write tools at `PreToolUse` and, with no matcher, at `Stop`. The package states the - posture (judges the write, re-reads the turn, refuses when it cannot decide, needs python3) - and the skill claims its hooks. The bundled runtime looks for the runner beside the gate - before the repository layout, and takes the repository from the event's working directory - when the gate is packaged; a packaged script gate says `script_base: gate`, which the runner - reads as "beside me" instead of "under the repository root". Runtime goldens regenerate - (an emitter change, so a minor release). + that imports from beside itself still does -- wired to every surface agentseam records for + the vendor and no other: Claude Code's package gates the recorded write tools at `PreToolUse` + and the turn's end at `Stop`; Codex, Devin and Copilot record no write-tool vocabulary but + block at the turn's end, so their packages carry the gate at `Stop` alone and say that the + write itself is not judged; Cursor records neither, so its package stays advisory rather + than installing a hook that could only refuse. Each package states its posture (what is + judged and when, refuses when it cannot decide, needs python3, the vendor's own caveat) and + the skill claims its hooks. The bundled runtime looks for the runner beside the gate before + the repository layout, and takes the repository from the event's working directory when the + gate is packaged; a packaged script gate says `script_base: gate`, which the runner reads as + "beside me" instead of "under the repository root". Runtime goldens regenerate (an emitter + change, so a minor release). - **A policy's own skill files.** A policy may carry a `skill/` folder: `skill/body.md` is appended to its rendered `SKILL.md` after the constraint block, and every other file there - rides in the skill's directory in both the Agent Plugins and the Claude package -- a guided - setup page beside the skill that opens it. `--check` sees a changed or removed file; the - Claude store now owns `skills/`, so a rebuild removes what the policy stopped shipping. + rides in the skill's directory in the Agent Plugins package and every store package -- a + guided setup page beside the skill that opens it. `--check` sees a changed or removed file; + every store now owns `skills/`, so a rebuild removes what the policy stopped shipping. ## 0.10.0 — A script gate's evals replay, and its ambient line names the script by file diff --git a/docs/agent-plugins.md b/docs/agent-plugins.md index 1ab6d4b..047d3b2 100644 --- a/docs/agent-plugins.md +++ b/docs/agent-plugins.md @@ -203,15 +203,23 @@ real install**: One guard, one adapter, byte-identical across all four — only the envelope each client reads differs. -The Claude format also carries a policy's **gate** when the gate declares `tool_use`: the -compiled `scripts/gate.json`, the stdlib runner beside it as `scripts/gate.py`, and for -`kind: script` the policy's whole `implementations/` under `scripts/`. `hooks/hooks.json` -runs the same adapter with `--gate` on the vendor's recorded write tools at `PreToolUse` -and, with no matcher, at `Stop`, so a file written through a shell heredoc is judged at the -turn's end. The runtime finds the runner beside the gate and takes the repository from the -event's working directory, which is where `.chock/` config such as a policy's selection file -is read from. A gate that declares only `commit` stays advisory in the plugin: a hook that -could only refuse is not installed. The deny dialects are pinned by `tests/test_pretooluse_protocol.py` (every +Every hook-carrying format also carries a policy's **gate** when the gate declares +`tool_use`: the compiled `scripts/gate.json`, the stdlib runner beside it as `scripts/gate.py`, +and for `kind: script` the policy's whole `implementations/` under `scripts/`. The hooks file +runs the same adapter with `--gate` on every surface agentseam records for the vendor, and on +no other: + +| Store | Write path | Turn's end | Package posture | +| :--- | :--- | :--- | :--- | +| claude | `PreToolUse` on `Write\|Edit\|MultiEdit\|NotebookEdit` | `Stop` | judges the write, re-reads the turn | +| codex, devin, copilot | none recorded | `Stop` | re-reads the turn; the write itself is not judged | +| cursor | none recorded | no blocking stop hook | advisory; no hook is installed | + +A file written through a shell heredoc is judged at the turn's end wherever `Stop` reaches. +The runtime finds the runner beside the gate and takes the repository from the event's working +directory, which is where `.chock/` config such as a policy's selection file is read from. A +gate that declares only `commit` stays advisory in every package: a hook that could only +refuse is not installed. The deny dialects are pinned by `tests/test_pretooluse_protocol.py` (every case there reproduces a witnessed failure), and the probe evidence is recorded in the 0.4.0 CHANGELOG entry. Codex additionally installs every hook **untrusted** until a human approves it, and that trust is bound to a hash of the hook command, so a plugin update silently diff --git a/src/chock/compile/emitters/in_agent_hooks.py b/src/chock/compile/emitters/in_agent_hooks.py index b661b89..1606bc9 100644 --- a/src/chock/compile/emitters/in_agent_hooks.py +++ b/src/chock/compile/emitters/in_agent_hooks.py @@ -53,17 +53,3 @@ def cursor_hooks_file(command: str) -> dict[str, Any]: **vendors.config_envelope("cursor"), "hooks": {vendors.shell_gate_event("cursor"): [cursor_entry(command)]}, } - - -def gate_hooks_map_file(vendor: str, command: str) -> dict[str, Any]: - """A hooks file running `command` on `vendor`'s recorded write tools and at the turn's end. - - The write matcher is the vendor's own recorded vocabulary; a vendor recording none gets - no write entry rather than an invented one. The turn-end entry carries no matcher. - """ - entries: dict[str, list[dict[str, Any]]] = {} - matcher = vendors.write_matcher(vendor) - if matcher is not None: - entries[vendors.pre_tool_event(vendor)] = [hook_entry(command, matcher=matcher)] - entries[vendors.stop_event(vendor)] = [hook_entry(command)] - return entries if vendors.hook_entry_bare(vendor) else {"hooks": entries} diff --git a/src/chock/plugin/claude.py b/src/chock/plugin/claude.py index f8c25e7..02378e9 100644 --- a/src/chock/plugin/claude.py +++ b/src/chock/plugin/claude.py @@ -9,10 +9,9 @@ from agentseam import packaging from chock.compile.emitters.in_agent import GATE_FILE, _guard_script, tool_use_gate_spec -from chock.compile.emitters.in_agent_hooks import gate_hooks_map_file, hooks_map_file +from chock.compile.emitters.in_agent_hooks import hooks_map_file from chock.gate import runtime_bundle -from chock.gate.runner import SCRIPT_BASE_GATE -from chock.plugin import store +from chock.plugin import gate_package, store from chock.plugin.build import ( _ADVISORY_NOTE_HOOK, _ADVISORY_NOTE_RULE, @@ -35,13 +34,7 @@ "disable the python3 Store alias or install Python. If the guard itself crashes or times " "out, the hook asks for confirmation rather than allowing silently." ) -POSTURE_ENFORCED_GATE = ( - "Session-enforced via PreToolUse and Stop hooks; needs python3. PreToolUse judges the file " - "a tool call would write; Stop re-reads what the turn actually left on disk, so a file " - "written through a shell heredoc is judged too. Without python3, fail-open clients allow " - "silently. A gate that cannot reach a decision refuses rather than allowing one it never " - "judged. Enforcement at every commit and in CI still needs chock installed in the repo." -) +POSTURE_ENFORCED_GATE = gate_package.gate_posture("claude_code") POSTURE_ADVISORY = "Advisory skill only; enforcement needs chock installed in the repo." _ENFORCED_NOTE = ( @@ -50,19 +43,10 @@ "enforcement across every commit and in CI still needs `chock sync`. " "See https://github.com/open-coder-ai/chock" ) -_ENFORCED_GATE_NOTE = ( - "This policy is enforced in this client by the PreToolUse and Stop hooks installed with " - "the plugin, subject to the fail conditions stated in the plugin description. Repo-wide " - "enforcement across every commit and in CI still needs `chock sync`. " - "See https://github.com/open-coder-ai/chock" -) +_ENFORCED_GATE_NOTE = gate_package.gate_skill_note("claude_code") -#: Where a packaged gate and everything it runs live inside the plugin: the compiled gate, -#: the runner beside it (write_gate looks there first), and a script gate's own program with -#: the files it imports, copied whole so a `sys.path` it sets on its own directory still holds. _GATE_REL = _SCRIPTS_TEMPLATE.format(name=GATE_FILE) -_RUNNER_REL = _SCRIPTS_TEMPLATE.format(name="gate.py") -_IMPLEMENTATIONS = "implementations" +_IMPLEMENTATIONS = gate_package.IMPLEMENTATIONS def _adapter_source(agent: str = "claude_code") -> str: @@ -84,28 +68,6 @@ def _gate_command() -> str: return f'python3 "{adapter}" --gate "{gate}"' -def _runner_source() -> str: - """The stdlib-only gate runner, verbatim -- the one `chock sync` vendors under .chock/bin.""" - return (Path(runtime_bundle.__file__).resolve().parent / "runner.py").read_text(encoding="utf-8") - - -def _packaged_gate(policy_dir: Path, spec: dict[str, Any]) -> tuple[dict[str, Any], dict[Path, str]]: - """The gate as the plugin carries it, plus the files a script gate needs beside it.""" - packaged = {key: value for key, value in spec.items() if key != "params"} - packaged["params"] = dict(spec.get("params") or {}) - files: dict[Path, str] = {} - if spec.get("kind") == "script": - name = Path(str(packaged["params"].get("script", ""))).name - packaged["params"]["script"] = f"{_IMPLEMENTATIONS}/{name}" - packaged["script_base"] = SCRIPT_BASE_GATE - root = Path(policy_dir) / _IMPLEMENTATIONS - for path in sorted(root.rglob("*")): - if path.is_file() and "__pycache__" not in path.parts: - rel = Path(_IMPLEMENTATIONS) / path.relative_to(root) - files[Path(_SCRIPTS_TEMPLATE.format(name=rel.as_posix()))] = path.read_text(encoding="utf-8") - return packaged, files - - def build_claude_manifest( manifest: dict[str, Any], policy_dir: Path, *, enforced: bool, gate: bool = False ) -> dict[str, Any]: @@ -169,12 +131,9 @@ def claude_plugin_files(policy_dir: Path, manifest: dict[str, Any], repo_root: P encoding="utf-8" ) elif gate: - packaged, carried = _packaged_gate(policy_dir, gate) - files[hooks_rel] = json.dumps(gate_hooks_map_file("claude_code", _gate_command()), indent=2) + "\n" + files[hooks_rel] = json.dumps(gate_package.gate_hooks_file("claude_code", _gate_command()), indent=2) + "\n" files[Path(_SCRIPTS_TEMPLATE.format(name="claude_code.py"))] = _adapter_source("claude_code") - files[Path(_RUNNER_REL)] = _runner_source() - files[Path(_GATE_REL)] = json.dumps(packaged, indent=2) + "\n" - files.update(carried) + files.update(gate_package.packaged_gate_files(policy_dir, gate, _SCRIPTS_TEMPLATE)) return files diff --git a/src/chock/plugin/cli.py b/src/chock/plugin/cli.py index f8db481..2673f8f 100644 --- a/src/chock/plugin/cli.py +++ b/src/chock/plugin/cli.py @@ -210,16 +210,18 @@ def main(argv: list[str] | None = None) -> int: print(f" plugin.json + skills//SKILL.md per policy; enforcement metadata under {NAMESPACE}") if "claude" in formats: print( - " .claude-plugin/plugin.json + hooks/ + scripts/ per guard policy and per policy gating tool_use; " - "fail posture stated in each description" + " .claude-plugin/plugin.json + hooks/ + scripts/ per guard policy and per policy gating tool_use " + "(PreToolUse on write tools + Stop); fail posture stated in each description" ) if "copilot" in formats: - print(" root plugin.json + com.github.copilot/hooks/ per guard policy; same posture discipline") + print( + " root plugin.json + com.github.copilot/hooks/ per guard policy and per gate (Stop); same posture discipline" + ) if "cursor" in formats: print(" .cursor-plugin/plugin.json + hooks/ (beforeShellExecution) per guard policy") if "codex" in formats: - print(" .codex-plugin/plugin.json + hooks/ (PreToolUse) per guard policy") + print(" .codex-plugin/plugin.json + hooks/ (PreToolUse per guard policy; Stop per gate)") if "devin" in formats: - print(" .devin-plugin/plugin.json + hooks.json (PreToolUse, best-effort) per guard policy") + print(" .devin-plugin/plugin.json + hooks.json (PreToolUse per guard policy; Stop per gate; best-effort)") print(" Skills are advisory in any client. Repo-level enforcement still needs `chock sync`.") return 0 diff --git a/src/chock/plugin/codex.py b/src/chock/plugin/codex.py index bb52c70..dcb4cdd 100644 --- a/src/chock/plugin/codex.py +++ b/src/chock/plugin/codex.py @@ -8,9 +8,9 @@ from agentseam import packaging -from chock.compile.emitters.in_agent import _guard_script +from chock.compile.emitters.in_agent import _guard_script, tool_use_gate_spec from chock.compile.emitters.in_agent_hooks import hooks_map_file -from chock.plugin import posture, store +from chock.plugin import gate_package, posture, store from chock.plugin.build import ( _ADVISORY_NOTE_HOOK, _ADVISORY_NOTE_RULE, @@ -19,6 +19,7 @@ _one_line, build_skill, plugin_name, + skill_assets, ) from chock.plugin.claude import POSTURE_ADVISORY, _adapter_source from chock.plugin.listing import ICON_REL, LICENSE_REL, icon_svg, interface_block, license_text @@ -60,11 +61,27 @@ def _hook_command(script: str) -> str: return f'python3 "{adapter}" --guard "{guard}"' -def build_codex_manifest(manifest: dict[str, Any], policy_dir: Path, *, enforced: bool) -> dict[str, Any]: +POSTURE_GATE_CODEX = gate_package.gate_posture( + "codex_cli", + "Codex requires a one-time trust review per hook -- the plugin is ADVISORY until you approve its hook, and a plugin update voids that trust until re-approved.", +) +_GATE_NOTE_CODEX = gate_package.gate_skill_note("codex_cli") + + +def _gate_command() -> str: + """The same adapter, handed the packaged gate instead of a guard.""" + adapter = packaging.executable_ref("codex_cli", _SCRIPTS_TEMPLATE.format(name="codex_cli.py")) + gate = packaging.executable_ref("codex_cli", _SCRIPTS_TEMPLATE.format(name="gate.json")) + return f'python3 "{adapter}" --gate "{gate}"' + + +def build_codex_manifest( + manifest: dict[str, Any], policy_dir: Path, *, enforced: bool, gate: bool = False +) -> dict[str, Any]: """Derive `.codex-plugin/plugin.json` from a policy manifest.""" policy_id = str(manifest.get("id") or Path(policy_dir).name) provenance = manifest.get("provenance") or {} - posture = POSTURE_ENFORCED_CODEX if enforced else POSTURE_ADVISORY + posture = (POSTURE_GATE_CODEX if gate else POSTURE_ENFORCED_CODEX) if enforced else POSTURE_ADVISORY description = _one_line(manifest.get("description")) @@ -95,21 +112,31 @@ def codex_plugin_files(policy_dir: Path, manifest: dict[str, Any], repo_root: Pa policy_id = str(manifest.get("id") or policy_dir.name) name = plugin_name(policy_id) script = _guard_script(policy_dir, policy_id) - - skill = build_skill(policy_dir, manifest, Path(repo_root), hooks=HOOKS_REL if script else None) + gate = ( + None + if script or not gate_package.gate_reaches("codex_cli") + else tool_use_gate_spec(policy_dir, Path(repo_root)) + ) + enforced = script is not None or gate is not None + + skill = build_skill(policy_dir, manifest, Path(repo_root), hooks=HOOKS_REL if enforced else None) if script: skill = skill.replace(_ADVISORY_NOTE_RULE, _ENFORCED_NOTE_CODEX).replace( _ADVISORY_NOTE_HOOK, _ENFORCED_NOTE_CODEX ) + elif gate: + skill = skill.replace(_ADVISORY_NOTE_RULE, _GATE_NOTE_CODEX).replace(_ADVISORY_NOTE_HOOK, _GATE_NOTE_CODEX) files: dict[Path, str] = { Path(_LAYOUT["manifest"]): json.dumps( - build_codex_manifest(manifest, policy_dir, enforced=script is not None), indent=2 + build_codex_manifest(manifest, policy_dir, enforced=enforced, gate=gate is not None), indent=2 ) + "\n", Path(packaging.supports("codex_cli", packaging.SKILL).format(name=name)): skill, ICON_REL: icon_svg(), } + for rel, content in skill_assets(policy_dir).items(): + files[Path(packaging.supports("codex_cli", packaging.SKILL).format(name=name)).parent / rel] = content licence = license_text(manifest) if licence: files[LICENSE_REL] = licence @@ -119,6 +146,10 @@ def codex_plugin_files(policy_dir: Path, manifest: dict[str, Any], repo_root: Pa files[Path(_SCRIPTS_TEMPLATE.format(name=script))] = (policy_dir / "implementations" / script).read_text( encoding="utf-8" ) + elif gate: + files[Path(HOOKS_REL)] = json.dumps(gate_package.gate_hooks_file("codex_cli", _gate_command()), indent=2) + "\n" + files[Path(_SCRIPTS_TEMPLATE.format(name="codex_cli.py"))] = _adapter_source("codex_cli") + files.update(gate_package.packaged_gate_files(policy_dir, gate, _SCRIPTS_TEMPLATE)) return files diff --git a/src/chock/plugin/copilot.py b/src/chock/plugin/copilot.py index 6c711cc..e4331e8 100644 --- a/src/chock/plugin/copilot.py +++ b/src/chock/plugin/copilot.py @@ -8,9 +8,9 @@ from agentseam import packaging -from chock.compile.emitters.in_agent import _guard_script +from chock.compile.emitters.in_agent import _guard_script, tool_use_gate_spec from chock.compile.emitters.in_agent_hooks import hooks_map_file -from chock.plugin import store +from chock.plugin import gate_package, store from chock.plugin.build import ( _ADVISORY_NOTE_HOOK, _ADVISORY_NOTE_RULE, @@ -20,6 +20,7 @@ build_skill, license_text, plugin_name, + skill_assets, ) from chock.plugin.claude import POSTURE_ADVISORY, _adapter_source from chock.plugin.store import SCRIPTS_TEMPLATE as _SCRIPTS_TEMPLATE @@ -59,10 +60,28 @@ def _hook_command(script: str) -> str: return f'r="{root}"; [ -n "$r" ] && [ -f {adapter} ] || exit 0; exec python3 {adapter} --guard {guard}' -def build_copilot_manifest(manifest: dict[str, Any], policy_dir: Path, *, enforced: bool) -> dict[str, Any]: +POSTURE_GATE_COPILOT = gate_package.gate_posture( + "vscode_copilot", + "Enforces only in a client that reads the com.github.copilot namespace and tells the hook where the package lives; a client that exports no plugin-root variable runs the hook, which then allows, so treat this package as advisory unless a deny has been witnessed in your own client.", +) +_GATE_NOTE_COPILOT = gate_package.gate_skill_note("vscode_copilot") + + +def _gate_command() -> str: + """The same adapter, handed the packaged gate instead of a guard.""" + assert PLUGIN_ROOT.startswith("${") and PLUGIN_ROOT.endswith("}"), PLUGIN_ROOT # noqa: S101 -- build-time constant, not request input + root = f"{PLUGIN_ROOT[:-1]}:-}}" + adapter = f'"$r/{_SCRIPTS_TEMPLATE.format(name="vscode_copilot.py")}"' + gate = f'"$r/{_SCRIPTS_TEMPLATE.format(name="gate.json")}"' + return f'r="{root}"; [ -n "$r" ] && [ -f {adapter} ] || exit 0; exec python3 {adapter} --gate {gate}' + + +def build_copilot_manifest( + manifest: dict[str, Any], policy_dir: Path, *, enforced: bool, gate: bool = False +) -> dict[str, Any]: """Derive the root `plugin.json` from a policy manifest.""" data = build_manifest(manifest, policy_dir) - posture = POSTURE_ENFORCED_COPILOT if enforced else POSTURE_ADVISORY + posture = (POSTURE_GATE_COPILOT if gate else POSTURE_ENFORCED_COPILOT) if enforced else POSTURE_ADVISORY data["description"] = f"{data['description']} [{posture}]".strip() extension = data["extensions"][NAMESPACE] del extension["manifest"] @@ -78,20 +97,30 @@ def copilot_plugin_files(policy_dir: Path, manifest: dict[str, Any], repo_root: policy_id = manifest.get("id") or policy_dir.name name = plugin_name(str(policy_id)) script = _guard_script(policy_dir, str(policy_id)) - - skill = build_skill(policy_dir, manifest, Path(repo_root), hooks=HOOKS_REL if script else None) + gate = ( + None + if script or not gate_package.gate_reaches("vscode_copilot") + else tool_use_gate_spec(policy_dir, Path(repo_root)) + ) + enforced = script is not None or gate is not None + + skill = build_skill(policy_dir, manifest, Path(repo_root), hooks=HOOKS_REL if enforced else None) if script: skill = skill.replace(_ADVISORY_NOTE_RULE, _COPILOT_ENFORCED_NOTE).replace( _ADVISORY_NOTE_HOOK, _COPILOT_ENFORCED_NOTE ) + elif gate: + skill = skill.replace(_ADVISORY_NOTE_RULE, _GATE_NOTE_COPILOT).replace(_ADVISORY_NOTE_HOOK, _GATE_NOTE_COPILOT) files: dict[Path, str] = { Path(_LAYOUT["manifest"]): json.dumps( - build_copilot_manifest(manifest, policy_dir, enforced=script is not None), indent=2 + build_copilot_manifest(manifest, policy_dir, enforced=enforced, gate=gate is not None), indent=2 ) + "\n", Path(packaging.supports("copilot", packaging.SKILL).format(name=name)): skill, } + for rel, content in skill_assets(policy_dir).items(): + files[Path(packaging.supports("copilot", packaging.SKILL).format(name=name)).parent / rel] = content licence = license_text(manifest) if licence: files[LICENSE_REL] = licence @@ -101,6 +130,12 @@ def copilot_plugin_files(policy_dir: Path, manifest: dict[str, Any], repo_root: files[Path(_SCRIPTS_TEMPLATE.format(name=script))] = (policy_dir / "implementations" / script).read_text( encoding="utf-8" ) + elif gate: + files[Path(HOOKS_REL)] = ( + json.dumps(gate_package.gate_hooks_file("vscode_copilot", _gate_command()), indent=2) + "\n" + ) + files[Path(_SCRIPTS_TEMPLATE.format(name="vscode_copilot.py"))] = _adapter_source("vscode_copilot") + files.update(gate_package.packaged_gate_files(policy_dir, gate, _SCRIPTS_TEMPLATE)) return files diff --git a/src/chock/plugin/cursor.py b/src/chock/plugin/cursor.py index 5d34356..daab8b8 100644 --- a/src/chock/plugin/cursor.py +++ b/src/chock/plugin/cursor.py @@ -8,9 +8,9 @@ from agentseam import packaging -from chock.compile.emitters.in_agent import _guard_script +from chock.compile.emitters.in_agent import _guard_script, tool_use_gate_spec from chock.compile.emitters.in_agent_hooks import cursor_hooks_file -from chock.plugin import posture, store +from chock.plugin import gate_package, posture, store from chock.plugin.build import ( _ADVISORY_NOTE_HOOK, _ADVISORY_NOTE_RULE, @@ -21,6 +21,7 @@ build_skill, license_text, plugin_name, + skill_assets, ) from chock.plugin.claude import POSTURE_ADVISORY, _adapter_source from chock.plugin.store import SCRIPTS_TEMPLATE as _SCRIPTS_TEMPLATE @@ -61,11 +62,24 @@ def _hook_command(script: str) -> str: return f'python3 "{adapter}" --guard "{guard}"' -def build_cursor_manifest(manifest: dict[str, Any], policy_dir: Path, *, enforced: bool) -> dict[str, Any]: +POSTURE_GATE_CURSOR = gate_package.gate_posture("cursor", "") +_GATE_NOTE_CURSOR = gate_package.gate_skill_note("cursor") + + +def _gate_command() -> str: + """The same adapter, handed the packaged gate instead of a guard.""" + adapter = packaging.executable_ref("cursor", _SCRIPTS_TEMPLATE.format(name="cursor.py")) + gate = packaging.executable_ref("cursor", _SCRIPTS_TEMPLATE.format(name="gate.json")) + return f'python3 "{adapter}" --gate "{gate}"' + + +def build_cursor_manifest( + manifest: dict[str, Any], policy_dir: Path, *, enforced: bool, gate: bool = False +) -> dict[str, Any]: """Derive `.cursor-plugin/plugin.json` from a policy manifest.""" policy_id = str(manifest.get("id") or Path(policy_dir).name) provenance = manifest.get("provenance") or {} - posture = POSTURE_ENFORCED_CURSOR if enforced else POSTURE_ADVISORY + posture = (POSTURE_GATE_CURSOR if gate else POSTURE_ENFORCED_CURSOR) if enforced else POSTURE_ADVISORY data: dict[str, Any] = { "name": plugin_name(policy_id), @@ -95,20 +109,28 @@ def cursor_plugin_files(policy_dir: Path, manifest: dict[str, Any], repo_root: P policy_id = str(manifest.get("id") or policy_dir.name) name = plugin_name(policy_id) script = _guard_script(policy_dir, policy_id) + gate = ( + None if script or not gate_package.gate_reaches("cursor") else tool_use_gate_spec(policy_dir, Path(repo_root)) + ) + enforced = script is not None or gate is not None - skill = build_skill(policy_dir, manifest, Path(repo_root), hooks=HOOKS_REL if script else None) + skill = build_skill(policy_dir, manifest, Path(repo_root), hooks=HOOKS_REL if enforced else None) if script: skill = skill.replace(_ADVISORY_NOTE_RULE, _ENFORCED_NOTE_CURSOR).replace( _ADVISORY_NOTE_HOOK, _ENFORCED_NOTE_CURSOR ) + elif gate: + skill = skill.replace(_ADVISORY_NOTE_RULE, _GATE_NOTE_CURSOR).replace(_ADVISORY_NOTE_HOOK, _GATE_NOTE_CURSOR) files: dict[Path, str] = { Path(_LAYOUT["manifest"]): json.dumps( - build_cursor_manifest(manifest, policy_dir, enforced=script is not None), indent=2 + build_cursor_manifest(manifest, policy_dir, enforced=enforced, gate=gate is not None), indent=2 ) + "\n", Path(packaging.supports("cursor", packaging.SKILL).format(name=name)): skill, } + for rel, content in skill_assets(policy_dir).items(): + files[Path(packaging.supports("cursor", packaging.SKILL).format(name=name)).parent / rel] = content licence = license_text(manifest) if licence: files[LICENSE_REL] = licence @@ -118,6 +140,10 @@ def cursor_plugin_files(policy_dir: Path, manifest: dict[str, Any], repo_root: P files[Path(_SCRIPTS_TEMPLATE.format(name=script))] = (policy_dir / "implementations" / script).read_text( encoding="utf-8" ) + elif gate: + files[Path(HOOKS_REL)] = json.dumps(gate_package.gate_hooks_file("cursor", _gate_command()), indent=2) + "\n" + files[Path(_SCRIPTS_TEMPLATE.format(name="cursor.py"))] = _adapter_source("cursor") + files.update(gate_package.packaged_gate_files(policy_dir, gate, _SCRIPTS_TEMPLATE)) return files diff --git a/src/chock/plugin/data/stores/codex.json b/src/chock/plugin/data/stores/codex.json index 9990041..b101619 100644 --- a/src/chock/plugin/data/stores/codex.json +++ b/src/chock/plugin/data/stores/codex.json @@ -1,3 +1,8 @@ { - "owned_subtrees": ["hooks", "scripts", "assets"] + "owned_subtrees": [ + "hooks", + "scripts", + "assets", + "skills" + ] } diff --git a/src/chock/plugin/data/stores/copilot.json b/src/chock/plugin/data/stores/copilot.json index bc0fe11..7179f06 100644 --- a/src/chock/plugin/data/stores/copilot.json +++ b/src/chock/plugin/data/stores/copilot.json @@ -1,3 +1,7 @@ { - "owned_subtrees": ["com.github.copilot", "scripts"] + "owned_subtrees": [ + "com.github.copilot", + "scripts", + "skills" + ] } diff --git a/src/chock/plugin/data/stores/cursor.json b/src/chock/plugin/data/stores/cursor.json index 9ca6320..6246957 100644 --- a/src/chock/plugin/data/stores/cursor.json +++ b/src/chock/plugin/data/stores/cursor.json @@ -1,3 +1,7 @@ { - "owned_subtrees": ["hooks", "scripts"] + "owned_subtrees": [ + "hooks", + "scripts", + "skills" + ] } diff --git a/src/chock/plugin/data/stores/devin.json b/src/chock/plugin/data/stores/devin.json index 80914e2..6003301 100644 --- a/src/chock/plugin/data/stores/devin.json +++ b/src/chock/plugin/data/stores/devin.json @@ -1,3 +1,7 @@ { - "owned_subtrees": ["scripts", "hooks.json"] + "owned_subtrees": [ + "scripts", + "hooks.json", + "skills" + ] } diff --git a/src/chock/plugin/devin.py b/src/chock/plugin/devin.py index 6d51766..c55468d 100644 --- a/src/chock/plugin/devin.py +++ b/src/chock/plugin/devin.py @@ -8,9 +8,9 @@ from agentseam import packaging -from chock.compile.emitters.in_agent import _guard_script +from chock.compile.emitters.in_agent import _guard_script, tool_use_gate_spec from chock.compile.emitters.in_agent_hooks import hooks_map_file -from chock.plugin import posture, store +from chock.plugin import gate_package, posture, store from chock.plugin.build import ( _ADVISORY_NOTE_HOOK, _ADVISORY_NOTE_RULE, @@ -19,6 +19,7 @@ build_skill, license_text, plugin_name, + skill_assets, ) from chock.plugin.claude import POSTURE_ADVISORY, _adapter_source from chock.plugin.store import SCRIPTS_TEMPLATE as _SCRIPTS_TEMPLATE @@ -68,10 +69,26 @@ def _hook_command(script: str) -> str: return f'python3 "{adapter}" --guard "{guard}"' -def build_devin_manifest(manifest: dict[str, Any], policy_dir: Path, *, enforced: bool) -> dict[str, Any]: +POSTURE_GATE_DEVIN = gate_package.gate_posture( + "devin", + "In the vendor's own words, plugin hooks are best effort and fail open, documented for local Devin sessions only.", +) +_GATE_NOTE_DEVIN = gate_package.gate_skill_note("devin") + + +def _gate_command() -> str: + """The same adapter, handed the packaged gate instead of a guard.""" + adapter = f"${DEVIN_PLUGIN_ROOT_VAR}/{_SCRIPTS_TEMPLATE.format(name='devin.py')}" + gate = f"${DEVIN_PLUGIN_ROOT_VAR}/{_SCRIPTS_TEMPLATE.format(name='gate.json')}" + return f'python3 "{adapter}" --gate "{gate}"' + + +def build_devin_manifest( + manifest: dict[str, Any], policy_dir: Path, *, enforced: bool, gate: bool = False +) -> dict[str, Any]: """Derive `.devin-plugin/plugin.json` from a policy manifest.""" policy_id = str(manifest.get("id") or Path(policy_dir).name) - posture_text = POSTURE_BESTEFFORT_DEVIN if enforced else POSTURE_ADVISORY + posture_text = (POSTURE_GATE_DEVIN if gate else POSTURE_BESTEFFORT_DEVIN) if enforced else POSTURE_ADVISORY description = _one_line(manifest.get("description")) @@ -91,20 +108,26 @@ def devin_plugin_files(policy_dir: Path, manifest: dict[str, Any], repo_root: Pa policy_id = str(manifest.get("id") or policy_dir.name) name = plugin_name(policy_id) script = _guard_script(policy_dir, policy_id) + gate = None if script or not gate_package.gate_reaches("devin") else tool_use_gate_spec(policy_dir, Path(repo_root)) + enforced = script is not None or gate is not None - skill = build_skill(policy_dir, manifest, Path(repo_root), hooks=HOOKS_REL if script else None) + skill = build_skill(policy_dir, manifest, Path(repo_root), hooks=HOOKS_REL if enforced else None) if script: skill = skill.replace(_ADVISORY_NOTE_RULE, _BESTEFFORT_NOTE_DEVIN).replace( _ADVISORY_NOTE_HOOK, _BESTEFFORT_NOTE_DEVIN ) + elif gate: + skill = skill.replace(_ADVISORY_NOTE_RULE, _GATE_NOTE_DEVIN).replace(_ADVISORY_NOTE_HOOK, _GATE_NOTE_DEVIN) files: dict[Path, str] = { Path(_LAYOUT["manifest"]): json.dumps( - build_devin_manifest(manifest, policy_dir, enforced=script is not None), indent=2 + build_devin_manifest(manifest, policy_dir, enforced=enforced, gate=gate is not None), indent=2 ) + "\n", Path(packaging.supports("devin", packaging.SKILL).format(name=name)): skill, } + for rel, content in skill_assets(policy_dir).items(): + files[Path(packaging.supports("devin", packaging.SKILL).format(name=name)).parent / rel] = content licence = license_text(manifest) if licence: files[LICENSE_REL] = licence @@ -114,6 +137,10 @@ def devin_plugin_files(policy_dir: Path, manifest: dict[str, Any], repo_root: Pa files[Path(_SCRIPTS_TEMPLATE.format(name=script))] = (policy_dir / "implementations" / script).read_text( encoding="utf-8" ) + elif gate: + files[Path(HOOKS_REL)] = json.dumps(gate_package.gate_hooks_file("devin", _gate_command()), indent=2) + "\n" + files[Path(_SCRIPTS_TEMPLATE.format(name="devin.py"))] = _adapter_source("devin") + files.update(gate_package.packaged_gate_files(policy_dir, gate, _SCRIPTS_TEMPLATE)) return files diff --git a/src/chock/plugin/gate_package.py b/src/chock/plugin/gate_package.py new file mode 100644 index 0000000..a33a9ce --- /dev/null +++ b/src/chock/plugin/gate_package.py @@ -0,0 +1,118 @@ +"""A policy's gate as a plugin carries it: what travels, where it runs, and what the package may claim. + +Shared by every hook-carrying store. A plugin installs at the agent, not in a repository, so +the compiled gate, the stdlib runner and a script gate's program travel together under the +package's `scripts/`, and the bundled runtime finds the runner beside the gate. What the gate +reaches in a client is agentseam's answer, never typed here: the write path where the vendor +records a write-tool vocabulary, the turn's end where it records a blocking stop hook. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from agentseam import contract, matrix + +from chock import vendors +from chock.compile.emitters.in_agent import GATE_FILE +from chock.compile.emitters.in_agent_hooks import hook_entry +from chock.gate import runtime_bundle +from chock.gate.runner import SCRIPT_BASE_GATE + +RUNNER_FILE = "gate.py" +IMPLEMENTATIONS = "implementations" + +_STOP_ONLY_POSTURE = ( + "Session-enforced at the turn's end by a Stop hook; needs python3. This client records no " + "file-writing tool vocabulary, so the write itself is not judged: what the turn actually left " + "on disk is re-read, and a construct a rule denies is refused then, however it was written. " + "Without python3, fail-open clients allow silently. A gate that cannot reach a decision " + "refuses rather than allowing one it never judged. Enforcement at every commit and in CI " + "still needs chock installed in the repo." +) +_WRITE_AND_STOP_POSTURE = ( + "Session-enforced via PreToolUse and Stop hooks; needs python3. PreToolUse judges the file " + "a tool call would write; Stop re-reads what the turn actually left on disk, so a file " + "written through a shell heredoc is judged too. Without python3, fail-open clients allow " + "silently. A gate that cannot reach a decision refuses rather than allowing one it never " + "judged. Enforcement at every commit and in CI still needs chock installed in the repo." +) +_STOP_ONLY_NOTE = ( + "This policy is enforced in this client by the Stop hook installed with the plugin: the " + "write itself is not judged here, because the client records no write-tool vocabulary, " + "and what the turn left on disk is judged at its end instead. Subject to the fail " + "conditions stated in the plugin description. Repo-wide enforcement across every commit " + "and in CI still needs `chock sync`. See https://github.com/open-coder-ai/chock" +) +_WRITE_AND_STOP_NOTE = ( + "This policy is enforced in this client by the PreToolUse and Stop hooks installed with " + "the plugin, subject to the fail conditions stated in the plugin description. Repo-wide " + "enforcement across every commit and in CI still needs `chock sync`. " + "See https://github.com/open-coder-ai/chock" +) + + +def gate_reach(vendor: str) -> tuple[str | None, bool]: + """(the write matcher, whether the turn's end blocks) for `vendor`, from agentseam's records.""" + return vendors.write_matcher(vendor), matrix.can_block(vendor, contract.STOP) + + +def gate_reaches(vendor: str) -> bool: + """Whether a packaged gate has any surface at all in `vendor`; without one, no hook is installed.""" + matcher, stop = gate_reach(vendor) + return matcher is not None or stop + + +def gate_hooks_file(vendor: str, command: str) -> dict[str, Any]: + """The hooks document running `command` on every surface the gate reaches in `vendor`.""" + matcher, stop = gate_reach(vendor) + entries: dict[str, list[dict[str, Any]]] = {} + if matcher is not None: + entries[vendors.pre_tool_event(vendor)] = [hook_entry(command, matcher=matcher)] + if stop: + entries[vendors.stop_event(vendor)] = [hook_entry(command)] + return entries if vendors.hook_entry_bare(vendor) else {"hooks": entries} + + +def gate_posture(vendor: str, caveat: str = "") -> str: + """The package's own statement of what its gate reaches in `vendor`, plus the vendor's caveat.""" + matcher, _ = gate_reach(vendor) + text = _WRITE_AND_STOP_POSTURE if matcher is not None else _STOP_ONLY_POSTURE + return f"{text} {caveat}".strip() + + +def gate_skill_note(vendor: str) -> str: + """The note the packaged skill ends with when it carries a gate.""" + matcher, _ = gate_reach(vendor) + return _WRITE_AND_STOP_NOTE if matcher is not None else _STOP_ONLY_NOTE + + +def runner_source() -> str: + """The stdlib-only gate runner, verbatim -- the one `chock sync` vendors under .chock/bin.""" + return (Path(runtime_bundle.__file__).resolve().parent / "runner.py").read_text(encoding="utf-8") + + +def packaged_gate_files(policy_dir: Path, spec: dict[str, Any], scripts_template: str) -> dict[Path, str]: + """The gate, the runner beside it, and a script gate's program with the files it imports. + + Keyed by path inside the package. A script gate's `implementations/` is copied whole, so + a program that puts its own directory on `sys.path` still finds what it imports, and the + gate says `script_base: gate` so the runner resolves the script beside the gate file + rather than under a repository root the plugin does not have. + """ + packaged = {key: value for key, value in spec.items() if key != "params"} + packaged["params"] = dict(spec.get("params") or {}) + files: dict[Path, str] = {Path(scripts_template.format(name=RUNNER_FILE)): runner_source()} + if spec.get("kind") == "script": + name = Path(str(packaged["params"].get("script", ""))).name + packaged["params"]["script"] = f"{IMPLEMENTATIONS}/{name}" + packaged["script_base"] = SCRIPT_BASE_GATE + root = Path(policy_dir) / IMPLEMENTATIONS + for path in sorted(root.rglob("*")): + if path.is_file() and "__pycache__" not in path.parts: + rel = Path(IMPLEMENTATIONS) / path.relative_to(root) + files[Path(scripts_template.format(name=rel.as_posix()))] = path.read_text(encoding="utf-8") + files[Path(scripts_template.format(name=GATE_FILE))] = json.dumps(packaged, indent=2) + "\n" + return files diff --git a/tests/test_plugin_gate_stores.py b/tests/test_plugin_gate_stores.py new file mode 100644 index 0000000..847cc27 --- /dev/null +++ b/tests/test_plugin_gate_stores.py @@ -0,0 +1,137 @@ +"""The packaged gate in every hook-carrying store, reaching exactly what agentseam records. + +Claude Code records a write-tool vocabulary and a blocking turn-end hook, so its package gates +both. Codex, Devin and Copilot record no write tools but block at the turn's end, so their +packages carry the gate at `Stop` alone and say so. Cursor records neither, so a gate has no +surface there and its package stays advisory rather than installing a hook that could only +refuse. None of that is typed here: the test asks agentseam the same question the emitter does. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest +from conftest import init_repo +from test_plugin_gate import POLICY_ID, SCRIPT, _manifest +from test_plugin_gate import policy as shared_policy + +from chock.gate import runtime_bundle +from chock.plugin import codex, copilot, cursor, devin +from chock.plugin.gate_package import gate_reach, gate_reaches + +#: The shared fixture under a name no test parameter shadows. +gate_policy = shared_policy + +STORES = { + "codex": ("codex_cli", codex.build_codex_plugin, codex.HOOKS_REL, codex.codex_plugin_differences), + "devin": ("devin", devin.build_devin_plugin, devin.HOOKS_REL, devin.devin_plugin_differences), + "copilot": ("vscode_copilot", copilot.build_copilot_plugin, copilot.HOOKS_REL, copilot.copilot_plugin_differences), + "cursor": ("cursor", cursor.build_cursor_plugin, cursor.HOOKS_REL, cursor.cursor_plugin_differences), +} + + +def _hooks_doc(out: Path, vendor: str, hooks_rel: str) -> dict: + doc = json.loads((out / hooks_rel).read_text(encoding="utf-8")) + return doc if vendor == "devin" else doc["hooks"] + + +@pytest.mark.parametrize("store", sorted(STORES)) +def test_the_gate_reaches_what_the_vendor_records(gate_policy, tmp_path: Path, store: str) -> None: + vendor, build, hooks_rel, _ = STORES[store] + manifest = _manifest() + out = tmp_path / "dist" / store / POLICY_ID + build(gate_policy(manifest), manifest, tmp_path, out) + matcher, stop = gate_reach(vendor) + + if not gate_reaches(vendor): + assert not (out / hooks_rel).exists() + assert not (out / "scripts").exists() + return + + hooks = _hooks_doc(out, vendor, hooks_rel) + events = set(hooks) + assert ("PreToolUse" in events or "preToolUse" in events) == (matcher is not None) + assert ("Stop" in events or "stop" in events) == stop + for entries in hooks.values(): + assert "--gate" in entries[0]["hooks"][0]["command"] + gate = json.loads((out / "scripts" / "gate.json").read_text(encoding="utf-8")) + assert gate["script_base"] == "gate" and gate["params"]["script"] == f"implementations/{SCRIPT}" + assert (out / "scripts" / "gate.py").exists() + assert (out / "scripts" / "implementations" / "helper.py").exists() + + +def test_which_vendors_the_gate_reaches_is_agentseam_s_answer() -> None: + """Pinned so a change upstream surfaces here rather than silently widening or narrowing a package.""" + assert gate_reach("claude_code") == ("Write|Edit|MultiEdit|NotebookEdit", True) + assert gate_reach("codex_cli") == (None, True) + assert gate_reach("devin") == (None, True) + assert gate_reach("vscode_copilot") == (None, True) + assert gate_reach("cursor") == (None, False) + + +@pytest.mark.parametrize("store", ["codex", "devin", "copilot"]) +def test_a_stop_only_package_says_the_write_is_not_judged(gate_policy, tmp_path: Path, store: str) -> None: + vendor, build, _, _ = STORES[store] + manifest = _manifest() + out = tmp_path / "dist" / store / POLICY_ID + build(gate_policy(manifest), manifest, tmp_path, out) + manifest_path = next(p for p in out.rglob("plugin.json")) + description = json.loads(manifest_path.read_text(encoding="utf-8"))["description"] + assert "Stop hook" in description and "write itself is not judged" in description + skill = (out / "skills" / POLICY_ID / "SKILL.md").read_text(encoding="utf-8") + assert "Stop hook installed with the plugin" in skill + assert "advisory: the client reading it" not in skill + + +def test_cursor_stays_advisory_for_a_gate(gate_policy, tmp_path: Path) -> None: + manifest = _manifest() + out = tmp_path / "dist" / "cursor" / POLICY_ID + cursor.build_cursor_plugin(gate_policy(manifest), manifest, tmp_path, out) + description = json.loads((out / ".cursor-plugin" / "plugin.json").read_text(encoding="utf-8"))["description"] + assert cursor.POSTURE_ADVISORY in description + + +@pytest.mark.parametrize("store", sorted(STORES)) +def test_output_is_byte_stable_in_every_store(gate_policy, tmp_path: Path, store: str) -> None: + _, build, _, differences = STORES[store] + manifest = _manifest() + pack = gate_policy(manifest) + out = tmp_path / "dist" / store / POLICY_ID + build(pack, manifest, tmp_path, out) + assert differences(pack, manifest, tmp_path, out) == [] + + +def test_codex_s_packaged_gate_reads_the_turn_from_where_the_agent_works(gate_policy, tmp_path: Path) -> None: + """The stop-only path end to end on a non-Claude bundle: the worktree is the event's cwd.""" + manifest = _manifest() + out = tmp_path / "dist" / "codex" / POLICY_ID + codex.build_codex_plugin(gate_policy(manifest), manifest, tmp_path, out) + repo = tmp_path / "project" + repo.mkdir() + init_repo(repo) + (repo / "Leak.java").write_text("FORBIDDEN\n", encoding="utf-8") + ns: dict = {"__name__": "chock_runtime_under_test"} + exec(compile(runtime_bundle.render("codex_cli"), "", "exec"), ns) # noqa: S102 -- the rendered runtime is the unit under test + event = SimpleNamespace(event="stop", tool=None, command=None, path=None, content=None, cwd=str(repo), raw={}) + refused = ns["evaluate_gate"](["--gate", str(out / "scripts" / "gate.json")], event) + assert refused is not None and "Leak.java" in refused[1] + + +@pytest.mark.parametrize("store", sorted(STORES)) +def test_skill_assets_ride_in_every_store(gate_policy, tmp_path: Path, store: str) -> None: + _, build, _, differences = STORES[store] + manifest = _manifest() + pack = gate_policy( + manifest, skill_files={"body.md": "## Guided setup\n\nOpen `setup.html`.", "setup.html": "

page

"} + ) + out = tmp_path / "dist" / store / POLICY_ID + build(pack, manifest, tmp_path, out) + assert (out / "skills" / POLICY_ID / "setup.html").read_text(encoding="utf-8") == "

page

" + assert "## Guided setup" in (out / "skills" / POLICY_ID / "SKILL.md").read_text(encoding="utf-8") + (pack / "skill" / "setup.html").unlink() + assert any("setup.html" in d for d in differences(pack, manifest, tmp_path, out)) + build(pack, manifest, tmp_path, out) + assert not (out / "skills" / POLICY_ID / "setup.html").exists()