From c9682fee93789f91c977d42169e66611e60edf33 Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:41:51 +0000 Subject: [PATCH] Adopt chock v0.9.0 and agentseam 0.3.0 `.framework-ref` moves v0.8.0 -> v0.9.0 and `requirements/chock.in` follows chock's pyproject at that tag (agentseam 0.2.1 -> 0.3.0); `requirements/chock.txt` is regenerated by pip-compile with hashes for the 0.3.0 wheel. Everything else is `chock sync --repo .` on the 0.9.0 release wheel, committed as it came out: - `.chock/compiled//pre-tool-use/` and `stop/` for the four policies that declare `on: [commit, tool_use]` -- the write-path gate and the turn-end backstop 0.9.0 emits for content policies, which 0.8.0 compiled to nothing. - The ten `.chock/bin/` runtimes and `gate.py`, built with agentseam 0.3.0 and refusing rather than falling through when the judge crashes. - This repo's own vendor configs gain the new entries (`.claude/settings.json` gets `Stop` beside `PreToolUse`; gemini, codex, devin, tabnine and `.agents/hooks.json` get theirs). - `.chock/coverage.json`: for those four policies the claude and gemini cells move from `enforced-at-commit` to `best-effort` with a stated basis, because the write-path hook is now installed and credited at the word its evidence can back. The commit hook underneath is unchanged. `stop` credits nothing. - `chock.lock` follows the manifests. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- .agents/hooks.json | 34 ++ .chock/bin/antigravity.py | 237 ++++++++++++-- .chock/bin/claude_code.py | 257 +++++++++++++-- .chock/bin/codex_cli.py | 256 +++++++++++++-- .chock/bin/cursor.py | 301 +++++++++++++++--- .chock/bin/devin.py | 258 +++++++++++++-- .chock/bin/gate.py | 167 +++++++++- .chock/bin/gemini_cli.py | 256 +++++++++++++-- .chock/bin/grok.py | 258 +++++++++++++-- .chock/bin/tabnine.py | 256 +++++++++++++-- .chock/bin/vscode_copilot.py | 223 +++++++++++-- .chock/bin/windsurf.py | 237 ++++++++++++-- .../pre-tool-use/gate.json | 14 + .../pre-tool-use/gemini_cli-write-hooks.json | 10 + .../pre-tool-use/pretooluse-write.json | 10 + .../stop/antigravity-hooks.json | 14 + .../stop/codex_cli-hooks.json | 15 + .../stop/devin-hooks.json | 12 + .../block-invisible-unicode/stop/gate.json | 14 + .../stop/gemini_cli-hooks.json | 14 + .../block-invisible-unicode/stop/stop.json | 9 + .../stop/tabnine-hooks.json | 15 + .../pre-tool-use/gate.json | 14 + .../pre-tool-use/gemini_cli-write-hooks.json | 10 + .../pre-tool-use/pretooluse-write.json | 10 + .../stop/antigravity-hooks.json | 14 + .../stop/codex_cli-hooks.json | 15 + .../stop/devin-hooks.json | 12 + .../stop/gate.json | 14 + .../stop/gemini_cli-hooks.json | 14 + .../stop/stop.json | 9 + .../stop/tabnine-hooks.json | 15 + .../pin-github-actions/pre-tool-use/gate.json | 14 + .../pre-tool-use/gemini_cli-write-hooks.json | 10 + .../pre-tool-use/pretooluse-write.json | 10 + .../stop/antigravity-hooks.json | 14 + .../stop/codex_cli-hooks.json | 15 + .../pin-github-actions/stop/devin-hooks.json | 12 + .../pin-github-actions/stop/gate.json | 14 + .../stop/gemini_cli-hooks.json | 14 + .../pin-github-actions/stop/stop.json | 9 + .../stop/tabnine-hooks.json | 15 + .../scan-secrets/pre-tool-use/gate.json | 15 + .../pre-tool-use/gemini_cli-write-hooks.json | 10 + .../pre-tool-use/pretooluse-write.json | 10 + .../scan-secrets/stop/antigravity-hooks.json | 14 + .../scan-secrets/stop/codex_cli-hooks.json | 15 + .../scan-secrets/stop/devin-hooks.json | 12 + .chock/compiled/scan-secrets/stop/gate.json | 15 + .../scan-secrets/stop/gemini_cli-hooks.json | 14 + .chock/compiled/scan-secrets/stop/stop.json | 9 + .../scan-secrets/stop/tabnine-hooks.json | 15 + .chock/coverage.json | 80 ++--- .claude/settings.json | 78 +++++ .codex/hooks.json | 38 +++ .devin/hooks.v1.json | 34 ++ .framework-ref | 2 +- .gemini/settings.json | 34 ++ .tabnine/agent/settings.json | 38 +++ chock.lock | 8 +- requirements/chock.in | 4 +- requirements/chock.txt | 6 +- 62 files changed, 3190 insertions(+), 382 deletions(-) create mode 100644 .chock/compiled/block-invisible-unicode/pre-tool-use/gate.json create mode 100644 .chock/compiled/block-invisible-unicode/pre-tool-use/gemini_cli-write-hooks.json create mode 100644 .chock/compiled/block-invisible-unicode/pre-tool-use/pretooluse-write.json create mode 100644 .chock/compiled/block-invisible-unicode/stop/antigravity-hooks.json create mode 100644 .chock/compiled/block-invisible-unicode/stop/codex_cli-hooks.json create mode 100644 .chock/compiled/block-invisible-unicode/stop/devin-hooks.json create mode 100644 .chock/compiled/block-invisible-unicode/stop/gate.json create mode 100644 .chock/compiled/block-invisible-unicode/stop/gemini_cli-hooks.json create mode 100644 .chock/compiled/block-invisible-unicode/stop/stop.json create mode 100644 .chock/compiled/block-invisible-unicode/stop/tabnine-hooks.json create mode 100644 .chock/compiled/block-wildcard-agent-permissions/pre-tool-use/gate.json create mode 100644 .chock/compiled/block-wildcard-agent-permissions/pre-tool-use/gemini_cli-write-hooks.json create mode 100644 .chock/compiled/block-wildcard-agent-permissions/pre-tool-use/pretooluse-write.json create mode 100644 .chock/compiled/block-wildcard-agent-permissions/stop/antigravity-hooks.json create mode 100644 .chock/compiled/block-wildcard-agent-permissions/stop/codex_cli-hooks.json create mode 100644 .chock/compiled/block-wildcard-agent-permissions/stop/devin-hooks.json create mode 100644 .chock/compiled/block-wildcard-agent-permissions/stop/gate.json create mode 100644 .chock/compiled/block-wildcard-agent-permissions/stop/gemini_cli-hooks.json create mode 100644 .chock/compiled/block-wildcard-agent-permissions/stop/stop.json create mode 100644 .chock/compiled/block-wildcard-agent-permissions/stop/tabnine-hooks.json create mode 100644 .chock/compiled/pin-github-actions/pre-tool-use/gate.json create mode 100644 .chock/compiled/pin-github-actions/pre-tool-use/gemini_cli-write-hooks.json create mode 100644 .chock/compiled/pin-github-actions/pre-tool-use/pretooluse-write.json create mode 100644 .chock/compiled/pin-github-actions/stop/antigravity-hooks.json create mode 100644 .chock/compiled/pin-github-actions/stop/codex_cli-hooks.json create mode 100644 .chock/compiled/pin-github-actions/stop/devin-hooks.json create mode 100644 .chock/compiled/pin-github-actions/stop/gate.json create mode 100644 .chock/compiled/pin-github-actions/stop/gemini_cli-hooks.json create mode 100644 .chock/compiled/pin-github-actions/stop/stop.json create mode 100644 .chock/compiled/pin-github-actions/stop/tabnine-hooks.json create mode 100644 .chock/compiled/scan-secrets/pre-tool-use/gate.json create mode 100644 .chock/compiled/scan-secrets/pre-tool-use/gemini_cli-write-hooks.json create mode 100644 .chock/compiled/scan-secrets/pre-tool-use/pretooluse-write.json create mode 100644 .chock/compiled/scan-secrets/stop/antigravity-hooks.json create mode 100644 .chock/compiled/scan-secrets/stop/codex_cli-hooks.json create mode 100644 .chock/compiled/scan-secrets/stop/devin-hooks.json create mode 100644 .chock/compiled/scan-secrets/stop/gate.json create mode 100644 .chock/compiled/scan-secrets/stop/gemini_cli-hooks.json create mode 100644 .chock/compiled/scan-secrets/stop/stop.json create mode 100644 .chock/compiled/scan-secrets/stop/tabnine-hooks.json diff --git a/.agents/hooks.json b/.agents/hooks.json index 51c8d58..8c756af 100644 --- a/.agents/hooks.json +++ b/.agents/hooks.json @@ -57,6 +57,40 @@ } ] } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python3\" \".chock/bin/antigravity.py\" --gate \".chock/compiled/block-invisible-unicode/stop/gate.json\"" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python3\" \".chock/bin/antigravity.py\" --gate \".chock/compiled/block-wildcard-agent-permissions/stop/gate.json\"" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python3\" \".chock/bin/antigravity.py\" --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python3\" \".chock/bin/antigravity.py\" --gate \".chock/compiled/scan-secrets/stop/gate.json\"" + } + ] + } ] } } \ No newline at end of file diff --git a/.chock/bin/antigravity.py b/.chock/bin/antigravity.py index b320995..647b424 100755 --- a/.chock/bin/antigravity.py +++ b/.chock/bin/antigravity.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.2.1 -- bundle("antigravity"). Do not hand-edit, except the +# Generated by agentseam 0.3.0 -- bundle("antigravity"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -15,14 +15,13 @@ import os as _chock_os import shlex as _chock_shlex -import shutil as _chock_shutil import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.2.1) +# contract (agentseam 0.3.0) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -310,7 +309,19 @@ def hj_parse(cfg, raw, wire=None): _TRANSFORM_MISSING_INPUT = "transform_missing_input" def hj_reverse(cfg): - """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" + """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides. + + One wire name per canonical event, which is a real limit and not an oversight (R3, gap 4). + Cursor is where it bites: `pre_tool` pins to `preToolUse`, and Cursor honours `ask` only at + `beforeShellExecution` / `beforeMCPExecution`. So installing at `pre_tool` forecloses `ask` + before dispatch is ever reached -- the runtime degrade to `deny` is honest about it, but the + install already chose. Deny-style policies, which is all that ships today, are unaffected. + + The day an ask-style Cursor policy exists, this map has to select by decision dialect + (deny -> `preToolUse`, ask -> `beforeShellExecution`) and become one-to-many. Recorded here + rather than built, so the resolution is not re-litigated from scratch; the three facts it + rests on are pinned in tests/test_cursor_ask_dialect.py. + """ reverse = {} for name, canonical in cfg["events"].items(): if canonical != UNKNOWN: @@ -602,6 +613,10 @@ def degrade(decision, event): # >>> agentseam handler >>> GUARD_VIOLATION = 1 +GUARD_ASK_EXIT = 3 + +PYTHON_SUFFIX = '.py' + _BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') GATE_LOG_ENV = 'CHOCK_GATE_LOG' @@ -614,6 +629,8 @@ def degrade(decision, event): GUARD_CLEAN = 'clean' +GUARD_ASKED = 'asked' + GUARD_UNCHECKED = 'unchecked' GUARD_ERRORED = 'errored' @@ -641,41 +658,56 @@ def find_bash(guard: _chock_Path) -> str | None: return candidate return None +def find_interpreter(guard: _chock_Path) -> str | None: + """The interpreter that can run `guard`: this Python for `.py`, otherwise a usable bash.""" + if guard.suffix == PYTHON_SUFFIX: + return sys.executable or None + return find_bash(guard) + def run_guard(guard: _chock_Path, command: str) -> str: - """`GUARD_BLOCKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + """`GUARD_BLOCKED` / `GUARD_ASKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + return run_guard_detailed(guard, command)[0] + +def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: + """`run_guard`'s verdict plus the guard's own first line, which an ask carries to the user.""" try: args = _chock_shlex.split(command) except ValueError: print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) - return GUARD_UNCHECKED + return (GUARD_UNCHECKED, '') if not args: - return GUARD_UNCHECKED - bash = find_bash(guard) - if bash is None: - print(f'chock: no usable bash found, {guard.name} not checked', file=sys.stderr) - return GUARD_UNCHECKED + return (GUARD_UNCHECKED, '') + interpreter = find_interpreter(guard) + if interpreter is None: + print(f'chock: no usable interpreter found, {guard.name} not checked', file=sys.stderr) + return (GUARD_UNCHECKED, '') try: env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} - proc = _chock_subprocess.run([bash, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([interpreter, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) except _chock_subprocess.TimeoutExpired: print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) - return GUARD_ERRORED + return (GUARD_ERRORED, '') except (OSError, UnicodeError) as exc: print(f'chock: guard could not run, not checked: {exc}', file=sys.stderr) - return GUARD_ERRORED + return (GUARD_ERRORED, '') + output = ((proc.stderr or '') + (proc.stdout or '')).strip() + first_line = output.splitlines()[0].strip() if output else '' if proc.returncode == GUARD_VIOLATION: sys.stderr.write(proc.stdout or '') sys.stderr.write(proc.stderr or '') - if not ((proc.stdout or '') + (proc.stderr or '')).strip(): + if not output: print(f'chock: blocked by {_chock_Path(guard).name} (guard gave no reason)', file=sys.stderr) - return GUARD_BLOCKED + return (GUARD_BLOCKED, first_line) + if proc.returncode == GUARD_ASK_EXIT: + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + return (GUARD_ASKED, first_line) if proc.returncode != 0: - detail = (proc.stderr or proc.stdout or '').strip().splitlines() - print(f'chock: guard exited {proc.returncode}, not checked' + (f': {detail[0][:120]}' if detail else ''), file=sys.stderr) - return GUARD_ERRORED - return GUARD_CLEAN + print(f'chock: guard exited {proc.returncode}, not checked' + (f': {first_line[:120]}' if first_line else ''), file=sys.stderr) + return (GUARD_ERRORED, '') + return (GUARD_CLEAN, '') -def log_outcome(guard: _chock_Path, tool: str, *, blocked: bool) -> None: +def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> None: """Append one outcome record. Best effort: never raises, never changes the verdict.""" try: if _chock_os.environ.get(GATE_LOG_ENV) == '0': @@ -695,7 +727,7 @@ def log_outcome(guard: _chock_Path, tool: str, *, blocked: bool) -> None: log_path = log_dir / 'gate-events.jsonl' if log_path.exists() and log_path.stat().st_size > _LOG_MAX_BYTES: log_path.replace(log_dir / 'gate-events.1.jsonl') - record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': 'block' if blocked else 'allow'} + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': verdict} with log_path.open('a', encoding='utf-8') as fh: fh.write(json.dumps(record, ensure_ascii=False) + '\n') except Exception: @@ -706,23 +738,176 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N guard = guard_path_from_argv(argv) if guard is None or not guard.exists(): return None - verdict = run_guard(guard, command) - if verdict in (GUARD_BLOCKED, GUARD_CLEAN): - log_outcome(guard, tool, blocked=verdict == GUARD_BLOCKED) + verdict, message = run_guard_detailed(guard, command) + logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} + if verdict in logged: + log_outcome(guard, tool, verdict=logged[verdict]) if verdict == GUARD_BLOCKED: return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + if verdict == GUARD_ASKED: + return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') if verdict == GUARD_ERRORED: return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") return None +GATE_FLAG = '--gate' -def handle(event): +_GATE_TIMEOUT_SECONDS = 30 + +_GATE_DEPTH_TO_CHOCK = 3 + +_RUNNER_PARTS = ('bin', 'gate.py') + +_GIT = 'git' + +_DELETED = 'D' + +_RENAMED = 'R' + +GATE_BLOCKED = 'blocked' + +GATE_CLEAN = 'clean' + +GATE_ERRORED = 'errored' + +VERDICT_DENY = 'deny' + +def gate_path_from_argv(argv): + """The `--gate ` argument a vendored runtime was invoked with, or None.""" + if GATE_FLAG in argv: + index = argv.index(GATE_FLAG) + if index + 1 < len(argv): + return _chock_Path(argv[index + 1]) + return None + +def runner_for(gate): + """The vendored gate runner beside this compiled gate, or None when it is not there.""" + parents = gate.resolve().parents + if len(parents) <= _GATE_DEPTH_TO_CHOCK: + return None + runner = parents[_GATE_DEPTH_TO_CHOCK].joinpath(*_RUNNER_PARTS) + return runner if runner.exists() else None + +def writes_from_event(event): + """The one file this tool call would write, or none when it carries no file text.""" + path = getattr(event, 'path', None) + content = getattr(event, 'content', None) + if not path or not isinstance(content, str): + return {} + return {str(path): content} + +def changed_paths(repo_root): + """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" + try: + proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + except (OSError, _chock_subprocess.SubprocessError): + return [] + if proc.returncode != 0: + return [] + fields = [field for field in (proc.stdout or '').split('\x00') if field] + paths = [] + skip_next = False + for field in fields: + if skip_next: + skip_next = False + continue + status, path = (field[:2], field[3:]) + skip_next = status.startswith(_RENAMED) + if _DELETED in status or not path: + continue + paths.append(path) + return paths + +def writes_from_worktree(repo_root): + """What this turn actually left on disk, however it was written. + + The write path sees only writes it recognises; a shell heredoc carries no file argument. + Reading final state is what makes that stop mattering, so this deliberately does not care + which tool produced the bytes. + """ + writes = {} + for path in changed_paths(repo_root): + try: + writes[path] = _chock_Path(repo_root, path).read_text(encoding='utf-8') + except (OSError, UnicodeDecodeError): + continue + return writes + +def run_gate(gate, writes, event): + """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) + except (OSError, _chock_subprocess.SubprocessError) as exc: + return (GATE_ERRORED, str(exc)) + if proc.returncode == 0: + return (GATE_CLEAN, '') + if proc.returncode == 1: + return (GATE_BLOCKED, (proc.stderr or '').strip()) + return (GATE_ERRORED, (proc.stderr or '').strip()) + +_EVENT_ARG = {'pre_tool': 'pre-tool-use', 'stop': 'stop'} + +PRE_TOOL = 'pre_tool' + +def root_for(gate): + """The repository this compiled gate belongs to, or None when the layout is not that.""" + parents = gate.resolve().parents + if len(parents) <= _GATE_DEPTH_TO_CHOCK: + return None + return parents[_GATE_DEPTH_TO_CHOCK].parent + +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 {} + +def evaluate_gate(argv, event): + """The decision this event earns from a compiled gate, or None when it has nothing to say.""" + gate = gate_path_from_argv(argv) + name = _EVENT_ARG.get(getattr(event, 'event', '')) + if gate is None or name is None or (not gate.exists()): + return None + writes = writes_for(event, gate) + if not writes: + return None + outcome, message = run_gate(gate, writes, name) + if outcome == GATE_BLOCKED: + return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') + if outcome == GATE_ERRORED: + return (VERDICT_DENY, f'chock could not check this write: {message}. Refusing rather than reporting an allow it never established.') + return None + + +def _judge(event): if event.event == "pre_tool" and event.command: verdict = evaluate(sys.argv[1:], event.command, event.tool or "") if verdict is not None: outcome, reason = verdict return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + gated = evaluate_gate(sys.argv[1:], event) + if gated is not None: + return Decision.deny(gated[1]) return None + + +def handle(event): + # A door that cannot decide refuses. An exception escaping here would exit the hook + # with a traceback, which every client reads as a non-blocking error: fail-open, with + # the reason on a stderr nobody watches. The refusal carries the reason instead. + try: + return _judge(event) + except Exception as exc: # noqa: BLE001 -- any failure here must refuse, never fall through + return Decision.deny( + "chock could not check this call (%s: %s). Refusing rather than reporting an " + "allow it never established." % (type(exc).__name__, exc) + ) # <<< agentseam handler <<< diff --git a/.chock/bin/claude_code.py b/.chock/bin/claude_code.py index b395abd..6185c6f 100755 --- a/.chock/bin/claude_code.py +++ b/.chock/bin/claude_code.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.2.1 -- bundle("claude_code"). Do not hand-edit, except the +# Generated by agentseam 0.3.0 -- bundle("claude_code"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.2.1) +# contract (agentseam 0.3.0) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -255,21 +255,26 @@ def _accepted_by_markers(c, raw, name): for event_name, required in c.get("accept_when_all", {}).items() ) +def _disqualified(cfg, c, raw, name): + """Every reason an entry declines a payload once its wire event name is known.""" + if name not in cfg["events"] and not (c.get("accept_any_name") and isinstance(name, str)): + return True + if "client_types" in c and raw.get("client_type") not in c["client_types"]: + return True + return _rejected_by_markers(c, raw) + def hj_claims(cfg, raw): """True when this payload matches the entry's marker discipline.""" if not isinstance(raw, dict): return False c = cfg["claims"] name = _wire_name(cfg, raw) + # A positive self-identification beats a shared event name, accept_names included. + if raw.get("client_type") in c.get("reject_client_types", ()): + return False if name in c.get("accept_names", ()): return True - if name not in cfg["events"]: - return False - if "client_types" in c and raw.get("client_type") not in c["client_types"]: - return False - if _rejected_by_markers(c, raw): - return False - return _accepted_by_markers(c, raw, name) + return not _disqualified(cfg, c, raw, name) and _accepted_by_markers(c, raw, name) def _segment(node, part): """One path segment: a dict key, or `key[N]` indexing the list under it.""" @@ -361,7 +366,19 @@ def hj_parse(cfg, raw, wire=None): _TRANSFORM_MISSING_INPUT = "transform_missing_input" def hj_reverse(cfg): - """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" + """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides. + + One wire name per canonical event, which is a real limit and not an oversight (R3, gap 4). + Cursor is where it bites: `pre_tool` pins to `preToolUse`, and Cursor honours `ask` only at + `beforeShellExecution` / `beforeMCPExecution`. So installing at `pre_tool` forecloses `ask` + before dispatch is ever reached -- the runtime degrade to `deny` is honest about it, but the + install already chose. Deny-style policies, which is all that ships today, are unaffected. + + The day an ask-style Cursor policy exists, this map has to select by decision dialect + (deny -> `preToolUse`, ask -> `beforeShellExecution`) and become one-to-many. Recorded here + rather than built, so the resolution is not re-litigated from scratch; the three facts it + rests on are pinned in tests/test_cursor_ask_dialect.py. + """ reverse = {} for name, canonical in cfg["events"].items(): if canonical != UNKNOWN: @@ -611,7 +628,7 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos AGENT = "claude_code" -VENDOR = {'agent': 'claude_code', 'claims': {'client_types': (None, 'claude_code'), 'event_key': ('hook_event_name',), 'mode': 'marker', 'notes': 'prompt_id rejects only when looks_like_claude_code(raw) is also false; a real Claude Code payload may carry prompt_id and must still be accepted (matrix-notes.json: fixed 2026-08-27).', 'reject_markers': ('turn_id', 'project_path', 'timestamp'), 'reject_markers_unless_probe': {'looks_like_claude_code': ('prompt_id',)}}, 'config_format': 'json', 'config_path': '.claude/settings.json', 'display': 'Claude Code', 'events': {'FileChanged': 'file_changed', 'InstructionsLoaded': 'instructions_loaded', 'PostToolUse': 'post_tool', 'PostToolUseFailure': 'tool_failure', 'PreCompact': 'pre_compact', 'PreToolUse': 'pre_tool', 'SessionEnd': 'session_end', 'SessionStart': 'session_start', 'Stop': 'stop', 'SubagentStart': 'subagent_start', 'SubagentStop': 'subagent_stop', 'UserPromptSubmit': 'prompt_submit'}, 'evidence': {'claims': {'basis': 'live-run', 'date': '2026-08-28', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'live-run', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'live-run', 'date': '2026-08-28', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'live-run', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'live-run', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'live-run', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'repo_root_token': {'basis': 'vendor-docs', 'date': '2026-09-01', 'test': 'tests/test_vendor_config.py::test_repo_root_token_is_recorded_only_where_primary_sourced'}, 'tools': {'basis': 'live-run', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'live-run', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'hook_json', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content', 'tool_input.new_string', 'tool_input.new_source', 'content', 'tool_input.edits[].new_string'), 'cwd': ('cwd',), 'output': ('tool_output',), 'path': ('tool_input.file_path', 'tool_input.path', 'tool_input.notebook_path', 'file_path'), 'prompt': ('prompt',), 'session_id': ('session_id',), 'tool': ('tool_name',), 'tool_use_id': ('tool_use_id',)}, 'hook_entry': {'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'repo_root_token': '${CLAUDE_PROJECT_DIR}', 'tools': {'shell': ('Bash',), 'write': ('Write', 'Edit', 'MultiEdit', 'NotebookEdit')}, 'verdicts': {'answer_events': ('PreToolUse', 'Stop', 'UserPromptSubmit'), 'bare_allow': 'silent', 'context_events': ('SessionStart', 'UserPromptSubmit'), 'context_source': 'context', 'degrade_notes': {'escalate': 'confirmation requested; this event cannot prompt, so it blocks', 'transform': 'input rewrite requested; this event cannot modify input, so it blocks'}, 'echo': 'reverse_map', 'gates': {'PreToolUse': {'grammar': 'G2', 'honours_escalate': True, 'honours_transform': True}, 'Stop': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'UserPromptSubmit': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'suffix', 'reason_defaults': {'deny_gate': 'blocked', 'escalate_gate': 'confirmation required'}, 'transform_grammar': 'hook_specific_updated_input', 'vocabulary': ('allow', 'ask', 'block', 'deny'), 'vocabulary_basis': 'verified', 'words': {'block': 'block', 'deny': 'deny', 'escalate': 'ask', 'transform': 'allow', 'vouch': 'allow'}}} +VENDOR = {'agent': 'claude_code', 'claims': {'client_types': (None, 'claude_code'), 'event_key': ('hook_event_name',), 'mode': 'marker', 'notes': 'prompt_id rejects only when looks_like_claude_code(raw) is also false; a real Claude Code payload may carry prompt_id and must still be accepted (matrix-notes.json: fixed 2026-08-27).', 'reject_markers': ('turn_id', 'project_path', 'timestamp'), 'reject_markers_unless_probe': {'looks_like_claude_code': ('prompt_id',)}}, 'config_format': 'json', 'config_path': '.claude/settings.json', 'display': 'Claude Code', 'events': {'FileChanged': 'file_changed', 'InstructionsLoaded': 'instructions_loaded', 'PostToolUse': 'post_tool', 'PostToolUseFailure': 'tool_failure', 'PreCompact': 'pre_compact', 'PreToolUse': 'pre_tool', 'SessionEnd': 'session_end', 'SessionStart': 'session_start', 'Stop': 'stop', 'SubagentStart': 'subagent_start', 'SubagentStop': 'subagent_stop', 'UserPromptSubmit': 'prompt_submit'}, 'evidence': {'claims': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'repo_root_token': {'basis': 'vendor-docs', 'date': '2026-09-01', 'test': 'tests/test_vendor_config.py::test_repo_root_token_is_recorded_only_where_primary_sourced'}, 'tools': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'hook_json', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content', 'tool_input.new_string', 'tool_input.new_source', 'content', 'tool_input.edits[].new_string'), 'cwd': ('cwd',), 'output': ('tool_output',), 'path': ('tool_input.file_path', 'tool_input.path', 'tool_input.notebook_path', 'file_path'), 'prompt': ('prompt',), 'session_id': ('session_id',), 'tool': ('tool_name',), 'tool_use_id': ('tool_use_id',)}, 'hook_entry': {'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'repo_root_token': '${CLAUDE_PROJECT_DIR}', 'tools': {'shell': ('Bash',), 'write': ('Write', 'Edit', 'MultiEdit', 'NotebookEdit')}, 'verdicts': {'answer_events': ('PreToolUse', 'Stop', 'UserPromptSubmit'), 'bare_allow': 'silent', 'context_events': ('SessionStart', 'UserPromptSubmit'), 'context_source': 'context', 'degrade_notes': {'escalate': 'confirmation requested; this event cannot prompt, so it blocks', 'transform': 'input rewrite requested; this event cannot modify input, so it blocks'}, 'echo': 'reverse_map', 'gates': {'PreToolUse': {'grammar': 'G2', 'honours_escalate': True, 'honours_transform': True}, 'Stop': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'UserPromptSubmit': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'suffix', 'reason_defaults': {'deny_gate': 'blocked', 'escalate_gate': 'confirmation required'}, 'transform_grammar': 'hook_specific_updated_input', 'vocabulary': ('allow', 'ask', 'block', 'deny'), 'vocabulary_basis': 'verified', 'words': {'block': 'block', 'deny': 'deny', 'escalate': 'ask', 'transform': 'allow', 'vouch': 'allow'}}} def claims(raw): @@ -664,6 +681,10 @@ def degrade(decision, event): # >>> agentseam handler >>> GUARD_VIOLATION = 1 +GUARD_ASK_EXIT = 3 + +PYTHON_SUFFIX = '.py' + _BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') GATE_LOG_ENV = 'CHOCK_GATE_LOG' @@ -676,6 +697,8 @@ def degrade(decision, event): GUARD_CLEAN = 'clean' +GUARD_ASKED = 'asked' + GUARD_UNCHECKED = 'unchecked' GUARD_ERRORED = 'errored' @@ -703,41 +726,56 @@ def find_bash(guard: _chock_Path) -> str | None: return candidate return None +def find_interpreter(guard: _chock_Path) -> str | None: + """The interpreter that can run `guard`: this Python for `.py`, otherwise a usable bash.""" + if guard.suffix == PYTHON_SUFFIX: + return sys.executable or None + return find_bash(guard) + def run_guard(guard: _chock_Path, command: str) -> str: - """`GUARD_BLOCKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + """`GUARD_BLOCKED` / `GUARD_ASKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + return run_guard_detailed(guard, command)[0] + +def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: + """`run_guard`'s verdict plus the guard's own first line, which an ask carries to the user.""" try: args = _chock_shlex.split(command) except ValueError: print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) - return GUARD_UNCHECKED + return (GUARD_UNCHECKED, '') if not args: - return GUARD_UNCHECKED - bash = find_bash(guard) - if bash is None: - print(f'chock: no usable bash found, {guard.name} not checked', file=sys.stderr) - return GUARD_UNCHECKED + return (GUARD_UNCHECKED, '') + interpreter = find_interpreter(guard) + if interpreter is None: + print(f'chock: no usable interpreter found, {guard.name} not checked', file=sys.stderr) + return (GUARD_UNCHECKED, '') try: env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} - proc = _chock_subprocess.run([bash, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([interpreter, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) except _chock_subprocess.TimeoutExpired: print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) - return GUARD_ERRORED + return (GUARD_ERRORED, '') except (OSError, UnicodeError) as exc: print(f'chock: guard could not run, not checked: {exc}', file=sys.stderr) - return GUARD_ERRORED + return (GUARD_ERRORED, '') + output = ((proc.stderr or '') + (proc.stdout or '')).strip() + first_line = output.splitlines()[0].strip() if output else '' if proc.returncode == GUARD_VIOLATION: sys.stderr.write(proc.stdout or '') sys.stderr.write(proc.stderr or '') - if not ((proc.stdout or '') + (proc.stderr or '')).strip(): + if not output: print(f'chock: blocked by {_chock_Path(guard).name} (guard gave no reason)', file=sys.stderr) - return GUARD_BLOCKED + return (GUARD_BLOCKED, first_line) + if proc.returncode == GUARD_ASK_EXIT: + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + return (GUARD_ASKED, first_line) if proc.returncode != 0: - detail = (proc.stderr or proc.stdout or '').strip().splitlines() - print(f'chock: guard exited {proc.returncode}, not checked' + (f': {detail[0][:120]}' if detail else ''), file=sys.stderr) - return GUARD_ERRORED - return GUARD_CLEAN + print(f'chock: guard exited {proc.returncode}, not checked' + (f': {first_line[:120]}' if first_line else ''), file=sys.stderr) + return (GUARD_ERRORED, '') + return (GUARD_CLEAN, '') -def log_outcome(guard: _chock_Path, tool: str, *, blocked: bool) -> None: +def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> None: """Append one outcome record. Best effort: never raises, never changes the verdict.""" try: if _chock_os.environ.get(GATE_LOG_ENV) == '0': @@ -757,7 +795,7 @@ def log_outcome(guard: _chock_Path, tool: str, *, blocked: bool) -> None: log_path = log_dir / 'gate-events.jsonl' if log_path.exists() and log_path.stat().st_size > _LOG_MAX_BYTES: log_path.replace(log_dir / 'gate-events.1.jsonl') - record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': 'block' if blocked else 'allow'} + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': verdict} with log_path.open('a', encoding='utf-8') as fh: fh.write(json.dumps(record, ensure_ascii=False) + '\n') except Exception: @@ -768,15 +806,152 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N guard = guard_path_from_argv(argv) if guard is None or not guard.exists(): return None - verdict = run_guard(guard, command) - if verdict in (GUARD_BLOCKED, GUARD_CLEAN): - log_outcome(guard, tool, blocked=verdict == GUARD_BLOCKED) + verdict, message = run_guard_detailed(guard, command) + logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} + if verdict in logged: + log_outcome(guard, tool, verdict=logged[verdict]) if verdict == GUARD_BLOCKED: return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + if verdict == GUARD_ASKED: + return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') if verdict == GUARD_ERRORED: return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") return None +GATE_FLAG = '--gate' + +_GATE_TIMEOUT_SECONDS = 30 + +_GATE_DEPTH_TO_CHOCK = 3 + +_RUNNER_PARTS = ('bin', 'gate.py') + +_GIT = 'git' + +_DELETED = 'D' + +_RENAMED = 'R' + +GATE_BLOCKED = 'blocked' + +GATE_CLEAN = 'clean' + +GATE_ERRORED = 'errored' + +VERDICT_DENY = 'deny' + +def gate_path_from_argv(argv): + """The `--gate ` argument a vendored runtime was invoked with, or None.""" + if GATE_FLAG in argv: + index = argv.index(GATE_FLAG) + if index + 1 < len(argv): + return _chock_Path(argv[index + 1]) + return None + +def runner_for(gate): + """The vendored gate runner beside this compiled gate, or None when it is not there.""" + parents = gate.resolve().parents + if len(parents) <= _GATE_DEPTH_TO_CHOCK: + return None + runner = parents[_GATE_DEPTH_TO_CHOCK].joinpath(*_RUNNER_PARTS) + return runner if runner.exists() else None + +def writes_from_event(event): + """The one file this tool call would write, or none when it carries no file text.""" + path = getattr(event, 'path', None) + content = getattr(event, 'content', None) + if not path or not isinstance(content, str): + return {} + return {str(path): content} + +def changed_paths(repo_root): + """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" + try: + proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + except (OSError, _chock_subprocess.SubprocessError): + return [] + if proc.returncode != 0: + return [] + fields = [field for field in (proc.stdout or '').split('\x00') if field] + paths = [] + skip_next = False + for field in fields: + if skip_next: + skip_next = False + continue + status, path = (field[:2], field[3:]) + skip_next = status.startswith(_RENAMED) + if _DELETED in status or not path: + continue + paths.append(path) + return paths + +def writes_from_worktree(repo_root): + """What this turn actually left on disk, however it was written. + + The write path sees only writes it recognises; a shell heredoc carries no file argument. + Reading final state is what makes that stop mattering, so this deliberately does not care + which tool produced the bytes. + """ + writes = {} + for path in changed_paths(repo_root): + try: + writes[path] = _chock_Path(repo_root, path).read_text(encoding='utf-8') + except (OSError, UnicodeDecodeError): + continue + return writes + +def run_gate(gate, writes, event): + """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) + except (OSError, _chock_subprocess.SubprocessError) as exc: + return (GATE_ERRORED, str(exc)) + if proc.returncode == 0: + return (GATE_CLEAN, '') + if proc.returncode == 1: + return (GATE_BLOCKED, (proc.stderr or '').strip()) + return (GATE_ERRORED, (proc.stderr or '').strip()) + +_EVENT_ARG = {'pre_tool': 'pre-tool-use', 'stop': 'stop'} + +PRE_TOOL = 'pre_tool' + +def root_for(gate): + """The repository this compiled gate belongs to, or None when the layout is not that.""" + parents = gate.resolve().parents + if len(parents) <= _GATE_DEPTH_TO_CHOCK: + return None + return parents[_GATE_DEPTH_TO_CHOCK].parent + +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 {} + +def evaluate_gate(argv, event): + """The decision this event earns from a compiled gate, or None when it has nothing to say.""" + gate = gate_path_from_argv(argv) + name = _EVENT_ARG.get(getattr(event, 'event', '')) + if gate is None or name is None or (not gate.exists()): + return None + writes = writes_for(event, gate) + if not writes: + return None + outcome, message = run_gate(gate, writes, name) + if outcome == GATE_BLOCKED: + return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') + if outcome == GATE_ERRORED: + return (VERDICT_DENY, f'chock could not check this write: {message}. Refusing rather than reporting an allow it never established.') + return None + _GIT = _chock_shutil.which('git') or 'git' _INSTRUCTION = "Chock: this clone's git hooks are NOT installed -- git never clones hooks, so commit-time gates will not run locally until someone runs:\n pip install chock && chock sync --repo .\nRun that before the first commit. (The repo's CI gate, where wired, enforces regardless.)" @@ -844,15 +1019,31 @@ def _chock_handle_session_start(event): return Decision.allow(context=_INSTRUCTION) -def handle(event): +def _judge(event): if event.event == "pre_tool" and event.command: verdict = evaluate(sys.argv[1:], event.command, event.tool or "") if verdict is not None: outcome, reason = verdict return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + gated = evaluate_gate(sys.argv[1:], event) + if gated is not None: + return Decision.deny(gated[1]) if event.event == "session_start": return _chock_handle_session_start(event) return None + + +def handle(event): + # A door that cannot decide refuses. An exception escaping here would exit the hook + # with a traceback, which every client reads as a non-blocking error: fail-open, with + # the reason on a stderr nobody watches. The refusal carries the reason instead. + try: + return _judge(event) + except Exception as exc: # noqa: BLE001 -- any failure here must refuse, never fall through + return Decision.deny( + "chock could not check this call (%s: %s). Refusing rather than reporting an " + "allow it never established." % (type(exc).__name__, exc) + ) # <<< agentseam handler <<< diff --git a/.chock/bin/codex_cli.py b/.chock/bin/codex_cli.py index 52d2ce9..7a72300 100755 --- a/.chock/bin/codex_cli.py +++ b/.chock/bin/codex_cli.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.2.1 -- bundle("codex_cli"). Do not hand-edit, except the +# Generated by agentseam 0.3.0 -- bundle("codex_cli"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -15,14 +15,13 @@ import os as _chock_os import shlex as _chock_shlex -import shutil as _chock_shutil import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.2.1) +# contract (agentseam 0.3.0) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -242,21 +241,26 @@ def _accepted_by_markers(c, raw, name): for event_name, required in c.get("accept_when_all", {}).items() ) +def _disqualified(cfg, c, raw, name): + """Every reason an entry declines a payload once its wire event name is known.""" + if name not in cfg["events"] and not (c.get("accept_any_name") and isinstance(name, str)): + return True + if "client_types" in c and raw.get("client_type") not in c["client_types"]: + return True + return _rejected_by_markers(c, raw) + def hj_claims(cfg, raw): """True when this payload matches the entry's marker discipline.""" if not isinstance(raw, dict): return False c = cfg["claims"] name = _wire_name(cfg, raw) + # A positive self-identification beats a shared event name, accept_names included. + if raw.get("client_type") in c.get("reject_client_types", ()): + return False if name in c.get("accept_names", ()): return True - if name not in cfg["events"]: - return False - if "client_types" in c and raw.get("client_type") not in c["client_types"]: - return False - if _rejected_by_markers(c, raw): - return False - return _accepted_by_markers(c, raw, name) + return not _disqualified(cfg, c, raw, name) and _accepted_by_markers(c, raw, name) def _segment(node, part): """One path segment: a dict key, or `key[N]` indexing the list under it.""" @@ -348,7 +352,19 @@ def hj_parse(cfg, raw, wire=None): _TRANSFORM_MISSING_INPUT = "transform_missing_input" def hj_reverse(cfg): - """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" + """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides. + + One wire name per canonical event, which is a real limit and not an oversight (R3, gap 4). + Cursor is where it bites: `pre_tool` pins to `preToolUse`, and Cursor honours `ask` only at + `beforeShellExecution` / `beforeMCPExecution`. So installing at `pre_tool` forecloses `ask` + before dispatch is ever reached -- the runtime degrade to `deny` is honest about it, but the + install already chose. Deny-style policies, which is all that ships today, are unaffected. + + The day an ask-style Cursor policy exists, this map has to select by decision dialect + (deny -> `preToolUse`, ask -> `beforeShellExecution`) and become one-to-many. Recorded here + rather than built, so the resolution is not re-litigated from scratch; the three facts it + rests on are pinned in tests/test_cursor_ask_dialect.py. + """ reverse = {} for name, canonical in cfg["events"].items(): if canonical != UNKNOWN: @@ -651,6 +667,10 @@ def degrade(decision, event): # >>> agentseam handler >>> GUARD_VIOLATION = 1 +GUARD_ASK_EXIT = 3 + +PYTHON_SUFFIX = '.py' + _BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') GATE_LOG_ENV = 'CHOCK_GATE_LOG' @@ -663,6 +683,8 @@ def degrade(decision, event): GUARD_CLEAN = 'clean' +GUARD_ASKED = 'asked' + GUARD_UNCHECKED = 'unchecked' GUARD_ERRORED = 'errored' @@ -690,41 +712,56 @@ def find_bash(guard: _chock_Path) -> str | None: return candidate return None +def find_interpreter(guard: _chock_Path) -> str | None: + """The interpreter that can run `guard`: this Python for `.py`, otherwise a usable bash.""" + if guard.suffix == PYTHON_SUFFIX: + return sys.executable or None + return find_bash(guard) + def run_guard(guard: _chock_Path, command: str) -> str: - """`GUARD_BLOCKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + """`GUARD_BLOCKED` / `GUARD_ASKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + return run_guard_detailed(guard, command)[0] + +def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: + """`run_guard`'s verdict plus the guard's own first line, which an ask carries to the user.""" try: args = _chock_shlex.split(command) except ValueError: print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) - return GUARD_UNCHECKED + return (GUARD_UNCHECKED, '') if not args: - return GUARD_UNCHECKED - bash = find_bash(guard) - if bash is None: - print(f'chock: no usable bash found, {guard.name} not checked', file=sys.stderr) - return GUARD_UNCHECKED + return (GUARD_UNCHECKED, '') + interpreter = find_interpreter(guard) + if interpreter is None: + print(f'chock: no usable interpreter found, {guard.name} not checked', file=sys.stderr) + return (GUARD_UNCHECKED, '') try: env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} - proc = _chock_subprocess.run([bash, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([interpreter, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) except _chock_subprocess.TimeoutExpired: print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) - return GUARD_ERRORED + return (GUARD_ERRORED, '') except (OSError, UnicodeError) as exc: print(f'chock: guard could not run, not checked: {exc}', file=sys.stderr) - return GUARD_ERRORED + return (GUARD_ERRORED, '') + output = ((proc.stderr or '') + (proc.stdout or '')).strip() + first_line = output.splitlines()[0].strip() if output else '' if proc.returncode == GUARD_VIOLATION: sys.stderr.write(proc.stdout or '') sys.stderr.write(proc.stderr or '') - if not ((proc.stdout or '') + (proc.stderr or '')).strip(): + if not output: print(f'chock: blocked by {_chock_Path(guard).name} (guard gave no reason)', file=sys.stderr) - return GUARD_BLOCKED + return (GUARD_BLOCKED, first_line) + if proc.returncode == GUARD_ASK_EXIT: + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + return (GUARD_ASKED, first_line) if proc.returncode != 0: - detail = (proc.stderr or proc.stdout or '').strip().splitlines() - print(f'chock: guard exited {proc.returncode}, not checked' + (f': {detail[0][:120]}' if detail else ''), file=sys.stderr) - return GUARD_ERRORED - return GUARD_CLEAN + print(f'chock: guard exited {proc.returncode}, not checked' + (f': {first_line[:120]}' if first_line else ''), file=sys.stderr) + return (GUARD_ERRORED, '') + return (GUARD_CLEAN, '') -def log_outcome(guard: _chock_Path, tool: str, *, blocked: bool) -> None: +def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> None: """Append one outcome record. Best effort: never raises, never changes the verdict.""" try: if _chock_os.environ.get(GATE_LOG_ENV) == '0': @@ -744,7 +781,7 @@ def log_outcome(guard: _chock_Path, tool: str, *, blocked: bool) -> None: log_path = log_dir / 'gate-events.jsonl' if log_path.exists() and log_path.stat().st_size > _LOG_MAX_BYTES: log_path.replace(log_dir / 'gate-events.1.jsonl') - record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': 'block' if blocked else 'allow'} + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': verdict} with log_path.open('a', encoding='utf-8') as fh: fh.write(json.dumps(record, ensure_ascii=False) + '\n') except Exception: @@ -755,23 +792,176 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N guard = guard_path_from_argv(argv) if guard is None or not guard.exists(): return None - verdict = run_guard(guard, command) - if verdict in (GUARD_BLOCKED, GUARD_CLEAN): - log_outcome(guard, tool, blocked=verdict == GUARD_BLOCKED) + verdict, message = run_guard_detailed(guard, command) + logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} + if verdict in logged: + log_outcome(guard, tool, verdict=logged[verdict]) if verdict == GUARD_BLOCKED: return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + if verdict == GUARD_ASKED: + return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') if verdict == GUARD_ERRORED: return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") return None +GATE_FLAG = '--gate' -def handle(event): +_GATE_TIMEOUT_SECONDS = 30 + +_GATE_DEPTH_TO_CHOCK = 3 + +_RUNNER_PARTS = ('bin', 'gate.py') + +_GIT = 'git' + +_DELETED = 'D' + +_RENAMED = 'R' + +GATE_BLOCKED = 'blocked' + +GATE_CLEAN = 'clean' + +GATE_ERRORED = 'errored' + +VERDICT_DENY = 'deny' + +def gate_path_from_argv(argv): + """The `--gate ` argument a vendored runtime was invoked with, or None.""" + if GATE_FLAG in argv: + index = argv.index(GATE_FLAG) + if index + 1 < len(argv): + return _chock_Path(argv[index + 1]) + return None + +def runner_for(gate): + """The vendored gate runner beside this compiled gate, or None when it is not there.""" + parents = gate.resolve().parents + if len(parents) <= _GATE_DEPTH_TO_CHOCK: + return None + runner = parents[_GATE_DEPTH_TO_CHOCK].joinpath(*_RUNNER_PARTS) + return runner if runner.exists() else None + +def writes_from_event(event): + """The one file this tool call would write, or none when it carries no file text.""" + path = getattr(event, 'path', None) + content = getattr(event, 'content', None) + if not path or not isinstance(content, str): + return {} + return {str(path): content} + +def changed_paths(repo_root): + """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" + try: + proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + except (OSError, _chock_subprocess.SubprocessError): + return [] + if proc.returncode != 0: + return [] + fields = [field for field in (proc.stdout or '').split('\x00') if field] + paths = [] + skip_next = False + for field in fields: + if skip_next: + skip_next = False + continue + status, path = (field[:2], field[3:]) + skip_next = status.startswith(_RENAMED) + if _DELETED in status or not path: + continue + paths.append(path) + return paths + +def writes_from_worktree(repo_root): + """What this turn actually left on disk, however it was written. + + The write path sees only writes it recognises; a shell heredoc carries no file argument. + Reading final state is what makes that stop mattering, so this deliberately does not care + which tool produced the bytes. + """ + writes = {} + for path in changed_paths(repo_root): + try: + writes[path] = _chock_Path(repo_root, path).read_text(encoding='utf-8') + except (OSError, UnicodeDecodeError): + continue + return writes + +def run_gate(gate, writes, event): + """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) + except (OSError, _chock_subprocess.SubprocessError) as exc: + return (GATE_ERRORED, str(exc)) + if proc.returncode == 0: + return (GATE_CLEAN, '') + if proc.returncode == 1: + return (GATE_BLOCKED, (proc.stderr or '').strip()) + return (GATE_ERRORED, (proc.stderr or '').strip()) + +_EVENT_ARG = {'pre_tool': 'pre-tool-use', 'stop': 'stop'} + +PRE_TOOL = 'pre_tool' + +def root_for(gate): + """The repository this compiled gate belongs to, or None when the layout is not that.""" + parents = gate.resolve().parents + if len(parents) <= _GATE_DEPTH_TO_CHOCK: + return None + return parents[_GATE_DEPTH_TO_CHOCK].parent + +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 {} + +def evaluate_gate(argv, event): + """The decision this event earns from a compiled gate, or None when it has nothing to say.""" + gate = gate_path_from_argv(argv) + name = _EVENT_ARG.get(getattr(event, 'event', '')) + if gate is None or name is None or (not gate.exists()): + return None + writes = writes_for(event, gate) + if not writes: + return None + outcome, message = run_gate(gate, writes, name) + if outcome == GATE_BLOCKED: + return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') + if outcome == GATE_ERRORED: + return (VERDICT_DENY, f'chock could not check this write: {message}. Refusing rather than reporting an allow it never established.') + return None + + +def _judge(event): if event.event == "pre_tool" and event.command: verdict = evaluate(sys.argv[1:], event.command, event.tool or "") if verdict is not None: outcome, reason = verdict return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + gated = evaluate_gate(sys.argv[1:], event) + if gated is not None: + return Decision.deny(gated[1]) return None + + +def handle(event): + # A door that cannot decide refuses. An exception escaping here would exit the hook + # with a traceback, which every client reads as a non-blocking error: fail-open, with + # the reason on a stderr nobody watches. The refusal carries the reason instead. + try: + return _judge(event) + except Exception as exc: # noqa: BLE001 -- any failure here must refuse, never fall through + return Decision.deny( + "chock could not check this call (%s: %s). Refusing rather than reporting an " + "allow it never established." % (type(exc).__name__, exc) + ) # <<< agentseam handler <<< diff --git a/.chock/bin/cursor.py b/.chock/bin/cursor.py index 7e38d7f..579e895 100755 --- a/.chock/bin/cursor.py +++ b/.chock/bin/cursor.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.2.1 -- bundle("cursor"). Do not hand-edit, except the +# Generated by agentseam 0.3.0 -- bundle("cursor"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -15,14 +15,13 @@ import os as _chock_os import shlex as _chock_shlex -import shutil as _chock_shutil import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.2.1) +# contract (agentseam 0.3.0) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -307,8 +306,22 @@ def hj_parse(cfg, raw, wire=None): _ESCALATE_FROM_TRANSFORM = "escalate_from_transform" +_TRANSFORM_MISSING_INPUT = "transform_missing_input" + def hj_reverse(cfg): - """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" + """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides. + + One wire name per canonical event, which is a real limit and not an oversight (R3, gap 4). + Cursor is where it bites: `pre_tool` pins to `preToolUse`, and Cursor honours `ask` only at + `beforeShellExecution` / `beforeMCPExecution`. So installing at `pre_tool` forecloses `ask` + before dispatch is ever reached -- the runtime degrade to `deny` is honest about it, but the + install already chose. Deny-style policies, which is all that ships today, are unaffected. + + The day an ask-style Cursor policy exists, this map has to select by decision dialect + (deny -> `preToolUse`, ask -> `beforeShellExecution`) and become one-to-many. Recorded here + rather than built, so the resolution is not re-litigated from scratch; the three facts it + rests on are pinned in tests/test_cursor_ask_dialect.py. + """ reverse = {} for name, canonical in cfg["events"].items(): if canonical != UNKNOWN: @@ -440,11 +453,16 @@ def _because(reason, note): return "%s (%s)" % (reason, note) if reason else note def _wire_of(cfg, event): - """The wire name to answer at: the payload's own, `tool` where `parse` kept it there, - else the entry's default gate.""" + """The wire name to answer at -- `cursor_wire` again, so respond and parse cannot diverge. + + `tool` is read only for an Event carrying no payload, where `parse` left the inferred + name there; without a payload there is nothing to re-infer from. + """ name = (event.raw or {}).get("hook_event_name") if name in cfg["events"]: return name + if event.raw: + return cursor_wire(event.raw) return event.tool if event.tool in cfg["events"] else cfg["verdicts"].get("default_wire_event") def _flag_payload(v, decision, name): @@ -454,27 +472,39 @@ def _flag_payload(v, decision, name): note = v["flag_note"] % (name, decision.reason or v["flag_note_default"]) return _json.dumps({"additional_context": note}), 0 -def _prompt_submit_payload(decision): - payload = {"continue": decision.outcome not in (DENY, ESCALATE, TRANSFORM)} - if decision.reason: - payload["user_message"] = decision.reason +def _prompt_submit_payload(v, gate, decision, name): + """`user_message` is end-user text, so it carries a refusal -- never an allow's own + rationale, which the permission gate has never surfaced either.""" + blocking = decision.outcome in (DENY, ESCALATE, TRANSFORM) + payload = {"continue": not blocking} + reason = _refusal_reason(v, gate, decision, name) if blocking else None + if reason: + payload["user_message"] = reason return _json.dumps(payload), 0 -def _gate_payload(words, notes, gate, decision, name): - """The PRE_TOOL gate's (permission, reason) pair, before the shared trailing message rule.""" - reason = decision.reason +def _refusal_reason(v, gate, decision, name): + """The handler's own reason, plus why the outcome changed shape on the way out.""" + notes = v["degrade_notes"] if decision.outcome == TRANSFORM: - if gate["honours_transform"] and decision.updated_input is not None: - return {"permission": words["allow"], "updated_input": decision.updated_input}, reason - return {"permission": words["block"]}, _because(reason, notes["transform"]) - if decision.outcome == DENY: - return {"permission": words["block"]}, reason + # A gate that DOES honour transform refused only for want of a replacement input; + # "this gate cannot express it" would be false at the one gate that can. + key = _TRANSFORM_MISSING_INPUT if gate["honours_transform"] else "transform" + return _because(decision.reason, notes[key]) if decision.outcome == ESCALATE: - if gate["honours_escalate"]: - return {"permission": words["escalate"]}, reason note = notes[_ESCALATE_FROM_TRANSFORM] if degraded_from(decision) == TRANSFORM else notes["escalate"] - return {"permission": words["block"]}, _because(reason, note % name) - return {"permission": words["allow"]}, reason + return _because(decision.reason, note % name) + return decision.reason + +def _gate_payload(v, gate, decision, name): + """The PRE_TOOL gate's (permission, reason) pair, before the shared trailing message rule.""" + words = v["words"] + if decision.outcome == TRANSFORM and gate["honours_transform"] and decision.updated_input is not None: + return {"permission": words["allow"], "updated_input": decision.updated_input}, decision.reason + if decision.outcome == ESCALATE and gate["honours_escalate"]: + return {"permission": words["escalate"]}, decision.reason + if decision.outcome in (DENY, ESCALATE, TRANSFORM): + return {"permission": words["block"]}, _refusal_reason(v, gate, decision, name) + return {"permission": words["allow"]}, decision.reason def cursor_respond(cfg, decision, event): v = cfg["verdicts"] @@ -485,14 +515,13 @@ def cursor_respond(cfg, decision, event): return "", 0 if canonical in (POST_TOOL, TOOL_FAILURE): return _flag_payload(v, decision, name) - if canonical == PROMPT_SUBMIT: - return _prompt_submit_payload(decision) - gate = v["gates"].get(name) + if canonical == PROMPT_SUBMIT and gate is not None: + return _prompt_submit_payload(v, gate, decision, name) if gate is None or canonical != PRE_TOOL: return "", 0 - payload, reason = _gate_payload(v["words"], v["degrade_notes"], gate, decision, name) + payload, reason = _gate_payload(v, gate, decision, name) if reason and payload["permission"] != v["words"]["allow"]: payload["user_message"] = reason payload["agent_message"] = reason @@ -504,7 +533,7 @@ def cursor_respond(cfg, decision, event): AGENT = "cursor" -VENDOR = {'agent': 'cursor', 'claims': {'mode': 'shape_inferred'}, 'config_format': 'json', 'config_path': '.cursor/hooks.json', 'display': 'Cursor', 'events': {'afterFileEdit': 'file_changed', 'afterMCPExecution': 'post_tool', 'afterShellExecution': 'post_tool', 'afterTabFileEdit': 'file_changed', 'beforeMCPExecution': 'pre_tool', 'beforeReadFile': 'pre_tool', 'beforeShellExecution': 'pre_tool', 'beforeSubmitPrompt': 'prompt_submit', 'beforeTabFileRead': 'pre_tool', 'postToolUse': 'post_tool', 'postToolUseFailure': 'tool_failure', 'preCompact': 'pre_compact', 'preToolUse': 'pre_tool', 'sessionEnd': 'session_end', 'sessionStart': 'session_start', 'stop': 'stop', 'subagentStart': 'subagent_start', 'subagentStop': 'subagent_stop'}, 'evidence': {'claims': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'cursor', 'fields': {'command': ('command', 'tool_input.command'), 'content': ('edits[].new_string', 'tool_input.content', 'tool_input.new_string', 'content'), 'cwd': ('cwd',), 'output': ('tool_output', 'output', 'result_json'), 'path': ('file_path', 'tool_input.file_path', 'tool_input.path'), 'prompt': ('prompt',), 'session_id': ('conversation_id',), 'tool': ('tool_name',), 'tool_use_id': ('tool_use_id',)}, 'hook_entry': {'matcher': False, 'wrapper': 'cursor'}, 'needs_trust': False, 'tools': {}, 'verdicts': {'answer_events': ('beforeMCPExecution', 'beforeReadFile', 'beforeShellExecution', 'beforeSubmitPrompt', 'beforeTabFileRead', 'preToolUse'), 'bare_allow': 'required', 'default_wire_event': 'beforeShellExecution', 'degrade_notes': {'escalate': '%s cannot prompt for confirmation, so this is a block', 'escalate_from_transform': '%s cannot modify a tool call, so this is a block', 'transform': 'input requires modification, which this gate cannot express'}, 'flag_note': 'observed after the fact (%s cannot prevent it): %s', 'flag_note_default': 'policy violation', 'gates': {'beforeMCPExecution': {'grammar': 'G4', 'honours_escalate': True, 'honours_transform': False}, 'beforeReadFile': {'grammar': 'G4', 'honours_escalate': False, 'honours_transform': False}, 'beforeShellExecution': {'grammar': 'G4', 'honours_escalate': True, 'honours_transform': False}, 'beforeSubmitPrompt': {'grammar': 'G4', 'honours_escalate': False, 'honours_transform': False}, 'beforeTabFileRead': {'grammar': 'G4', 'honours_escalate': False, 'honours_transform': False}, 'preToolUse': {'grammar': 'G4', 'honours_escalate': False, 'honours_transform': True}}, 'transform_grammar': 'permission_updated_input', 'vocabulary': ('allow', 'ask', 'deny'), 'vocabulary_basis': 'verified', 'words': {'allow': 'allow', 'block': 'deny', 'escalate': 'ask'}}, 'wire_events': {'file_changed': 'afterFileEdit', 'post_tool': 'postToolUse', 'pre_tool': 'preToolUse'}} +VENDOR = {'agent': 'cursor', 'claims': {'mode': 'shape_inferred'}, 'config_format': 'json', 'config_path': '.cursor/hooks.json', 'display': 'Cursor', 'events': {'afterFileEdit': 'file_changed', 'afterMCPExecution': 'post_tool', 'afterShellExecution': 'post_tool', 'afterTabFileEdit': 'file_changed', 'beforeMCPExecution': 'pre_tool', 'beforeReadFile': 'pre_tool', 'beforeShellExecution': 'pre_tool', 'beforeSubmitPrompt': 'prompt_submit', 'beforeTabFileRead': 'pre_tool', 'postToolUse': 'post_tool', 'postToolUseFailure': 'tool_failure', 'preCompact': 'pre_compact', 'preToolUse': 'pre_tool', 'sessionEnd': 'session_end', 'sessionStart': 'session_start', 'stop': 'stop', 'subagentStart': 'subagent_start', 'subagentStop': 'subagent_stop'}, 'evidence': {'claims': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'cursor', 'fields': {'command': ('command', 'tool_input.command'), 'content': ('edits[].new_string', 'tool_input.content', 'tool_input.new_string', 'content'), 'cwd': ('cwd',), 'output': ('tool_output', 'output', 'result_json'), 'path': ('file_path', 'tool_input.file_path', 'tool_input.path'), 'prompt': ('prompt',), 'session_id': ('conversation_id',), 'tool': ('tool_name',), 'tool_use_id': ('tool_use_id',)}, 'hook_entry': {'matcher': False, 'wrapper': 'cursor'}, 'needs_trust': False, 'tools': {}, 'verdicts': {'answer_events': ('beforeMCPExecution', 'beforeReadFile', 'beforeShellExecution', 'beforeSubmitPrompt', 'beforeTabFileRead', 'preToolUse'), 'bare_allow': 'required', 'default_wire_event': 'beforeShellExecution', 'degrade_notes': {'escalate': '%s cannot prompt for confirmation, so this is a block', 'escalate_from_transform': '%s cannot modify the input, so this is a block', 'transform': 'input requires modification, which this gate cannot express', 'transform_missing_input': 'no replacement input was supplied'}, 'flag_note': 'observed after the fact (%s cannot prevent it): %s', 'flag_note_default': 'policy violation', 'gates': {'beforeMCPExecution': {'grammar': 'G4', 'honours_escalate': True, 'honours_transform': False}, 'beforeReadFile': {'grammar': 'G4', 'honours_escalate': False, 'honours_transform': False}, 'beforeShellExecution': {'grammar': 'G4', 'honours_escalate': True, 'honours_transform': False}, 'beforeSubmitPrompt': {'grammar': 'G4', 'honours_escalate': False, 'honours_transform': False}, 'beforeTabFileRead': {'grammar': 'G4', 'honours_escalate': False, 'honours_transform': False}, 'preToolUse': {'grammar': 'G4', 'honours_escalate': False, 'honours_transform': True}}, 'transform_grammar': 'permission_updated_input', 'vocabulary': ('allow', 'ask', 'deny'), 'vocabulary_basis': 'verified', 'words': {'allow': 'allow', 'block': 'deny', 'escalate': 'ask'}}, 'wire_events': {'file_changed': 'afterFileEdit', 'post_tool': 'postToolUse', 'pre_tool': 'preToolUse'}} def claims(raw): @@ -557,6 +586,10 @@ def degrade(decision, event): # >>> agentseam handler >>> GUARD_VIOLATION = 1 +GUARD_ASK_EXIT = 3 + +PYTHON_SUFFIX = '.py' + _BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') GATE_LOG_ENV = 'CHOCK_GATE_LOG' @@ -569,6 +602,8 @@ def degrade(decision, event): GUARD_CLEAN = 'clean' +GUARD_ASKED = 'asked' + GUARD_UNCHECKED = 'unchecked' GUARD_ERRORED = 'errored' @@ -596,41 +631,56 @@ def find_bash(guard: _chock_Path) -> str | None: return candidate return None +def find_interpreter(guard: _chock_Path) -> str | None: + """The interpreter that can run `guard`: this Python for `.py`, otherwise a usable bash.""" + if guard.suffix == PYTHON_SUFFIX: + return sys.executable or None + return find_bash(guard) + def run_guard(guard: _chock_Path, command: str) -> str: - """`GUARD_BLOCKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + """`GUARD_BLOCKED` / `GUARD_ASKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + return run_guard_detailed(guard, command)[0] + +def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: + """`run_guard`'s verdict plus the guard's own first line, which an ask carries to the user.""" try: args = _chock_shlex.split(command) except ValueError: print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) - return GUARD_UNCHECKED + return (GUARD_UNCHECKED, '') if not args: - return GUARD_UNCHECKED - bash = find_bash(guard) - if bash is None: - print(f'chock: no usable bash found, {guard.name} not checked', file=sys.stderr) - return GUARD_UNCHECKED + return (GUARD_UNCHECKED, '') + interpreter = find_interpreter(guard) + if interpreter is None: + print(f'chock: no usable interpreter found, {guard.name} not checked', file=sys.stderr) + return (GUARD_UNCHECKED, '') try: env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} - proc = _chock_subprocess.run([bash, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([interpreter, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) except _chock_subprocess.TimeoutExpired: print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) - return GUARD_ERRORED + return (GUARD_ERRORED, '') except (OSError, UnicodeError) as exc: print(f'chock: guard could not run, not checked: {exc}', file=sys.stderr) - return GUARD_ERRORED + return (GUARD_ERRORED, '') + output = ((proc.stderr or '') + (proc.stdout or '')).strip() + first_line = output.splitlines()[0].strip() if output else '' if proc.returncode == GUARD_VIOLATION: sys.stderr.write(proc.stdout or '') sys.stderr.write(proc.stderr or '') - if not ((proc.stdout or '') + (proc.stderr or '')).strip(): + if not output: print(f'chock: blocked by {_chock_Path(guard).name} (guard gave no reason)', file=sys.stderr) - return GUARD_BLOCKED + return (GUARD_BLOCKED, first_line) + if proc.returncode == GUARD_ASK_EXIT: + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + return (GUARD_ASKED, first_line) if proc.returncode != 0: - detail = (proc.stderr or proc.stdout or '').strip().splitlines() - print(f'chock: guard exited {proc.returncode}, not checked' + (f': {detail[0][:120]}' if detail else ''), file=sys.stderr) - return GUARD_ERRORED - return GUARD_CLEAN + print(f'chock: guard exited {proc.returncode}, not checked' + (f': {first_line[:120]}' if first_line else ''), file=sys.stderr) + return (GUARD_ERRORED, '') + return (GUARD_CLEAN, '') -def log_outcome(guard: _chock_Path, tool: str, *, blocked: bool) -> None: +def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> None: """Append one outcome record. Best effort: never raises, never changes the verdict.""" try: if _chock_os.environ.get(GATE_LOG_ENV) == '0': @@ -650,7 +700,7 @@ def log_outcome(guard: _chock_Path, tool: str, *, blocked: bool) -> None: log_path = log_dir / 'gate-events.jsonl' if log_path.exists() and log_path.stat().st_size > _LOG_MAX_BYTES: log_path.replace(log_dir / 'gate-events.1.jsonl') - record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': 'block' if blocked else 'allow'} + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': verdict} with log_path.open('a', encoding='utf-8') as fh: fh.write(json.dumps(record, ensure_ascii=False) + '\n') except Exception: @@ -661,23 +711,176 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N guard = guard_path_from_argv(argv) if guard is None or not guard.exists(): return None - verdict = run_guard(guard, command) - if verdict in (GUARD_BLOCKED, GUARD_CLEAN): - log_outcome(guard, tool, blocked=verdict == GUARD_BLOCKED) + verdict, message = run_guard_detailed(guard, command) + logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} + if verdict in logged: + log_outcome(guard, tool, verdict=logged[verdict]) if verdict == GUARD_BLOCKED: return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + if verdict == GUARD_ASKED: + return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') if verdict == GUARD_ERRORED: return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") return None +GATE_FLAG = '--gate' -def handle(event): +_GATE_TIMEOUT_SECONDS = 30 + +_GATE_DEPTH_TO_CHOCK = 3 + +_RUNNER_PARTS = ('bin', 'gate.py') + +_GIT = 'git' + +_DELETED = 'D' + +_RENAMED = 'R' + +GATE_BLOCKED = 'blocked' + +GATE_CLEAN = 'clean' + +GATE_ERRORED = 'errored' + +VERDICT_DENY = 'deny' + +def gate_path_from_argv(argv): + """The `--gate ` argument a vendored runtime was invoked with, or None.""" + if GATE_FLAG in argv: + index = argv.index(GATE_FLAG) + if index + 1 < len(argv): + return _chock_Path(argv[index + 1]) + return None + +def runner_for(gate): + """The vendored gate runner beside this compiled gate, or None when it is not there.""" + parents = gate.resolve().parents + if len(parents) <= _GATE_DEPTH_TO_CHOCK: + return None + runner = parents[_GATE_DEPTH_TO_CHOCK].joinpath(*_RUNNER_PARTS) + return runner if runner.exists() else None + +def writes_from_event(event): + """The one file this tool call would write, or none when it carries no file text.""" + path = getattr(event, 'path', None) + content = getattr(event, 'content', None) + if not path or not isinstance(content, str): + return {} + return {str(path): content} + +def changed_paths(repo_root): + """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" + try: + proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + except (OSError, _chock_subprocess.SubprocessError): + return [] + if proc.returncode != 0: + return [] + fields = [field for field in (proc.stdout or '').split('\x00') if field] + paths = [] + skip_next = False + for field in fields: + if skip_next: + skip_next = False + continue + status, path = (field[:2], field[3:]) + skip_next = status.startswith(_RENAMED) + if _DELETED in status or not path: + continue + paths.append(path) + return paths + +def writes_from_worktree(repo_root): + """What this turn actually left on disk, however it was written. + + The write path sees only writes it recognises; a shell heredoc carries no file argument. + Reading final state is what makes that stop mattering, so this deliberately does not care + which tool produced the bytes. + """ + writes = {} + for path in changed_paths(repo_root): + try: + writes[path] = _chock_Path(repo_root, path).read_text(encoding='utf-8') + except (OSError, UnicodeDecodeError): + continue + return writes + +def run_gate(gate, writes, event): + """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) + except (OSError, _chock_subprocess.SubprocessError) as exc: + return (GATE_ERRORED, str(exc)) + if proc.returncode == 0: + return (GATE_CLEAN, '') + if proc.returncode == 1: + return (GATE_BLOCKED, (proc.stderr or '').strip()) + return (GATE_ERRORED, (proc.stderr or '').strip()) + +_EVENT_ARG = {'pre_tool': 'pre-tool-use', 'stop': 'stop'} + +PRE_TOOL = 'pre_tool' + +def root_for(gate): + """The repository this compiled gate belongs to, or None when the layout is not that.""" + parents = gate.resolve().parents + if len(parents) <= _GATE_DEPTH_TO_CHOCK: + return None + return parents[_GATE_DEPTH_TO_CHOCK].parent + +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 {} + +def evaluate_gate(argv, event): + """The decision this event earns from a compiled gate, or None when it has nothing to say.""" + gate = gate_path_from_argv(argv) + name = _EVENT_ARG.get(getattr(event, 'event', '')) + if gate is None or name is None or (not gate.exists()): + return None + writes = writes_for(event, gate) + if not writes: + return None + outcome, message = run_gate(gate, writes, name) + if outcome == GATE_BLOCKED: + return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') + if outcome == GATE_ERRORED: + return (VERDICT_DENY, f'chock could not check this write: {message}. Refusing rather than reporting an allow it never established.') + return None + + +def _judge(event): if event.event == "pre_tool" and event.command: verdict = evaluate(sys.argv[1:], event.command, event.tool or "") if verdict is not None: outcome, reason = verdict return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + gated = evaluate_gate(sys.argv[1:], event) + if gated is not None: + return Decision.deny(gated[1]) return None + + +def handle(event): + # A door that cannot decide refuses. An exception escaping here would exit the hook + # with a traceback, which every client reads as a non-blocking error: fail-open, with + # the reason on a stderr nobody watches. The refusal carries the reason instead. + try: + return _judge(event) + except Exception as exc: # noqa: BLE001 -- any failure here must refuse, never fall through + return Decision.deny( + "chock could not check this call (%s: %s). Refusing rather than reporting an " + "allow it never established." % (type(exc).__name__, exc) + ) # <<< agentseam handler <<< diff --git a/.chock/bin/devin.py b/.chock/bin/devin.py index c2e7a06..860ee58 100755 --- a/.chock/bin/devin.py +++ b/.chock/bin/devin.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.2.1 -- bundle("devin"). Do not hand-edit, except the +# Generated by agentseam 0.3.0 -- bundle("devin"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -15,14 +15,13 @@ import os as _chock_os import shlex as _chock_shlex -import shutil as _chock_shutil import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.2.1) +# contract (agentseam 0.3.0) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -255,21 +254,26 @@ def _accepted_by_markers(c, raw, name): for event_name, required in c.get("accept_when_all", {}).items() ) +def _disqualified(cfg, c, raw, name): + """Every reason an entry declines a payload once its wire event name is known.""" + if name not in cfg["events"] and not (c.get("accept_any_name") and isinstance(name, str)): + return True + if "client_types" in c and raw.get("client_type") not in c["client_types"]: + return True + return _rejected_by_markers(c, raw) + def hj_claims(cfg, raw): """True when this payload matches the entry's marker discipline.""" if not isinstance(raw, dict): return False c = cfg["claims"] name = _wire_name(cfg, raw) + # A positive self-identification beats a shared event name, accept_names included. + if raw.get("client_type") in c.get("reject_client_types", ()): + return False if name in c.get("accept_names", ()): return True - if name not in cfg["events"]: - return False - if "client_types" in c and raw.get("client_type") not in c["client_types"]: - return False - if _rejected_by_markers(c, raw): - return False - return _accepted_by_markers(c, raw, name) + return not _disqualified(cfg, c, raw, name) and _accepted_by_markers(c, raw, name) def _segment(node, part): """One path segment: a dict key, or `key[N]` indexing the list under it.""" @@ -361,7 +365,19 @@ def hj_parse(cfg, raw, wire=None): _TRANSFORM_MISSING_INPUT = "transform_missing_input" def hj_reverse(cfg): - """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" + """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides. + + One wire name per canonical event, which is a real limit and not an oversight (R3, gap 4). + Cursor is where it bites: `pre_tool` pins to `preToolUse`, and Cursor honours `ask` only at + `beforeShellExecution` / `beforeMCPExecution`. So installing at `pre_tool` forecloses `ask` + before dispatch is ever reached -- the runtime degrade to `deny` is honest about it, but the + install already chose. Deny-style policies, which is all that ships today, are unaffected. + + The day an ask-style Cursor policy exists, this map has to select by decision dialect + (deny -> `preToolUse`, ask -> `beforeShellExecution`) and become one-to-many. Recorded here + rather than built, so the resolution is not re-litigated from scratch; the three facts it + rests on are pinned in tests/test_cursor_ask_dialect.py. + """ reverse = {} for name, canonical in cfg["events"].items(): if canonical != UNKNOWN: @@ -580,7 +596,7 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos AGENT = "devin" -VENDOR = {'agent': 'devin', 'claims': {'accept_markers': ('prompt_id',), 'accept_names': ('PermissionRequest', 'PostCompaction'), 'event_key': ('hook_event_name',), 'mode': 'marker', 'notes': 'accept_names are names Claude Code never sends, claimed before any marker check; prompt_id is required alongside looks_like_claude_code(raw) being false.', 'reject_probes': ('looks_like_claude_code',)}, 'config_format': 'json', 'config_path': '.devin/hooks.v1.json', 'display': 'Devin', 'events': {'PermissionRequest': 'pre_tool', 'PostToolUse': 'post_tool', 'PreToolUse': 'pre_tool', 'SessionEnd': 'session_end', 'SessionStart': 'session_start', 'Stop': 'stop', 'UserPromptSubmit': 'prompt_submit'}, 'evidence': {'claims': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'hook_json', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content', 'tool_input.new_string'), 'cwd': ('cwd',), 'output': ('tool_output',), 'path': ('tool_input.file_path', 'tool_input.path'), 'prompt': ('prompt',), 'session_id': ('session_id',), 'tool': ('tool_name',)}, 'hook_entry': {'bare': True, 'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'tools': {}, 'verdicts': {'answer_events': ('PermissionRequest', 'PreToolUse', 'Stop', 'UserPromptSubmit'), 'bare_allow': 'unverified', 'context_events': ('PostToolUse', 'SessionStart', 'UserPromptSubmit'), 'context_source': 'reason', 'default_wire_event': 'PreToolUse', 'degrade_notes': {'escalate': 'Devin cannot prompt for confirmation, so this is a block', 'escalate_from_transform': 'Devin cannot modify a tool call, so this is a block'}, 'echo': 'payload', 'gates': {'PermissionRequest': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'PreToolUse': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': True}, 'Stop': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'UserPromptSubmit': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'suffix', 'reason_defaults': {'transform': 'input requires modification before it can run'}, 'transform_grammar': 'hook_specific_updated_input', 'vocabulary': ('approve', 'block'), 'vocabulary_basis': 'verified', 'words': {'allow': 'approve', 'block': 'block'}}, 'wire_events': {'pre_tool': 'PreToolUse'}} +VENDOR = {'agent': 'devin', 'claims': {'accept_markers': ('prompt_id',), 'accept_names': ('PermissionRequest', 'PostCompaction'), 'event_key': ('hook_event_name',), 'mode': 'marker', 'notes': 'accept_names are names Claude Code never sends, claimed before any marker check -- except against a client_type that names another vendor, since Kimi Code sends PermissionRequest too; prompt_id is required alongside looks_like_claude_code(raw) being false.', 'reject_client_types': ('kimi_code_cli',), 'reject_probes': ('looks_like_claude_code',)}, 'config_format': 'json', 'config_path': '.devin/hooks.v1.json', 'display': 'Devin', 'events': {'PermissionRequest': 'pre_tool', 'PostToolUse': 'post_tool', 'PreToolUse': 'pre_tool', 'SessionEnd': 'session_end', 'SessionStart': 'session_start', 'Stop': 'stop', 'UserPromptSubmit': 'prompt_submit'}, 'evidence': {'claims': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'hook_json', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content', 'tool_input.new_string'), 'cwd': ('cwd',), 'output': ('tool_output',), 'path': ('tool_input.file_path', 'tool_input.path'), 'prompt': ('prompt',), 'session_id': ('session_id',), 'tool': ('tool_name',)}, 'hook_entry': {'bare': True, 'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'tools': {}, 'verdicts': {'answer_events': ('PermissionRequest', 'PreToolUse', 'Stop', 'UserPromptSubmit'), 'bare_allow': 'unverified', 'context_events': ('PostToolUse', 'SessionStart', 'UserPromptSubmit'), 'context_source': 'reason', 'default_wire_event': 'PreToolUse', 'degrade_notes': {'escalate': 'Devin cannot prompt for confirmation, so this is a block', 'escalate_from_transform': '%s (Devin cannot modify the input at %s, so this is a block)'}, 'echo': 'payload', 'gates': {'PermissionRequest': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'PreToolUse': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': True}, 'Stop': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'UserPromptSubmit': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'suffix', 'reason_defaults': {'transform': 'input requires modification'}, 'transform_grammar': 'hook_specific_updated_input', 'vocabulary': ('approve', 'block'), 'vocabulary_basis': 'verified', 'words': {'allow': 'approve', 'block': 'block'}}, 'wire_events': {'pre_tool': 'PreToolUse'}} def claims(raw): @@ -633,6 +649,10 @@ def degrade(decision, event): # >>> agentseam handler >>> GUARD_VIOLATION = 1 +GUARD_ASK_EXIT = 3 + +PYTHON_SUFFIX = '.py' + _BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') GATE_LOG_ENV = 'CHOCK_GATE_LOG' @@ -645,6 +665,8 @@ def degrade(decision, event): GUARD_CLEAN = 'clean' +GUARD_ASKED = 'asked' + GUARD_UNCHECKED = 'unchecked' GUARD_ERRORED = 'errored' @@ -672,41 +694,56 @@ def find_bash(guard: _chock_Path) -> str | None: return candidate return None +def find_interpreter(guard: _chock_Path) -> str | None: + """The interpreter that can run `guard`: this Python for `.py`, otherwise a usable bash.""" + if guard.suffix == PYTHON_SUFFIX: + return sys.executable or None + return find_bash(guard) + def run_guard(guard: _chock_Path, command: str) -> str: - """`GUARD_BLOCKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + """`GUARD_BLOCKED` / `GUARD_ASKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + return run_guard_detailed(guard, command)[0] + +def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: + """`run_guard`'s verdict plus the guard's own first line, which an ask carries to the user.""" try: args = _chock_shlex.split(command) except ValueError: print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) - return GUARD_UNCHECKED + return (GUARD_UNCHECKED, '') if not args: - return GUARD_UNCHECKED - bash = find_bash(guard) - if bash is None: - print(f'chock: no usable bash found, {guard.name} not checked', file=sys.stderr) - return GUARD_UNCHECKED + return (GUARD_UNCHECKED, '') + interpreter = find_interpreter(guard) + if interpreter is None: + print(f'chock: no usable interpreter found, {guard.name} not checked', file=sys.stderr) + return (GUARD_UNCHECKED, '') try: env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} - proc = _chock_subprocess.run([bash, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([interpreter, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) except _chock_subprocess.TimeoutExpired: print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) - return GUARD_ERRORED + return (GUARD_ERRORED, '') except (OSError, UnicodeError) as exc: print(f'chock: guard could not run, not checked: {exc}', file=sys.stderr) - return GUARD_ERRORED + return (GUARD_ERRORED, '') + output = ((proc.stderr or '') + (proc.stdout or '')).strip() + first_line = output.splitlines()[0].strip() if output else '' if proc.returncode == GUARD_VIOLATION: sys.stderr.write(proc.stdout or '') sys.stderr.write(proc.stderr or '') - if not ((proc.stdout or '') + (proc.stderr or '')).strip(): + if not output: print(f'chock: blocked by {_chock_Path(guard).name} (guard gave no reason)', file=sys.stderr) - return GUARD_BLOCKED + return (GUARD_BLOCKED, first_line) + if proc.returncode == GUARD_ASK_EXIT: + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + return (GUARD_ASKED, first_line) if proc.returncode != 0: - detail = (proc.stderr or proc.stdout or '').strip().splitlines() - print(f'chock: guard exited {proc.returncode}, not checked' + (f': {detail[0][:120]}' if detail else ''), file=sys.stderr) - return GUARD_ERRORED - return GUARD_CLEAN + print(f'chock: guard exited {proc.returncode}, not checked' + (f': {first_line[:120]}' if first_line else ''), file=sys.stderr) + return (GUARD_ERRORED, '') + return (GUARD_CLEAN, '') -def log_outcome(guard: _chock_Path, tool: str, *, blocked: bool) -> None: +def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> None: """Append one outcome record. Best effort: never raises, never changes the verdict.""" try: if _chock_os.environ.get(GATE_LOG_ENV) == '0': @@ -726,7 +763,7 @@ def log_outcome(guard: _chock_Path, tool: str, *, blocked: bool) -> None: log_path = log_dir / 'gate-events.jsonl' if log_path.exists() and log_path.stat().st_size > _LOG_MAX_BYTES: log_path.replace(log_dir / 'gate-events.1.jsonl') - record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': 'block' if blocked else 'allow'} + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': verdict} with log_path.open('a', encoding='utf-8') as fh: fh.write(json.dumps(record, ensure_ascii=False) + '\n') except Exception: @@ -737,23 +774,176 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N guard = guard_path_from_argv(argv) if guard is None or not guard.exists(): return None - verdict = run_guard(guard, command) - if verdict in (GUARD_BLOCKED, GUARD_CLEAN): - log_outcome(guard, tool, blocked=verdict == GUARD_BLOCKED) + verdict, message = run_guard_detailed(guard, command) + logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} + if verdict in logged: + log_outcome(guard, tool, verdict=logged[verdict]) if verdict == GUARD_BLOCKED: return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + if verdict == GUARD_ASKED: + return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') if verdict == GUARD_ERRORED: return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") return None +GATE_FLAG = '--gate' -def handle(event): +_GATE_TIMEOUT_SECONDS = 30 + +_GATE_DEPTH_TO_CHOCK = 3 + +_RUNNER_PARTS = ('bin', 'gate.py') + +_GIT = 'git' + +_DELETED = 'D' + +_RENAMED = 'R' + +GATE_BLOCKED = 'blocked' + +GATE_CLEAN = 'clean' + +GATE_ERRORED = 'errored' + +VERDICT_DENY = 'deny' + +def gate_path_from_argv(argv): + """The `--gate ` argument a vendored runtime was invoked with, or None.""" + if GATE_FLAG in argv: + index = argv.index(GATE_FLAG) + if index + 1 < len(argv): + return _chock_Path(argv[index + 1]) + return None + +def runner_for(gate): + """The vendored gate runner beside this compiled gate, or None when it is not there.""" + parents = gate.resolve().parents + if len(parents) <= _GATE_DEPTH_TO_CHOCK: + return None + runner = parents[_GATE_DEPTH_TO_CHOCK].joinpath(*_RUNNER_PARTS) + return runner if runner.exists() else None + +def writes_from_event(event): + """The one file this tool call would write, or none when it carries no file text.""" + path = getattr(event, 'path', None) + content = getattr(event, 'content', None) + if not path or not isinstance(content, str): + return {} + return {str(path): content} + +def changed_paths(repo_root): + """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" + try: + proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + except (OSError, _chock_subprocess.SubprocessError): + return [] + if proc.returncode != 0: + return [] + fields = [field for field in (proc.stdout or '').split('\x00') if field] + paths = [] + skip_next = False + for field in fields: + if skip_next: + skip_next = False + continue + status, path = (field[:2], field[3:]) + skip_next = status.startswith(_RENAMED) + if _DELETED in status or not path: + continue + paths.append(path) + return paths + +def writes_from_worktree(repo_root): + """What this turn actually left on disk, however it was written. + + The write path sees only writes it recognises; a shell heredoc carries no file argument. + Reading final state is what makes that stop mattering, so this deliberately does not care + which tool produced the bytes. + """ + writes = {} + for path in changed_paths(repo_root): + try: + writes[path] = _chock_Path(repo_root, path).read_text(encoding='utf-8') + except (OSError, UnicodeDecodeError): + continue + return writes + +def run_gate(gate, writes, event): + """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) + except (OSError, _chock_subprocess.SubprocessError) as exc: + return (GATE_ERRORED, str(exc)) + if proc.returncode == 0: + return (GATE_CLEAN, '') + if proc.returncode == 1: + return (GATE_BLOCKED, (proc.stderr or '').strip()) + return (GATE_ERRORED, (proc.stderr or '').strip()) + +_EVENT_ARG = {'pre_tool': 'pre-tool-use', 'stop': 'stop'} + +PRE_TOOL = 'pre_tool' + +def root_for(gate): + """The repository this compiled gate belongs to, or None when the layout is not that.""" + parents = gate.resolve().parents + if len(parents) <= _GATE_DEPTH_TO_CHOCK: + return None + return parents[_GATE_DEPTH_TO_CHOCK].parent + +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 {} + +def evaluate_gate(argv, event): + """The decision this event earns from a compiled gate, or None when it has nothing to say.""" + gate = gate_path_from_argv(argv) + name = _EVENT_ARG.get(getattr(event, 'event', '')) + if gate is None or name is None or (not gate.exists()): + return None + writes = writes_for(event, gate) + if not writes: + return None + outcome, message = run_gate(gate, writes, name) + if outcome == GATE_BLOCKED: + return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') + if outcome == GATE_ERRORED: + return (VERDICT_DENY, f'chock could not check this write: {message}. Refusing rather than reporting an allow it never established.') + return None + + +def _judge(event): if event.event == "pre_tool" and event.command: verdict = evaluate(sys.argv[1:], event.command, event.tool or "") if verdict is not None: outcome, reason = verdict return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + gated = evaluate_gate(sys.argv[1:], event) + if gated is not None: + return Decision.deny(gated[1]) return None + + +def handle(event): + # A door that cannot decide refuses. An exception escaping here would exit the hook + # with a traceback, which every client reads as a non-blocking error: fail-open, with + # the reason on a stderr nobody watches. The refusal carries the reason instead. + try: + return _judge(event) + except Exception as exc: # noqa: BLE001 -- any failure here must refuse, never fall through + return Decision.deny( + "chock could not check this call (%s: %s). Refusing rather than reporting an " + "allow it never established." % (type(exc).__name__, exc) + ) # <<< agentseam handler <<< diff --git a/.chock/bin/gate.py b/.chock/bin/gate.py index 95b7ecd..055c4c3 100755 --- a/.chock/bin/gate.py +++ b/.chock/bin/gate.py @@ -12,6 +12,7 @@ import subprocess import sys import tomllib +from collections.abc import Mapping, Sequence from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path @@ -35,11 +36,23 @@ def __init__( push_stdin: str | None = None, base: str | None = None, head_ref: str | None = None, + scope: Sequence[str] | None = None, ) -> None: self.repo_root = Path(repo_root) self._push_stdin = push_stdin or "" self.base = base self.head_ref = head_ref + #: The policy's applies_to.paths. Empty means every changed file is in scope. + self.scope = tuple(scope or ()) + + def in_scope(self, path: str) -> bool: + """Whether this policy may judge this file at all. + + fnmatch semantics, so `*` crosses `/` and `.github/workflows/*` covers nested files. + A gate with no scope sees every changed file, which is what every gate did before + applies_to.paths was read. + """ + return not self.scope or any(fnmatch.fnmatchcase(path, g) for g in self.scope) def _range(self) -> list[str]: """The git-diff scope: a commit range in CI, the staged index otherwise.""" @@ -67,7 +80,8 @@ def rev_exists(self, ref: str) -> bool: def staged_paths(self, diff_filter: str = "ACMRT") -> list[str]: out = self._git("diff", *self._range(), "--name-only", f"--diff-filter={diff_filter}") - return [line.strip() for line in out.splitlines() if line.strip()] + paths = (line.strip() for line in out.splitlines() if line.strip()) + return [path for path in paths if self.in_scope(path)] def added_lines(self, path: str) -> list[str]: out = self._git("diff", *self._range(), "-U0", "--", path) @@ -77,6 +91,11 @@ def added_lines(self, path: str) -> list[str]: lines.append(line[1:]) return lines + def removed_lines(self, path: str) -> list[str]: + """The deleted side of the diff -- what a test-weakening change takes away.""" + out = self._git("diff", *self._range(), "-U0", "--", path) + return [line[1:] for line in out.splitlines() if line.startswith("-") and not line.startswith("---")] + def staged_blob(self, path: str) -> str: """The proposed content: staged in index mode, committed at HEAD in range mode.""" return self._git("show", f"HEAD:{path}" if self.base else f":{path}") @@ -100,11 +119,63 @@ def push_refs(self) -> list[str]: return refs -def _kind_content_regex(ctx: GateContext, params: dict, _event: str) -> GateResult: +class WriteContext(GateContext): + """Files an agent is about to write, or has just written, shaped like a staged diff. + + The gate kinds are untouched and cannot tell the difference: only the material changes. + A whole-file write is entirely added lines, and an edit carries exactly the text being + introduced, so "added_lines" means at this surface what it has always meant. + + It still subclasses GateContext so repo_root and the git-backed accessors a kind may + reach for keep working -- an allowlist file still lives in the repository even when the + content under judgement does not. + """ + + def __init__(self, repo_root: Path, writes: Mapping[str, str], scope: Sequence[str] | None = None) -> None: + super().__init__(repo_root=repo_root, scope=scope) + self._writes = dict(writes) + + def staged_paths(self, diff_filter: str = "ACMRT") -> list[str]: # noqa: ARG002 -- no diff to filter + return [path for path in self._writes if self.in_scope(path)] + + def staged_blob(self, path: str) -> str: + return self._writes.get(path, "") + + def added_lines(self, path: str) -> list[str]: + return self._writes.get(path, "").splitlines() + + def removed_lines(self, path: str) -> list[str]: # noqa: ARG002 -- a write removes nothing yet + """Nothing is removed by a write that has not landed, so a kind reading this sees none.""" + return [] + + def head_blob(self, path: str) -> str: + """What is on disk now, which is what this write would replace.""" + target = self.repo_root / path + try: + return target.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return "" + + +#: Kinds whose question a write can answer. A branch name is not in a tool call, so +#: forbidden_ref has nothing to read here; saying so beats passing it empty and calling +#: that an allow. +WRITE_PATH_KINDS = frozenset({"content_regex"}) + + +#: Events at which a line-level waiver is honoured: the ones where a human staged the text. At +#: tool use the scanned text is a live tool argument, and at the turn's end it is a file the +#: same agent just wrote, so a pragma there is the refused party waiving itself -- the gateway +#: evaluator never read it for that reason, and the published policy message says so. +WAIVABLE_EVENTS = frozenset({"commit", "push", "ci"}) + + +def _kind_content_regex(ctx: GateContext, params: dict, event: str) -> GateResult: content_re = re.compile(params["content_pattern"]) forbidden_path_regex = params.get("forbidden_path_regex") path_re = re.compile(forbidden_path_regex) if forbidden_path_regex else None - pragma_re = re.compile(params["allowlist_pragma"]) if params.get("allowlist_pragma") else None + pragma = params.get("allowlist_pragma") if event in WAIVABLE_EVENTS else None + pragma_re = re.compile(pragma) if pragma else None scan = params.get("scan", "added_lines") diff_filter = params.get("diff_filter", "ACMRT") @@ -235,10 +306,44 @@ def _kind_dependency_allowlist(ctx: GateContext, params: dict, _event: str) -> G return GateResult(allowed=not matches, matches=matches) +def _count(pattern: "re.Pattern[str]", lines: list[str], pragma: "re.Pattern[str] | None") -> int: + return sum(1 for line in lines if pattern.search(line) and not (pragma and pragma.search(line))) + + +def _kind_test_integrity(ctx: GateContext, params: dict, _event: str) -> GateResult: + """Block a change that wins green CI by weakening the tests rather than fixing the code.""" + path_re = re.compile(params["test_path_regex"]) + assertion_re = re.compile(params["assertion_pattern"]) + dummy_pattern = params.get("dummy_assertion_pattern") + dummy_re = re.compile(dummy_pattern) if dummy_pattern else None + pragma = params.get("allowlist_pragma") + pragma_re = re.compile(pragma) if pragma else None + + matches: list[str] = [] + added = removed = 0 + for path in ctx.staged_paths("D"): + if path_re.search(path): + matches.append(f"{path}: test file deleted") + for path in ctx.staged_paths("ACMRT"): + if not path_re.search(path): + continue + added_lines = ctx.added_lines(path) + if pragma_re and any(pragma_re.search(line) for line in added_lines): + continue + added += _count(assertion_re, added_lines, pragma_re) + removed += _count(assertion_re, ctx.removed_lines(path), pragma_re) + if dummy_re and any(dummy_re.search(line) for line in added_lines): + matches.append(f"{path}: vacuous assertion added") + if removed > added: + matches.append(f"assertions removed across tests: {removed} removed, {added} added") + return GateResult(allowed=not matches, matches=matches) + + KINDS = { "content_regex": _kind_content_regex, "forbidden_ref": _kind_forbidden_ref, "dependency_allowlist": _kind_dependency_allowlist, + "test_integrity": _kind_test_integrity, } @@ -285,7 +390,37 @@ def _log_outcome(gate_path: Path, event: str, spec: dict, result: GateResult) -> return -_EVENT_NAME = {"pre-commit": "commit", "pre-push": "push"} +#: Both agent surfaces answer to the vocabulary policies already declare. A policy saying +#: `on: [commit, tool_use]` has been asking for both of these all along; nothing in a manifest +#: has to change for it to get them. +_EVENT_NAME = {"pre-commit": "commit", "pre-push": "push", "pre-tool-use": "tool_use", "stop": "tool_use"} + +AGENT_EVENTS = ("pre-tool-use", "stop") + + +def _context( + event: str, + spec: dict, + repo_root: Path, + push_stdin: str | None, + base: str | None, + head_ref: str | None, + writes: Mapping[str, str] | None, +) -> GateContext | None: + """The material this event puts under judgement, or None when the kind cannot read it.""" + if event not in AGENT_EVENTS: + return GateContext( + repo_root=repo_root, push_stdin=push_stdin, base=base, head_ref=head_ref, scope=spec.get("paths") + ) + if spec.get("kind") not in WRITE_PATH_KINDS: + print( + f"gate: kind {spec.get('kind')!r} has nothing to read at {event} -- it asks about the " + "repository, not about a file being written. Refusing rather than reporting an allow " + "it never established.", + file=sys.stderr, + ) + return None + return WriteContext(repo_root=repo_root, writes=writes or {}, scope=spec.get("paths")) def run( @@ -295,6 +430,7 @@ def run( repo_root: Path, base: str | None = None, head_ref: str | None = None, + writes: Mapping[str, str] | None = None, ) -> int: gate_path = Path(gate_path) if not gate_path.exists(): @@ -315,7 +451,9 @@ def run( if kind is None: print(f"gate: unknown kind {spec.get('kind')!r}", file=sys.stderr) return 2 - ctx = GateContext(repo_root=repo_root, push_stdin=push_stdin, base=base, head_ref=head_ref) + ctx = _context(event, spec, repo_root, push_stdin, base, head_ref, writes) + if ctx is None: + return 2 if event == "ci" and base and not ctx.rev_exists(base): print( f"gate: base ref {base!r} does not resolve -- refusing to scan an empty range. " @@ -343,12 +481,24 @@ def _repo_root() -> Path: return Path.cwd() +def _writes(raw: str) -> dict[str, str]: + """The files this event puts under judgement. Unreadable input yields none, never a guess.""" + try: + payload = json.loads(raw or "{}") + except json.JSONDecodeError: + return {} + writes = payload.get("writes") + if not isinstance(writes, dict): + return {} + return {str(path): str(text) for path, text in writes.items() if isinstance(text, str)} + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(prog="gate.py") sub = parser.add_subparsers(dest="command", required=True) run_p = sub.add_parser("run", help="Run a compiled gate") run_p.add_argument("--gate", required=True, help="Path to compiled gate.json") - run_p.add_argument("--event", required=True, choices=["pre-commit", "pre-push", "ci"]) + run_p.add_argument("--event", required=True, choices=["pre-commit", "pre-push", "ci", *AGENT_EVENTS]) run_p.add_argument("--base", help="Base ref to diff HEAD against (required for --event ci)") run_p.add_argument("--head-ref", help="Branch under test, e.g. $GITHUB_HEAD_REF (used by forbidden_ref)") args = parser.parse_args(argv) @@ -356,6 +506,11 @@ def main(argv: list[str] | None = None) -> int: if args.event == "ci" and not args.base: parser.error("--event ci requires --base") + if args.event in AGENT_EVENTS: + # The files are on stdin because a tool call's content is not in the repository yet and + # cannot be read back from it. {"writes": {"": ""}}. + return run(Path(args.gate), args.event, None, _repo_root(), writes=_writes(sys.stdin.read())) + push_stdin = sys.stdin.read() if args.event == "pre-push" and not sys.stdin.isatty() else None return run(Path(args.gate), args.event, push_stdin, _repo_root(), base=args.base, head_ref=args.head_ref) diff --git a/.chock/bin/gemini_cli.py b/.chock/bin/gemini_cli.py index c66a9e1..5cdf2cc 100755 --- a/.chock/bin/gemini_cli.py +++ b/.chock/bin/gemini_cli.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.2.1 -- bundle("gemini_cli"). Do not hand-edit, except the +# Generated by agentseam 0.3.0 -- bundle("gemini_cli"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -15,14 +15,13 @@ import os as _chock_os import shlex as _chock_shlex -import shutil as _chock_shutil import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.2.1) +# contract (agentseam 0.3.0) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -255,21 +254,26 @@ def _accepted_by_markers(c, raw, name): for event_name, required in c.get("accept_when_all", {}).items() ) +def _disqualified(cfg, c, raw, name): + """Every reason an entry declines a payload once its wire event name is known.""" + if name not in cfg["events"] and not (c.get("accept_any_name") and isinstance(name, str)): + return True + if "client_types" in c and raw.get("client_type") not in c["client_types"]: + return True + return _rejected_by_markers(c, raw) + def hj_claims(cfg, raw): """True when this payload matches the entry's marker discipline.""" if not isinstance(raw, dict): return False c = cfg["claims"] name = _wire_name(cfg, raw) + # A positive self-identification beats a shared event name, accept_names included. + if raw.get("client_type") in c.get("reject_client_types", ()): + return False if name in c.get("accept_names", ()): return True - if name not in cfg["events"]: - return False - if "client_types" in c and raw.get("client_type") not in c["client_types"]: - return False - if _rejected_by_markers(c, raw): - return False - return _accepted_by_markers(c, raw, name) + return not _disqualified(cfg, c, raw, name) and _accepted_by_markers(c, raw, name) def _segment(node, part): """One path segment: a dict key, or `key[N]` indexing the list under it.""" @@ -361,7 +365,19 @@ def hj_parse(cfg, raw, wire=None): _TRANSFORM_MISSING_INPUT = "transform_missing_input" def hj_reverse(cfg): - """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" + """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides. + + One wire name per canonical event, which is a real limit and not an oversight (R3, gap 4). + Cursor is where it bites: `pre_tool` pins to `preToolUse`, and Cursor honours `ask` only at + `beforeShellExecution` / `beforeMCPExecution`. So installing at `pre_tool` forecloses `ask` + before dispatch is ever reached -- the runtime degrade to `deny` is honest about it, but the + install already chose. Deny-style policies, which is all that ships today, are unaffected. + + The day an ask-style Cursor policy exists, this map has to select by decision dialect + (deny -> `preToolUse`, ask -> `beforeShellExecution`) and become one-to-many. Recorded here + rather than built, so the resolution is not re-litigated from scratch; the three facts it + rests on are pinned in tests/test_cursor_ask_dialect.py. + """ reverse = {} for name, canonical in cfg["events"].items(): if canonical != UNKNOWN: @@ -633,6 +649,10 @@ def degrade(decision, event): # >>> agentseam handler >>> GUARD_VIOLATION = 1 +GUARD_ASK_EXIT = 3 + +PYTHON_SUFFIX = '.py' + _BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') GATE_LOG_ENV = 'CHOCK_GATE_LOG' @@ -645,6 +665,8 @@ def degrade(decision, event): GUARD_CLEAN = 'clean' +GUARD_ASKED = 'asked' + GUARD_UNCHECKED = 'unchecked' GUARD_ERRORED = 'errored' @@ -672,41 +694,56 @@ def find_bash(guard: _chock_Path) -> str | None: return candidate return None +def find_interpreter(guard: _chock_Path) -> str | None: + """The interpreter that can run `guard`: this Python for `.py`, otherwise a usable bash.""" + if guard.suffix == PYTHON_SUFFIX: + return sys.executable or None + return find_bash(guard) + def run_guard(guard: _chock_Path, command: str) -> str: - """`GUARD_BLOCKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + """`GUARD_BLOCKED` / `GUARD_ASKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + return run_guard_detailed(guard, command)[0] + +def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: + """`run_guard`'s verdict plus the guard's own first line, which an ask carries to the user.""" try: args = _chock_shlex.split(command) except ValueError: print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) - return GUARD_UNCHECKED + return (GUARD_UNCHECKED, '') if not args: - return GUARD_UNCHECKED - bash = find_bash(guard) - if bash is None: - print(f'chock: no usable bash found, {guard.name} not checked', file=sys.stderr) - return GUARD_UNCHECKED + return (GUARD_UNCHECKED, '') + interpreter = find_interpreter(guard) + if interpreter is None: + print(f'chock: no usable interpreter found, {guard.name} not checked', file=sys.stderr) + return (GUARD_UNCHECKED, '') try: env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} - proc = _chock_subprocess.run([bash, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([interpreter, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) except _chock_subprocess.TimeoutExpired: print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) - return GUARD_ERRORED + return (GUARD_ERRORED, '') except (OSError, UnicodeError) as exc: print(f'chock: guard could not run, not checked: {exc}', file=sys.stderr) - return GUARD_ERRORED + return (GUARD_ERRORED, '') + output = ((proc.stderr or '') + (proc.stdout or '')).strip() + first_line = output.splitlines()[0].strip() if output else '' if proc.returncode == GUARD_VIOLATION: sys.stderr.write(proc.stdout or '') sys.stderr.write(proc.stderr or '') - if not ((proc.stdout or '') + (proc.stderr or '')).strip(): + if not output: print(f'chock: blocked by {_chock_Path(guard).name} (guard gave no reason)', file=sys.stderr) - return GUARD_BLOCKED + return (GUARD_BLOCKED, first_line) + if proc.returncode == GUARD_ASK_EXIT: + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + return (GUARD_ASKED, first_line) if proc.returncode != 0: - detail = (proc.stderr or proc.stdout or '').strip().splitlines() - print(f'chock: guard exited {proc.returncode}, not checked' + (f': {detail[0][:120]}' if detail else ''), file=sys.stderr) - return GUARD_ERRORED - return GUARD_CLEAN + print(f'chock: guard exited {proc.returncode}, not checked' + (f': {first_line[:120]}' if first_line else ''), file=sys.stderr) + return (GUARD_ERRORED, '') + return (GUARD_CLEAN, '') -def log_outcome(guard: _chock_Path, tool: str, *, blocked: bool) -> None: +def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> None: """Append one outcome record. Best effort: never raises, never changes the verdict.""" try: if _chock_os.environ.get(GATE_LOG_ENV) == '0': @@ -726,7 +763,7 @@ def log_outcome(guard: _chock_Path, tool: str, *, blocked: bool) -> None: log_path = log_dir / 'gate-events.jsonl' if log_path.exists() and log_path.stat().st_size > _LOG_MAX_BYTES: log_path.replace(log_dir / 'gate-events.1.jsonl') - record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': 'block' if blocked else 'allow'} + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': verdict} with log_path.open('a', encoding='utf-8') as fh: fh.write(json.dumps(record, ensure_ascii=False) + '\n') except Exception: @@ -737,23 +774,176 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N guard = guard_path_from_argv(argv) if guard is None or not guard.exists(): return None - verdict = run_guard(guard, command) - if verdict in (GUARD_BLOCKED, GUARD_CLEAN): - log_outcome(guard, tool, blocked=verdict == GUARD_BLOCKED) + verdict, message = run_guard_detailed(guard, command) + logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} + if verdict in logged: + log_outcome(guard, tool, verdict=logged[verdict]) if verdict == GUARD_BLOCKED: return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + if verdict == GUARD_ASKED: + return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') if verdict == GUARD_ERRORED: return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") return None +GATE_FLAG = '--gate' -def handle(event): +_GATE_TIMEOUT_SECONDS = 30 + +_GATE_DEPTH_TO_CHOCK = 3 + +_RUNNER_PARTS = ('bin', 'gate.py') + +_GIT = 'git' + +_DELETED = 'D' + +_RENAMED = 'R' + +GATE_BLOCKED = 'blocked' + +GATE_CLEAN = 'clean' + +GATE_ERRORED = 'errored' + +VERDICT_DENY = 'deny' + +def gate_path_from_argv(argv): + """The `--gate ` argument a vendored runtime was invoked with, or None.""" + if GATE_FLAG in argv: + index = argv.index(GATE_FLAG) + if index + 1 < len(argv): + return _chock_Path(argv[index + 1]) + return None + +def runner_for(gate): + """The vendored gate runner beside this compiled gate, or None when it is not there.""" + parents = gate.resolve().parents + if len(parents) <= _GATE_DEPTH_TO_CHOCK: + return None + runner = parents[_GATE_DEPTH_TO_CHOCK].joinpath(*_RUNNER_PARTS) + return runner if runner.exists() else None + +def writes_from_event(event): + """The one file this tool call would write, or none when it carries no file text.""" + path = getattr(event, 'path', None) + content = getattr(event, 'content', None) + if not path or not isinstance(content, str): + return {} + return {str(path): content} + +def changed_paths(repo_root): + """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" + try: + proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + except (OSError, _chock_subprocess.SubprocessError): + return [] + if proc.returncode != 0: + return [] + fields = [field for field in (proc.stdout or '').split('\x00') if field] + paths = [] + skip_next = False + for field in fields: + if skip_next: + skip_next = False + continue + status, path = (field[:2], field[3:]) + skip_next = status.startswith(_RENAMED) + if _DELETED in status or not path: + continue + paths.append(path) + return paths + +def writes_from_worktree(repo_root): + """What this turn actually left on disk, however it was written. + + The write path sees only writes it recognises; a shell heredoc carries no file argument. + Reading final state is what makes that stop mattering, so this deliberately does not care + which tool produced the bytes. + """ + writes = {} + for path in changed_paths(repo_root): + try: + writes[path] = _chock_Path(repo_root, path).read_text(encoding='utf-8') + except (OSError, UnicodeDecodeError): + continue + return writes + +def run_gate(gate, writes, event): + """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) + except (OSError, _chock_subprocess.SubprocessError) as exc: + return (GATE_ERRORED, str(exc)) + if proc.returncode == 0: + return (GATE_CLEAN, '') + if proc.returncode == 1: + return (GATE_BLOCKED, (proc.stderr or '').strip()) + return (GATE_ERRORED, (proc.stderr or '').strip()) + +_EVENT_ARG = {'pre_tool': 'pre-tool-use', 'stop': 'stop'} + +PRE_TOOL = 'pre_tool' + +def root_for(gate): + """The repository this compiled gate belongs to, or None when the layout is not that.""" + parents = gate.resolve().parents + if len(parents) <= _GATE_DEPTH_TO_CHOCK: + return None + return parents[_GATE_DEPTH_TO_CHOCK].parent + +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 {} + +def evaluate_gate(argv, event): + """The decision this event earns from a compiled gate, or None when it has nothing to say.""" + gate = gate_path_from_argv(argv) + name = _EVENT_ARG.get(getattr(event, 'event', '')) + if gate is None or name is None or (not gate.exists()): + return None + writes = writes_for(event, gate) + if not writes: + return None + outcome, message = run_gate(gate, writes, name) + if outcome == GATE_BLOCKED: + return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') + if outcome == GATE_ERRORED: + return (VERDICT_DENY, f'chock could not check this write: {message}. Refusing rather than reporting an allow it never established.') + return None + + +def _judge(event): if event.event == "pre_tool" and event.command: verdict = evaluate(sys.argv[1:], event.command, event.tool or "") if verdict is not None: outcome, reason = verdict return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + gated = evaluate_gate(sys.argv[1:], event) + if gated is not None: + return Decision.deny(gated[1]) return None + + +def handle(event): + # A door that cannot decide refuses. An exception escaping here would exit the hook + # with a traceback, which every client reads as a non-blocking error: fail-open, with + # the reason on a stderr nobody watches. The refusal carries the reason instead. + try: + return _judge(event) + except Exception as exc: # noqa: BLE001 -- any failure here must refuse, never fall through + return Decision.deny( + "chock could not check this call (%s: %s). Refusing rather than reporting an " + "allow it never established." % (type(exc).__name__, exc) + ) # <<< agentseam handler <<< diff --git a/.chock/bin/grok.py b/.chock/bin/grok.py index 3c5454a..0f4fceb 100755 --- a/.chock/bin/grok.py +++ b/.chock/bin/grok.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.2.1 -- bundle("grok"). Do not hand-edit, except the +# Generated by agentseam 0.3.0 -- bundle("grok"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -15,14 +15,13 @@ import os as _chock_os import shlex as _chock_shlex -import shutil as _chock_shutil import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.2.1) +# contract (agentseam 0.3.0) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -238,21 +237,26 @@ def _accepted_by_markers(c, raw, name): for event_name, required in c.get("accept_when_all", {}).items() ) +def _disqualified(cfg, c, raw, name): + """Every reason an entry declines a payload once its wire event name is known.""" + if name not in cfg["events"] and not (c.get("accept_any_name") and isinstance(name, str)): + return True + if "client_types" in c and raw.get("client_type") not in c["client_types"]: + return True + return _rejected_by_markers(c, raw) + def hj_claims(cfg, raw): """True when this payload matches the entry's marker discipline.""" if not isinstance(raw, dict): return False c = cfg["claims"] name = _wire_name(cfg, raw) + # A positive self-identification beats a shared event name, accept_names included. + if raw.get("client_type") in c.get("reject_client_types", ()): + return False if name in c.get("accept_names", ()): return True - if name not in cfg["events"]: - return False - if "client_types" in c and raw.get("client_type") not in c["client_types"]: - return False - if _rejected_by_markers(c, raw): - return False - return _accepted_by_markers(c, raw, name) + return not _disqualified(cfg, c, raw, name) and _accepted_by_markers(c, raw, name) def _segment(node, part): """One path segment: a dict key, or `key[N]` indexing the list under it.""" @@ -344,7 +348,19 @@ def hj_parse(cfg, raw, wire=None): _TRANSFORM_MISSING_INPUT = "transform_missing_input" def hj_reverse(cfg): - """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" + """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides. + + One wire name per canonical event, which is a real limit and not an oversight (R3, gap 4). + Cursor is where it bites: `pre_tool` pins to `preToolUse`, and Cursor honours `ask` only at + `beforeShellExecution` / `beforeMCPExecution`. So installing at `pre_tool` forecloses `ask` + before dispatch is ever reached -- the runtime degrade to `deny` is honest about it, but the + install already chose. Deny-style policies, which is all that ships today, are unaffected. + + The day an ask-style Cursor policy exists, this map has to select by decision dialect + (deny -> `preToolUse`, ask -> `beforeShellExecution`) and become one-to-many. Recorded here + rather than built, so the resolution is not re-litigated from scratch; the three facts it + rests on are pinned in tests/test_cursor_ask_dialect.py. + """ reverse = {} for name, canonical in cfg["events"].items(): if canonical != UNKNOWN: @@ -563,7 +579,7 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos AGENT = "grok" -VENDOR = {'agent': 'grok', 'claims': {'event_key': ('hookEventName',), 'mode': 'marker'}, 'config_format': 'json', 'config_path': '.grok/hooks/agentseam.json', 'display': 'Grok CLI', 'events': {'PermissionDenied': 'tool_failure', 'PostCompact': 'pre_compact', 'PostToolUse': 'post_tool', 'PostToolUseFailure': 'tool_failure', 'PreCompact': 'pre_compact', 'PreToolUse': 'pre_tool', 'SessionEnd': 'session_end', 'SessionStart': 'session_start', 'Stop': 'stop', 'StopFailure': 'stop', 'SubagentStart': 'subagent_start', 'SubagentStop': 'subagent_stop', 'UserPromptSubmit': 'prompt_submit'}, 'evidence': {'claims': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'flat_decision', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content', 'tool_input.new_string'), 'cwd': ('cwd', 'workspaceRoot'), 'output': ('toolOutput',), 'path': ('tool_input.file_path', 'tool_input.path'), 'prompt': ('prompt',), 'session_id': ('sessionId',), 'tool': ('toolName',), 'tool_input': ('toolInput',)}, 'hook_entry': {'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': True, 'tools': {}, 'verdicts': {'answer_events': ('PreToolUse',), 'bare_allow': 'silent', 'degrade_notes': {'escalate': 'Grok cannot prompt for confirmation', 'escalate_from_transform': 'Grok cannot modify a tool call', 'transform': 'Grok cannot modify a tool call'}, 'gates': {'PreToolUse': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'because', 'vocabulary': ('deny',), 'vocabulary_basis': 'verified', 'words': {'block': 'deny'}}, 'wire_events': {'pre_compact': 'PreCompact', 'stop': 'Stop', 'tool_failure': 'PostToolUseFailure'}} +VENDOR = {'agent': 'grok', 'claims': {'event_key': ('hookEventName',), 'mode': 'marker'}, 'config_format': 'json', 'config_path': '.grok/hooks/agentseam.json', 'display': 'Grok CLI', 'events': {'PermissionDenied': 'tool_failure', 'PostCompact': 'unknown', 'PostToolUse': 'post_tool', 'PostToolUseFailure': 'tool_failure', 'PreCompact': 'pre_compact', 'PreToolUse': 'pre_tool', 'SessionEnd': 'session_end', 'SessionStart': 'session_start', 'Stop': 'stop', 'StopFailure': 'stop', 'SubagentStart': 'subagent_start', 'SubagentStop': 'subagent_stop', 'UserPromptSubmit': 'prompt_submit'}, 'evidence': {'claims': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'flat_decision', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content', 'tool_input.new_string'), 'cwd': ('cwd', 'workspaceRoot'), 'output': ('toolOutput',), 'path': ('tool_input.file_path', 'tool_input.path'), 'prompt': ('prompt',), 'session_id': ('sessionId',), 'tool': ('toolName',), 'tool_input': ('toolInput',)}, 'hook_entry': {'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': True, 'tools': {}, 'verdicts': {'answer_events': ('PreToolUse',), 'bare_allow': 'silent', 'degrade_notes': {'escalate': 'Grok cannot prompt for confirmation', 'escalate_from_transform': 'Grok cannot modify a tool call', 'transform': 'Grok cannot modify a tool call'}, 'gates': {'PreToolUse': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'because', 'vocabulary': ('deny',), 'vocabulary_basis': 'verified', 'words': {'block': 'deny'}}, 'wire_events': {'pre_compact': 'PreCompact', 'stop': 'Stop', 'tool_failure': 'PostToolUseFailure'}} def claims(raw): @@ -616,6 +632,10 @@ def degrade(decision, event): # >>> agentseam handler >>> GUARD_VIOLATION = 1 +GUARD_ASK_EXIT = 3 + +PYTHON_SUFFIX = '.py' + _BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') GATE_LOG_ENV = 'CHOCK_GATE_LOG' @@ -628,6 +648,8 @@ def degrade(decision, event): GUARD_CLEAN = 'clean' +GUARD_ASKED = 'asked' + GUARD_UNCHECKED = 'unchecked' GUARD_ERRORED = 'errored' @@ -655,41 +677,56 @@ def find_bash(guard: _chock_Path) -> str | None: return candidate return None +def find_interpreter(guard: _chock_Path) -> str | None: + """The interpreter that can run `guard`: this Python for `.py`, otherwise a usable bash.""" + if guard.suffix == PYTHON_SUFFIX: + return sys.executable or None + return find_bash(guard) + def run_guard(guard: _chock_Path, command: str) -> str: - """`GUARD_BLOCKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + """`GUARD_BLOCKED` / `GUARD_ASKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + return run_guard_detailed(guard, command)[0] + +def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: + """`run_guard`'s verdict plus the guard's own first line, which an ask carries to the user.""" try: args = _chock_shlex.split(command) except ValueError: print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) - return GUARD_UNCHECKED + return (GUARD_UNCHECKED, '') if not args: - return GUARD_UNCHECKED - bash = find_bash(guard) - if bash is None: - print(f'chock: no usable bash found, {guard.name} not checked', file=sys.stderr) - return GUARD_UNCHECKED + return (GUARD_UNCHECKED, '') + interpreter = find_interpreter(guard) + if interpreter is None: + print(f'chock: no usable interpreter found, {guard.name} not checked', file=sys.stderr) + return (GUARD_UNCHECKED, '') try: env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} - proc = _chock_subprocess.run([bash, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([interpreter, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) except _chock_subprocess.TimeoutExpired: print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) - return GUARD_ERRORED + return (GUARD_ERRORED, '') except (OSError, UnicodeError) as exc: print(f'chock: guard could not run, not checked: {exc}', file=sys.stderr) - return GUARD_ERRORED + return (GUARD_ERRORED, '') + output = ((proc.stderr or '') + (proc.stdout or '')).strip() + first_line = output.splitlines()[0].strip() if output else '' if proc.returncode == GUARD_VIOLATION: sys.stderr.write(proc.stdout or '') sys.stderr.write(proc.stderr or '') - if not ((proc.stdout or '') + (proc.stderr or '')).strip(): + if not output: print(f'chock: blocked by {_chock_Path(guard).name} (guard gave no reason)', file=sys.stderr) - return GUARD_BLOCKED + return (GUARD_BLOCKED, first_line) + if proc.returncode == GUARD_ASK_EXIT: + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + return (GUARD_ASKED, first_line) if proc.returncode != 0: - detail = (proc.stderr or proc.stdout or '').strip().splitlines() - print(f'chock: guard exited {proc.returncode}, not checked' + (f': {detail[0][:120]}' if detail else ''), file=sys.stderr) - return GUARD_ERRORED - return GUARD_CLEAN + print(f'chock: guard exited {proc.returncode}, not checked' + (f': {first_line[:120]}' if first_line else ''), file=sys.stderr) + return (GUARD_ERRORED, '') + return (GUARD_CLEAN, '') -def log_outcome(guard: _chock_Path, tool: str, *, blocked: bool) -> None: +def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> None: """Append one outcome record. Best effort: never raises, never changes the verdict.""" try: if _chock_os.environ.get(GATE_LOG_ENV) == '0': @@ -709,7 +746,7 @@ def log_outcome(guard: _chock_Path, tool: str, *, blocked: bool) -> None: log_path = log_dir / 'gate-events.jsonl' if log_path.exists() and log_path.stat().st_size > _LOG_MAX_BYTES: log_path.replace(log_dir / 'gate-events.1.jsonl') - record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': 'block' if blocked else 'allow'} + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': verdict} with log_path.open('a', encoding='utf-8') as fh: fh.write(json.dumps(record, ensure_ascii=False) + '\n') except Exception: @@ -720,23 +757,176 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N guard = guard_path_from_argv(argv) if guard is None or not guard.exists(): return None - verdict = run_guard(guard, command) - if verdict in (GUARD_BLOCKED, GUARD_CLEAN): - log_outcome(guard, tool, blocked=verdict == GUARD_BLOCKED) + verdict, message = run_guard_detailed(guard, command) + logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} + if verdict in logged: + log_outcome(guard, tool, verdict=logged[verdict]) if verdict == GUARD_BLOCKED: return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + if verdict == GUARD_ASKED: + return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') if verdict == GUARD_ERRORED: return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") return None +GATE_FLAG = '--gate' -def handle(event): +_GATE_TIMEOUT_SECONDS = 30 + +_GATE_DEPTH_TO_CHOCK = 3 + +_RUNNER_PARTS = ('bin', 'gate.py') + +_GIT = 'git' + +_DELETED = 'D' + +_RENAMED = 'R' + +GATE_BLOCKED = 'blocked' + +GATE_CLEAN = 'clean' + +GATE_ERRORED = 'errored' + +VERDICT_DENY = 'deny' + +def gate_path_from_argv(argv): + """The `--gate ` argument a vendored runtime was invoked with, or None.""" + if GATE_FLAG in argv: + index = argv.index(GATE_FLAG) + if index + 1 < len(argv): + return _chock_Path(argv[index + 1]) + return None + +def runner_for(gate): + """The vendored gate runner beside this compiled gate, or None when it is not there.""" + parents = gate.resolve().parents + if len(parents) <= _GATE_DEPTH_TO_CHOCK: + return None + runner = parents[_GATE_DEPTH_TO_CHOCK].joinpath(*_RUNNER_PARTS) + return runner if runner.exists() else None + +def writes_from_event(event): + """The one file this tool call would write, or none when it carries no file text.""" + path = getattr(event, 'path', None) + content = getattr(event, 'content', None) + if not path or not isinstance(content, str): + return {} + return {str(path): content} + +def changed_paths(repo_root): + """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" + try: + proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + except (OSError, _chock_subprocess.SubprocessError): + return [] + if proc.returncode != 0: + return [] + fields = [field for field in (proc.stdout or '').split('\x00') if field] + paths = [] + skip_next = False + for field in fields: + if skip_next: + skip_next = False + continue + status, path = (field[:2], field[3:]) + skip_next = status.startswith(_RENAMED) + if _DELETED in status or not path: + continue + paths.append(path) + return paths + +def writes_from_worktree(repo_root): + """What this turn actually left on disk, however it was written. + + The write path sees only writes it recognises; a shell heredoc carries no file argument. + Reading final state is what makes that stop mattering, so this deliberately does not care + which tool produced the bytes. + """ + writes = {} + for path in changed_paths(repo_root): + try: + writes[path] = _chock_Path(repo_root, path).read_text(encoding='utf-8') + except (OSError, UnicodeDecodeError): + continue + return writes + +def run_gate(gate, writes, event): + """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) + except (OSError, _chock_subprocess.SubprocessError) as exc: + return (GATE_ERRORED, str(exc)) + if proc.returncode == 0: + return (GATE_CLEAN, '') + if proc.returncode == 1: + return (GATE_BLOCKED, (proc.stderr or '').strip()) + return (GATE_ERRORED, (proc.stderr or '').strip()) + +_EVENT_ARG = {'pre_tool': 'pre-tool-use', 'stop': 'stop'} + +PRE_TOOL = 'pre_tool' + +def root_for(gate): + """The repository this compiled gate belongs to, or None when the layout is not that.""" + parents = gate.resolve().parents + if len(parents) <= _GATE_DEPTH_TO_CHOCK: + return None + return parents[_GATE_DEPTH_TO_CHOCK].parent + +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 {} + +def evaluate_gate(argv, event): + """The decision this event earns from a compiled gate, or None when it has nothing to say.""" + gate = gate_path_from_argv(argv) + name = _EVENT_ARG.get(getattr(event, 'event', '')) + if gate is None or name is None or (not gate.exists()): + return None + writes = writes_for(event, gate) + if not writes: + return None + outcome, message = run_gate(gate, writes, name) + if outcome == GATE_BLOCKED: + return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') + if outcome == GATE_ERRORED: + return (VERDICT_DENY, f'chock could not check this write: {message}. Refusing rather than reporting an allow it never established.') + return None + + +def _judge(event): if event.event == "pre_tool" and event.command: verdict = evaluate(sys.argv[1:], event.command, event.tool or "") if verdict is not None: outcome, reason = verdict return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + gated = evaluate_gate(sys.argv[1:], event) + if gated is not None: + return Decision.deny(gated[1]) return None + + +def handle(event): + # A door that cannot decide refuses. An exception escaping here would exit the hook + # with a traceback, which every client reads as a non-blocking error: fail-open, with + # the reason on a stderr nobody watches. The refusal carries the reason instead. + try: + return _judge(event) + except Exception as exc: # noqa: BLE001 -- any failure here must refuse, never fall through + return Decision.deny( + "chock could not check this call (%s: %s). Refusing rather than reporting an " + "allow it never established." % (type(exc).__name__, exc) + ) # <<< agentseam handler <<< diff --git a/.chock/bin/tabnine.py b/.chock/bin/tabnine.py index 9670808..88c2225 100755 --- a/.chock/bin/tabnine.py +++ b/.chock/bin/tabnine.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.2.1 -- bundle("tabnine"). Do not hand-edit, except the +# Generated by agentseam 0.3.0 -- bundle("tabnine"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -15,14 +15,13 @@ import os as _chock_os import shlex as _chock_shlex -import shutil as _chock_shutil import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.2.1) +# contract (agentseam 0.3.0) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -238,21 +237,26 @@ def _accepted_by_markers(c, raw, name): for event_name, required in c.get("accept_when_all", {}).items() ) +def _disqualified(cfg, c, raw, name): + """Every reason an entry declines a payload once its wire event name is known.""" + if name not in cfg["events"] and not (c.get("accept_any_name") and isinstance(name, str)): + return True + if "client_types" in c and raw.get("client_type") not in c["client_types"]: + return True + return _rejected_by_markers(c, raw) + def hj_claims(cfg, raw): """True when this payload matches the entry's marker discipline.""" if not isinstance(raw, dict): return False c = cfg["claims"] name = _wire_name(cfg, raw) + # A positive self-identification beats a shared event name, accept_names included. + if raw.get("client_type") in c.get("reject_client_types", ()): + return False if name in c.get("accept_names", ()): return True - if name not in cfg["events"]: - return False - if "client_types" in c and raw.get("client_type") not in c["client_types"]: - return False - if _rejected_by_markers(c, raw): - return False - return _accepted_by_markers(c, raw, name) + return not _disqualified(cfg, c, raw, name) and _accepted_by_markers(c, raw, name) def _segment(node, part): """One path segment: a dict key, or `key[N]` indexing the list under it.""" @@ -344,7 +348,19 @@ def hj_parse(cfg, raw, wire=None): _TRANSFORM_MISSING_INPUT = "transform_missing_input" def hj_reverse(cfg): - """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" + """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides. + + One wire name per canonical event, which is a real limit and not an oversight (R3, gap 4). + Cursor is where it bites: `pre_tool` pins to `preToolUse`, and Cursor honours `ask` only at + `beforeShellExecution` / `beforeMCPExecution`. So installing at `pre_tool` forecloses `ask` + before dispatch is ever reached -- the runtime degrade to `deny` is honest about it, but the + install already chose. Deny-style policies, which is all that ships today, are unaffected. + + The day an ask-style Cursor policy exists, this map has to select by decision dialect + (deny -> `preToolUse`, ask -> `beforeShellExecution`) and become one-to-many. Recorded here + rather than built, so the resolution is not re-litigated from scratch; the three facts it + rests on are pinned in tests/test_cursor_ask_dialect.py. + """ reverse = {} for name, canonical in cfg["events"].items(): if canonical != UNKNOWN: @@ -616,6 +632,10 @@ def degrade(decision, event): # >>> agentseam handler >>> GUARD_VIOLATION = 1 +GUARD_ASK_EXIT = 3 + +PYTHON_SUFFIX = '.py' + _BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') GATE_LOG_ENV = 'CHOCK_GATE_LOG' @@ -628,6 +648,8 @@ def degrade(decision, event): GUARD_CLEAN = 'clean' +GUARD_ASKED = 'asked' + GUARD_UNCHECKED = 'unchecked' GUARD_ERRORED = 'errored' @@ -655,41 +677,56 @@ def find_bash(guard: _chock_Path) -> str | None: return candidate return None +def find_interpreter(guard: _chock_Path) -> str | None: + """The interpreter that can run `guard`: this Python for `.py`, otherwise a usable bash.""" + if guard.suffix == PYTHON_SUFFIX: + return sys.executable or None + return find_bash(guard) + def run_guard(guard: _chock_Path, command: str) -> str: - """`GUARD_BLOCKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + """`GUARD_BLOCKED` / `GUARD_ASKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + return run_guard_detailed(guard, command)[0] + +def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: + """`run_guard`'s verdict plus the guard's own first line, which an ask carries to the user.""" try: args = _chock_shlex.split(command) except ValueError: print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) - return GUARD_UNCHECKED + return (GUARD_UNCHECKED, '') if not args: - return GUARD_UNCHECKED - bash = find_bash(guard) - if bash is None: - print(f'chock: no usable bash found, {guard.name} not checked', file=sys.stderr) - return GUARD_UNCHECKED + return (GUARD_UNCHECKED, '') + interpreter = find_interpreter(guard) + if interpreter is None: + print(f'chock: no usable interpreter found, {guard.name} not checked', file=sys.stderr) + return (GUARD_UNCHECKED, '') try: env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} - proc = _chock_subprocess.run([bash, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([interpreter, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) except _chock_subprocess.TimeoutExpired: print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) - return GUARD_ERRORED + return (GUARD_ERRORED, '') except (OSError, UnicodeError) as exc: print(f'chock: guard could not run, not checked: {exc}', file=sys.stderr) - return GUARD_ERRORED + return (GUARD_ERRORED, '') + output = ((proc.stderr or '') + (proc.stdout or '')).strip() + first_line = output.splitlines()[0].strip() if output else '' if proc.returncode == GUARD_VIOLATION: sys.stderr.write(proc.stdout or '') sys.stderr.write(proc.stderr or '') - if not ((proc.stdout or '') + (proc.stderr or '')).strip(): + if not output: print(f'chock: blocked by {_chock_Path(guard).name} (guard gave no reason)', file=sys.stderr) - return GUARD_BLOCKED + return (GUARD_BLOCKED, first_line) + if proc.returncode == GUARD_ASK_EXIT: + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + return (GUARD_ASKED, first_line) if proc.returncode != 0: - detail = (proc.stderr or proc.stdout or '').strip().splitlines() - print(f'chock: guard exited {proc.returncode}, not checked' + (f': {detail[0][:120]}' if detail else ''), file=sys.stderr) - return GUARD_ERRORED - return GUARD_CLEAN + print(f'chock: guard exited {proc.returncode}, not checked' + (f': {first_line[:120]}' if first_line else ''), file=sys.stderr) + return (GUARD_ERRORED, '') + return (GUARD_CLEAN, '') -def log_outcome(guard: _chock_Path, tool: str, *, blocked: bool) -> None: +def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> None: """Append one outcome record. Best effort: never raises, never changes the verdict.""" try: if _chock_os.environ.get(GATE_LOG_ENV) == '0': @@ -709,7 +746,7 @@ def log_outcome(guard: _chock_Path, tool: str, *, blocked: bool) -> None: log_path = log_dir / 'gate-events.jsonl' if log_path.exists() and log_path.stat().st_size > _LOG_MAX_BYTES: log_path.replace(log_dir / 'gate-events.1.jsonl') - record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': 'block' if blocked else 'allow'} + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': verdict} with log_path.open('a', encoding='utf-8') as fh: fh.write(json.dumps(record, ensure_ascii=False) + '\n') except Exception: @@ -720,23 +757,176 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N guard = guard_path_from_argv(argv) if guard is None or not guard.exists(): return None - verdict = run_guard(guard, command) - if verdict in (GUARD_BLOCKED, GUARD_CLEAN): - log_outcome(guard, tool, blocked=verdict == GUARD_BLOCKED) + verdict, message = run_guard_detailed(guard, command) + logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} + if verdict in logged: + log_outcome(guard, tool, verdict=logged[verdict]) if verdict == GUARD_BLOCKED: return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + if verdict == GUARD_ASKED: + return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') if verdict == GUARD_ERRORED: return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") return None +GATE_FLAG = '--gate' -def handle(event): +_GATE_TIMEOUT_SECONDS = 30 + +_GATE_DEPTH_TO_CHOCK = 3 + +_RUNNER_PARTS = ('bin', 'gate.py') + +_GIT = 'git' + +_DELETED = 'D' + +_RENAMED = 'R' + +GATE_BLOCKED = 'blocked' + +GATE_CLEAN = 'clean' + +GATE_ERRORED = 'errored' + +VERDICT_DENY = 'deny' + +def gate_path_from_argv(argv): + """The `--gate ` argument a vendored runtime was invoked with, or None.""" + if GATE_FLAG in argv: + index = argv.index(GATE_FLAG) + if index + 1 < len(argv): + return _chock_Path(argv[index + 1]) + return None + +def runner_for(gate): + """The vendored gate runner beside this compiled gate, or None when it is not there.""" + parents = gate.resolve().parents + if len(parents) <= _GATE_DEPTH_TO_CHOCK: + return None + runner = parents[_GATE_DEPTH_TO_CHOCK].joinpath(*_RUNNER_PARTS) + return runner if runner.exists() else None + +def writes_from_event(event): + """The one file this tool call would write, or none when it carries no file text.""" + path = getattr(event, 'path', None) + content = getattr(event, 'content', None) + if not path or not isinstance(content, str): + return {} + return {str(path): content} + +def changed_paths(repo_root): + """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" + try: + proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + except (OSError, _chock_subprocess.SubprocessError): + return [] + if proc.returncode != 0: + return [] + fields = [field for field in (proc.stdout or '').split('\x00') if field] + paths = [] + skip_next = False + for field in fields: + if skip_next: + skip_next = False + continue + status, path = (field[:2], field[3:]) + skip_next = status.startswith(_RENAMED) + if _DELETED in status or not path: + continue + paths.append(path) + return paths + +def writes_from_worktree(repo_root): + """What this turn actually left on disk, however it was written. + + The write path sees only writes it recognises; a shell heredoc carries no file argument. + Reading final state is what makes that stop mattering, so this deliberately does not care + which tool produced the bytes. + """ + writes = {} + for path in changed_paths(repo_root): + try: + writes[path] = _chock_Path(repo_root, path).read_text(encoding='utf-8') + except (OSError, UnicodeDecodeError): + continue + return writes + +def run_gate(gate, writes, event): + """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) + except (OSError, _chock_subprocess.SubprocessError) as exc: + return (GATE_ERRORED, str(exc)) + if proc.returncode == 0: + return (GATE_CLEAN, '') + if proc.returncode == 1: + return (GATE_BLOCKED, (proc.stderr or '').strip()) + return (GATE_ERRORED, (proc.stderr or '').strip()) + +_EVENT_ARG = {'pre_tool': 'pre-tool-use', 'stop': 'stop'} + +PRE_TOOL = 'pre_tool' + +def root_for(gate): + """The repository this compiled gate belongs to, or None when the layout is not that.""" + parents = gate.resolve().parents + if len(parents) <= _GATE_DEPTH_TO_CHOCK: + return None + return parents[_GATE_DEPTH_TO_CHOCK].parent + +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 {} + +def evaluate_gate(argv, event): + """The decision this event earns from a compiled gate, or None when it has nothing to say.""" + gate = gate_path_from_argv(argv) + name = _EVENT_ARG.get(getattr(event, 'event', '')) + if gate is None or name is None or (not gate.exists()): + return None + writes = writes_for(event, gate) + if not writes: + return None + outcome, message = run_gate(gate, writes, name) + if outcome == GATE_BLOCKED: + return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') + if outcome == GATE_ERRORED: + return (VERDICT_DENY, f'chock could not check this write: {message}. Refusing rather than reporting an allow it never established.') + return None + + +def _judge(event): if event.event == "pre_tool" and event.command: verdict = evaluate(sys.argv[1:], event.command, event.tool or "") if verdict is not None: outcome, reason = verdict return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + gated = evaluate_gate(sys.argv[1:], event) + if gated is not None: + return Decision.deny(gated[1]) return None + + +def handle(event): + # A door that cannot decide refuses. An exception escaping here would exit the hook + # with a traceback, which every client reads as a non-blocking error: fail-open, with + # the reason on a stderr nobody watches. The refusal carries the reason instead. + try: + return _judge(event) + except Exception as exc: # noqa: BLE001 -- any failure here must refuse, never fall through + return Decision.deny( + "chock could not check this call (%s: %s). Refusing rather than reporting an " + "allow it never established." % (type(exc).__name__, exc) + ) # <<< agentseam handler <<< diff --git a/.chock/bin/vscode_copilot.py b/.chock/bin/vscode_copilot.py index b27f465..2cc45d9 100755 --- a/.chock/bin/vscode_copilot.py +++ b/.chock/bin/vscode_copilot.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.2.1 -- bundle("vscode_copilot"). Do not hand-edit, except the +# Generated by agentseam 0.3.0 -- bundle("vscode_copilot"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -15,14 +15,13 @@ import os as _chock_os import shlex as _chock_shlex -import shutil as _chock_shutil import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.2.1) +# contract (agentseam 0.3.0) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -461,6 +460,10 @@ def degrade(decision, event): # >>> agentseam handler >>> GUARD_VIOLATION = 1 +GUARD_ASK_EXIT = 3 + +PYTHON_SUFFIX = '.py' + _BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') GATE_LOG_ENV = 'CHOCK_GATE_LOG' @@ -473,6 +476,8 @@ def degrade(decision, event): GUARD_CLEAN = 'clean' +GUARD_ASKED = 'asked' + GUARD_UNCHECKED = 'unchecked' GUARD_ERRORED = 'errored' @@ -500,41 +505,56 @@ def find_bash(guard: _chock_Path) -> str | None: return candidate return None +def find_interpreter(guard: _chock_Path) -> str | None: + """The interpreter that can run `guard`: this Python for `.py`, otherwise a usable bash.""" + if guard.suffix == PYTHON_SUFFIX: + return sys.executable or None + return find_bash(guard) + def run_guard(guard: _chock_Path, command: str) -> str: - """`GUARD_BLOCKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + """`GUARD_BLOCKED` / `GUARD_ASKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + return run_guard_detailed(guard, command)[0] + +def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: + """`run_guard`'s verdict plus the guard's own first line, which an ask carries to the user.""" try: args = _chock_shlex.split(command) except ValueError: print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) - return GUARD_UNCHECKED + return (GUARD_UNCHECKED, '') if not args: - return GUARD_UNCHECKED - bash = find_bash(guard) - if bash is None: - print(f'chock: no usable bash found, {guard.name} not checked', file=sys.stderr) - return GUARD_UNCHECKED + return (GUARD_UNCHECKED, '') + interpreter = find_interpreter(guard) + if interpreter is None: + print(f'chock: no usable interpreter found, {guard.name} not checked', file=sys.stderr) + return (GUARD_UNCHECKED, '') try: env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} - proc = _chock_subprocess.run([bash, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([interpreter, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) except _chock_subprocess.TimeoutExpired: print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) - return GUARD_ERRORED + return (GUARD_ERRORED, '') except (OSError, UnicodeError) as exc: print(f'chock: guard could not run, not checked: {exc}', file=sys.stderr) - return GUARD_ERRORED + return (GUARD_ERRORED, '') + output = ((proc.stderr or '') + (proc.stdout or '')).strip() + first_line = output.splitlines()[0].strip() if output else '' if proc.returncode == GUARD_VIOLATION: sys.stderr.write(proc.stdout or '') sys.stderr.write(proc.stderr or '') - if not ((proc.stdout or '') + (proc.stderr or '')).strip(): + if not output: print(f'chock: blocked by {_chock_Path(guard).name} (guard gave no reason)', file=sys.stderr) - return GUARD_BLOCKED + return (GUARD_BLOCKED, first_line) + if proc.returncode == GUARD_ASK_EXIT: + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + return (GUARD_ASKED, first_line) if proc.returncode != 0: - detail = (proc.stderr or proc.stdout or '').strip().splitlines() - print(f'chock: guard exited {proc.returncode}, not checked' + (f': {detail[0][:120]}' if detail else ''), file=sys.stderr) - return GUARD_ERRORED - return GUARD_CLEAN + print(f'chock: guard exited {proc.returncode}, not checked' + (f': {first_line[:120]}' if first_line else ''), file=sys.stderr) + return (GUARD_ERRORED, '') + return (GUARD_CLEAN, '') -def log_outcome(guard: _chock_Path, tool: str, *, blocked: bool) -> None: +def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> None: """Append one outcome record. Best effort: never raises, never changes the verdict.""" try: if _chock_os.environ.get(GATE_LOG_ENV) == '0': @@ -554,7 +574,7 @@ def log_outcome(guard: _chock_Path, tool: str, *, blocked: bool) -> None: log_path = log_dir / 'gate-events.jsonl' if log_path.exists() and log_path.stat().st_size > _LOG_MAX_BYTES: log_path.replace(log_dir / 'gate-events.1.jsonl') - record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': 'block' if blocked else 'allow'} + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': verdict} with log_path.open('a', encoding='utf-8') as fh: fh.write(json.dumps(record, ensure_ascii=False) + '\n') except Exception: @@ -565,23 +585,176 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N guard = guard_path_from_argv(argv) if guard is None or not guard.exists(): return None - verdict = run_guard(guard, command) - if verdict in (GUARD_BLOCKED, GUARD_CLEAN): - log_outcome(guard, tool, blocked=verdict == GUARD_BLOCKED) + verdict, message = run_guard_detailed(guard, command) + logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} + if verdict in logged: + log_outcome(guard, tool, verdict=logged[verdict]) if verdict == GUARD_BLOCKED: return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + if verdict == GUARD_ASKED: + return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') if verdict == GUARD_ERRORED: return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") return None +GATE_FLAG = '--gate' -def handle(event): +_GATE_TIMEOUT_SECONDS = 30 + +_GATE_DEPTH_TO_CHOCK = 3 + +_RUNNER_PARTS = ('bin', 'gate.py') + +_GIT = 'git' + +_DELETED = 'D' + +_RENAMED = 'R' + +GATE_BLOCKED = 'blocked' + +GATE_CLEAN = 'clean' + +GATE_ERRORED = 'errored' + +VERDICT_DENY = 'deny' + +def gate_path_from_argv(argv): + """The `--gate ` argument a vendored runtime was invoked with, or None.""" + if GATE_FLAG in argv: + index = argv.index(GATE_FLAG) + if index + 1 < len(argv): + return _chock_Path(argv[index + 1]) + return None + +def runner_for(gate): + """The vendored gate runner beside this compiled gate, or None when it is not there.""" + parents = gate.resolve().parents + if len(parents) <= _GATE_DEPTH_TO_CHOCK: + return None + runner = parents[_GATE_DEPTH_TO_CHOCK].joinpath(*_RUNNER_PARTS) + return runner if runner.exists() else None + +def writes_from_event(event): + """The one file this tool call would write, or none when it carries no file text.""" + path = getattr(event, 'path', None) + content = getattr(event, 'content', None) + if not path or not isinstance(content, str): + return {} + return {str(path): content} + +def changed_paths(repo_root): + """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" + try: + proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + except (OSError, _chock_subprocess.SubprocessError): + return [] + if proc.returncode != 0: + return [] + fields = [field for field in (proc.stdout or '').split('\x00') if field] + paths = [] + skip_next = False + for field in fields: + if skip_next: + skip_next = False + continue + status, path = (field[:2], field[3:]) + skip_next = status.startswith(_RENAMED) + if _DELETED in status or not path: + continue + paths.append(path) + return paths + +def writes_from_worktree(repo_root): + """What this turn actually left on disk, however it was written. + + The write path sees only writes it recognises; a shell heredoc carries no file argument. + Reading final state is what makes that stop mattering, so this deliberately does not care + which tool produced the bytes. + """ + writes = {} + for path in changed_paths(repo_root): + try: + writes[path] = _chock_Path(repo_root, path).read_text(encoding='utf-8') + except (OSError, UnicodeDecodeError): + continue + return writes + +def run_gate(gate, writes, event): + """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) + except (OSError, _chock_subprocess.SubprocessError) as exc: + return (GATE_ERRORED, str(exc)) + if proc.returncode == 0: + return (GATE_CLEAN, '') + if proc.returncode == 1: + return (GATE_BLOCKED, (proc.stderr or '').strip()) + return (GATE_ERRORED, (proc.stderr or '').strip()) + +_EVENT_ARG = {'pre_tool': 'pre-tool-use', 'stop': 'stop'} + +PRE_TOOL = 'pre_tool' + +def root_for(gate): + """The repository this compiled gate belongs to, or None when the layout is not that.""" + parents = gate.resolve().parents + if len(parents) <= _GATE_DEPTH_TO_CHOCK: + return None + return parents[_GATE_DEPTH_TO_CHOCK].parent + +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 {} + +def evaluate_gate(argv, event): + """The decision this event earns from a compiled gate, or None when it has nothing to say.""" + gate = gate_path_from_argv(argv) + name = _EVENT_ARG.get(getattr(event, 'event', '')) + if gate is None or name is None or (not gate.exists()): + return None + writes = writes_for(event, gate) + if not writes: + return None + outcome, message = run_gate(gate, writes, name) + if outcome == GATE_BLOCKED: + return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') + if outcome == GATE_ERRORED: + return (VERDICT_DENY, f'chock could not check this write: {message}. Refusing rather than reporting an allow it never established.') + return None + + +def _judge(event): if event.event == "pre_tool" and event.command: verdict = evaluate(sys.argv[1:], event.command, event.tool or "") if verdict is not None: outcome, reason = verdict return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + gated = evaluate_gate(sys.argv[1:], event) + if gated is not None: + return Decision.deny(gated[1]) return None + + +def handle(event): + # A door that cannot decide refuses. An exception escaping here would exit the hook + # with a traceback, which every client reads as a non-blocking error: fail-open, with + # the reason on a stderr nobody watches. The refusal carries the reason instead. + try: + return _judge(event) + except Exception as exc: # noqa: BLE001 -- any failure here must refuse, never fall through + return Decision.deny( + "chock could not check this call (%s: %s). Refusing rather than reporting an " + "allow it never established." % (type(exc).__name__, exc) + ) # <<< agentseam handler <<< diff --git a/.chock/bin/windsurf.py b/.chock/bin/windsurf.py index 4dd37fe..538b028 100755 --- a/.chock/bin/windsurf.py +++ b/.chock/bin/windsurf.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.2.1 -- bundle("windsurf"). Do not hand-edit, except the +# Generated by agentseam 0.3.0 -- bundle("windsurf"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -15,14 +15,13 @@ import os as _chock_os import shlex as _chock_shlex -import shutil as _chock_shutil import subprocess as _chock_subprocess from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.2.1) +# contract (agentseam 0.3.0) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -310,7 +309,19 @@ def hj_parse(cfg, raw, wire=None): _TRANSFORM_MISSING_INPUT = "transform_missing_input" def hj_reverse(cfg): - """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" + """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides. + + One wire name per canonical event, which is a real limit and not an oversight (R3, gap 4). + Cursor is where it bites: `pre_tool` pins to `preToolUse`, and Cursor honours `ask` only at + `beforeShellExecution` / `beforeMCPExecution`. So installing at `pre_tool` forecloses `ask` + before dispatch is ever reached -- the runtime degrade to `deny` is honest about it, but the + install already chose. Deny-style policies, which is all that ships today, are unaffected. + + The day an ask-style Cursor policy exists, this map has to select by decision dialect + (deny -> `preToolUse`, ask -> `beforeShellExecution`) and become one-to-many. Recorded here + rather than built, so the resolution is not re-litigated from scratch; the three facts it + rests on are pinned in tests/test_cursor_ask_dialect.py. + """ reverse = {} for name, canonical in cfg["events"].items(): if canonical != UNKNOWN: @@ -532,6 +543,10 @@ def degrade(decision, event): # >>> agentseam handler >>> GUARD_VIOLATION = 1 +GUARD_ASK_EXIT = 3 + +PYTHON_SUFFIX = '.py' + _BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') GATE_LOG_ENV = 'CHOCK_GATE_LOG' @@ -544,6 +559,8 @@ def degrade(decision, event): GUARD_CLEAN = 'clean' +GUARD_ASKED = 'asked' + GUARD_UNCHECKED = 'unchecked' GUARD_ERRORED = 'errored' @@ -571,41 +588,56 @@ def find_bash(guard: _chock_Path) -> str | None: return candidate return None +def find_interpreter(guard: _chock_Path) -> str | None: + """The interpreter that can run `guard`: this Python for `.py`, otherwise a usable bash.""" + if guard.suffix == PYTHON_SUFFIX: + return sys.executable or None + return find_bash(guard) + def run_guard(guard: _chock_Path, command: str) -> str: - """`GUARD_BLOCKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + """`GUARD_BLOCKED` / `GUARD_ASKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + return run_guard_detailed(guard, command)[0] + +def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: + """`run_guard`'s verdict plus the guard's own first line, which an ask carries to the user.""" try: args = _chock_shlex.split(command) except ValueError: print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) - return GUARD_UNCHECKED + return (GUARD_UNCHECKED, '') if not args: - return GUARD_UNCHECKED - bash = find_bash(guard) - if bash is None: - print(f'chock: no usable bash found, {guard.name} not checked', file=sys.stderr) - return GUARD_UNCHECKED + return (GUARD_UNCHECKED, '') + interpreter = find_interpreter(guard) + if interpreter is None: + print(f'chock: no usable interpreter found, {guard.name} not checked', file=sys.stderr) + return (GUARD_UNCHECKED, '') try: env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} - proc = _chock_subprocess.run([bash, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run([interpreter, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS, check=False) except _chock_subprocess.TimeoutExpired: print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) - return GUARD_ERRORED + return (GUARD_ERRORED, '') except (OSError, UnicodeError) as exc: print(f'chock: guard could not run, not checked: {exc}', file=sys.stderr) - return GUARD_ERRORED + return (GUARD_ERRORED, '') + output = ((proc.stderr or '') + (proc.stdout or '')).strip() + first_line = output.splitlines()[0].strip() if output else '' if proc.returncode == GUARD_VIOLATION: sys.stderr.write(proc.stdout or '') sys.stderr.write(proc.stderr or '') - if not ((proc.stdout or '') + (proc.stderr or '')).strip(): + if not output: print(f'chock: blocked by {_chock_Path(guard).name} (guard gave no reason)', file=sys.stderr) - return GUARD_BLOCKED + return (GUARD_BLOCKED, first_line) + if proc.returncode == GUARD_ASK_EXIT: + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + return (GUARD_ASKED, first_line) if proc.returncode != 0: - detail = (proc.stderr or proc.stdout or '').strip().splitlines() - print(f'chock: guard exited {proc.returncode}, not checked' + (f': {detail[0][:120]}' if detail else ''), file=sys.stderr) - return GUARD_ERRORED - return GUARD_CLEAN + print(f'chock: guard exited {proc.returncode}, not checked' + (f': {first_line[:120]}' if first_line else ''), file=sys.stderr) + return (GUARD_ERRORED, '') + return (GUARD_CLEAN, '') -def log_outcome(guard: _chock_Path, tool: str, *, blocked: bool) -> None: +def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> None: """Append one outcome record. Best effort: never raises, never changes the verdict.""" try: if _chock_os.environ.get(GATE_LOG_ENV) == '0': @@ -625,7 +657,7 @@ def log_outcome(guard: _chock_Path, tool: str, *, blocked: bool) -> None: log_path = log_dir / 'gate-events.jsonl' if log_path.exists() and log_path.stat().st_size > _LOG_MAX_BYTES: log_path.replace(log_dir / 'gate-events.1.jsonl') - record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': 'block' if blocked else 'allow'} + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': verdict} with log_path.open('a', encoding='utf-8') as fh: fh.write(json.dumps(record, ensure_ascii=False) + '\n') except Exception: @@ -636,23 +668,176 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N guard = guard_path_from_argv(argv) if guard is None or not guard.exists(): return None - verdict = run_guard(guard, command) - if verdict in (GUARD_BLOCKED, GUARD_CLEAN): - log_outcome(guard, tool, blocked=verdict == GUARD_BLOCKED) + verdict, message = run_guard_detailed(guard, command) + logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} + if verdict in logged: + log_outcome(guard, tool, verdict=logged[verdict]) if verdict == GUARD_BLOCKED: return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + if verdict == GUARD_ASKED: + return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') if verdict == GUARD_ERRORED: return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") return None +GATE_FLAG = '--gate' -def handle(event): +_GATE_TIMEOUT_SECONDS = 30 + +_GATE_DEPTH_TO_CHOCK = 3 + +_RUNNER_PARTS = ('bin', 'gate.py') + +_GIT = 'git' + +_DELETED = 'D' + +_RENAMED = 'R' + +GATE_BLOCKED = 'blocked' + +GATE_CLEAN = 'clean' + +GATE_ERRORED = 'errored' + +VERDICT_DENY = 'deny' + +def gate_path_from_argv(argv): + """The `--gate ` argument a vendored runtime was invoked with, or None.""" + if GATE_FLAG in argv: + index = argv.index(GATE_FLAG) + if index + 1 < len(argv): + return _chock_Path(argv[index + 1]) + return None + +def runner_for(gate): + """The vendored gate runner beside this compiled gate, or None when it is not there.""" + parents = gate.resolve().parents + if len(parents) <= _GATE_DEPTH_TO_CHOCK: + return None + runner = parents[_GATE_DEPTH_TO_CHOCK].joinpath(*_RUNNER_PARTS) + return runner if runner.exists() else None + +def writes_from_event(event): + """The one file this tool call would write, or none when it carries no file text.""" + path = getattr(event, 'path', None) + content = getattr(event, 'content', None) + if not path or not isinstance(content, str): + return {} + return {str(path): content} + +def changed_paths(repo_root): + """Every uncommitted path in the worktree. Outside a repository there is nothing to list.""" + try: + proc = _chock_subprocess.run([_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'], capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + except (OSError, _chock_subprocess.SubprocessError): + return [] + if proc.returncode != 0: + return [] + fields = [field for field in (proc.stdout or '').split('\x00') if field] + paths = [] + skip_next = False + for field in fields: + if skip_next: + skip_next = False + continue + status, path = (field[:2], field[3:]) + skip_next = status.startswith(_RENAMED) + if _DELETED in status or not path: + continue + paths.append(path) + return paths + +def writes_from_worktree(repo_root): + """What this turn actually left on disk, however it was written. + + The write path sees only writes it recognises; a shell heredoc carries no file argument. + Reading final state is what makes that stop mattering, so this deliberately does not care + which tool produced the bytes. + """ + writes = {} + for path in changed_paths(repo_root): + try: + writes[path] = _chock_Path(repo_root, path).read_text(encoding='utf-8') + except (OSError, UnicodeDecodeError): + continue + return writes + +def run_gate(gate, writes, event): + """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) + except (OSError, _chock_subprocess.SubprocessError) as exc: + return (GATE_ERRORED, str(exc)) + if proc.returncode == 0: + return (GATE_CLEAN, '') + if proc.returncode == 1: + return (GATE_BLOCKED, (proc.stderr or '').strip()) + return (GATE_ERRORED, (proc.stderr or '').strip()) + +_EVENT_ARG = {'pre_tool': 'pre-tool-use', 'stop': 'stop'} + +PRE_TOOL = 'pre_tool' + +def root_for(gate): + """The repository this compiled gate belongs to, or None when the layout is not that.""" + parents = gate.resolve().parents + if len(parents) <= _GATE_DEPTH_TO_CHOCK: + return None + return parents[_GATE_DEPTH_TO_CHOCK].parent + +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 {} + +def evaluate_gate(argv, event): + """The decision this event earns from a compiled gate, or None when it has nothing to say.""" + gate = gate_path_from_argv(argv) + name = _EVENT_ARG.get(getattr(event, 'event', '')) + if gate is None or name is None or (not gate.exists()): + return None + writes = writes_for(event, gate) + if not writes: + return None + outcome, message = run_gate(gate, writes, name) + if outcome == GATE_BLOCKED: + return (VERDICT_DENY, message or f'Blocked by chock policy: {gate.parent.parent.name}') + if outcome == GATE_ERRORED: + return (VERDICT_DENY, f'chock could not check this write: {message}. Refusing rather than reporting an allow it never established.') + return None + + +def _judge(event): if event.event == "pre_tool" and event.command: verdict = evaluate(sys.argv[1:], event.command, event.tool or "") if verdict is not None: outcome, reason = verdict return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + gated = evaluate_gate(sys.argv[1:], event) + if gated is not None: + return Decision.deny(gated[1]) return None + + +def handle(event): + # A door that cannot decide refuses. An exception escaping here would exit the hook + # with a traceback, which every client reads as a non-blocking error: fail-open, with + # the reason on a stderr nobody watches. The refusal carries the reason instead. + try: + return _judge(event) + except Exception as exc: # noqa: BLE001 -- any failure here must refuse, never fall through + return Decision.deny( + "chock could not check this call (%s: %s). Refusing rather than reporting an " + "allow it never established." % (type(exc).__name__, exc) + ) # <<< agentseam handler <<< diff --git a/.chock/compiled/block-invisible-unicode/pre-tool-use/gate.json b/.chock/compiled/block-invisible-unicode/pre-tool-use/gate.json new file mode 100644 index 0000000..a3b64d1 --- /dev/null +++ b/.chock/compiled/block-invisible-unicode/pre-tool-use/gate.json @@ -0,0 +1,14 @@ +{ + "kind": "content_regex", + "on": [ + "commit", + "tool_use" + ], + "action": "block", + "message": "Invisible or direction-override Unicode detected in this change. These characters change how code reads to a human or hide instructions an agent will still obey. Remove them. At commit, 'pragma: allowlist invisible-unicode' on the same line marks a documented exception (e.g. a test fixture); the pragma is NOT honored at tool-use, where the scanned text is a live tool argument an appended token could neutralize.", + "params": { + "scan": "added_lines", + "allowlist_pragma": "pragma:\\s*allowlist\\s+invisible-unicode", + "content_pattern": "[\\u202A-\\u202E\\u2066-\\u2069\\U000E0001-\\U000E007F]" + } +} \ No newline at end of file diff --git a/.chock/compiled/block-invisible-unicode/pre-tool-use/gemini_cli-write-hooks.json b/.chock/compiled/block-invisible-unicode/pre-tool-use/gemini_cli-write-hooks.json new file mode 100644 index 0000000..74b43fd --- /dev/null +++ b/.chock/compiled/block-invisible-unicode/pre-tool-use/gemini_cli-write-hooks.json @@ -0,0 +1,10 @@ +{ + "matcher": "write_file|replace", + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/gemini_cli.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/block-invisible-unicode/pre-tool-use/gate.json\"", + "timeout": 30 + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/block-invisible-unicode/pre-tool-use/pretooluse-write.json b/.chock/compiled/block-invisible-unicode/pre-tool-use/pretooluse-write.json new file mode 100644 index 0000000..bb3354d --- /dev/null +++ b/.chock/compiled/block-invisible-unicode/pre-tool-use/pretooluse-write.json @@ -0,0 +1,10 @@ +{ + "matcher": "Write|Edit|MultiEdit|NotebookEdit", + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/block-invisible-unicode/pre-tool-use/gate.json\"", + "timeout": 30 + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/block-invisible-unicode/stop/antigravity-hooks.json b/.chock/compiled/block-invisible-unicode/stop/antigravity-hooks.json new file mode 100644 index 0000000..c740146 --- /dev/null +++ b/.chock/compiled/block-invisible-unicode/stop/antigravity-hooks.json @@ -0,0 +1,14 @@ +{ + "agentseam": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/antigravity.py\" --gate \".chock/compiled/block-invisible-unicode/stop/gate.json\"" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-invisible-unicode/stop/codex_cli-hooks.json b/.chock/compiled/block-invisible-unicode/stop/codex_cli-hooks.json new file mode 100644 index 0000000..743b54b --- /dev/null +++ b/.chock/compiled/block-invisible-unicode/stop/codex_cli-hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/codex_cli.py\" --gate \".chock/compiled/block-invisible-unicode/stop/gate.json\"", + "commandWindows": "& @CHOCK_PYTHON@ \".chock/bin/codex_cli.py\" --gate \".chock/compiled/block-invisible-unicode/stop/gate.json\"" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-invisible-unicode/stop/devin-hooks.json b/.chock/compiled/block-invisible-unicode/stop/devin-hooks.json new file mode 100644 index 0000000..3b043c4 --- /dev/null +++ b/.chock/compiled/block-invisible-unicode/stop/devin-hooks.json @@ -0,0 +1,12 @@ +{ + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/devin.py\" --gate \".chock/compiled/block-invisible-unicode/stop/gate.json\"" + } + ] + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/block-invisible-unicode/stop/gate.json b/.chock/compiled/block-invisible-unicode/stop/gate.json new file mode 100644 index 0000000..a3b64d1 --- /dev/null +++ b/.chock/compiled/block-invisible-unicode/stop/gate.json @@ -0,0 +1,14 @@ +{ + "kind": "content_regex", + "on": [ + "commit", + "tool_use" + ], + "action": "block", + "message": "Invisible or direction-override Unicode detected in this change. These characters change how code reads to a human or hide instructions an agent will still obey. Remove them. At commit, 'pragma: allowlist invisible-unicode' on the same line marks a documented exception (e.g. a test fixture); the pragma is NOT honored at tool-use, where the scanned text is a live tool argument an appended token could neutralize.", + "params": { + "scan": "added_lines", + "allowlist_pragma": "pragma:\\s*allowlist\\s+invisible-unicode", + "content_pattern": "[\\u202A-\\u202E\\u2066-\\u2069\\U000E0001-\\U000E007F]" + } +} \ No newline at end of file diff --git a/.chock/compiled/block-invisible-unicode/stop/gemini_cli-hooks.json b/.chock/compiled/block-invisible-unicode/stop/gemini_cli-hooks.json new file mode 100644 index 0000000..d13adbf --- /dev/null +++ b/.chock/compiled/block-invisible-unicode/stop/gemini_cli-hooks.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "AfterAgent": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/gemini_cli.py\" --gate \".chock/compiled/block-invisible-unicode/stop/gate.json\"" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-invisible-unicode/stop/stop.json b/.chock/compiled/block-invisible-unicode/stop/stop.json new file mode 100644 index 0000000..6ce39e2 --- /dev/null +++ b/.chock/compiled/block-invisible-unicode/stop/stop.json @@ -0,0 +1,9 @@ +{ + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/block-invisible-unicode/stop/gate.json\"", + "timeout": 30 + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/block-invisible-unicode/stop/tabnine-hooks.json b/.chock/compiled/block-invisible-unicode/stop/tabnine-hooks.json new file mode 100644 index 0000000..567a0ee --- /dev/null +++ b/.chock/compiled/block-invisible-unicode/stop/tabnine-hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "AfterAgent": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/tabnine.py\" --gate \".chock/compiled/block-invisible-unicode/stop/gate.json\"", + "name": "agentseam" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-wildcard-agent-permissions/pre-tool-use/gate.json b/.chock/compiled/block-wildcard-agent-permissions/pre-tool-use/gate.json new file mode 100644 index 0000000..65c6e90 --- /dev/null +++ b/.chock/compiled/block-wildcard-agent-permissions/pre-tool-use/gate.json @@ -0,0 +1,14 @@ +{ + "kind": "content_regex", + "on": [ + "commit", + "tool_use" + ], + "action": "block", + "message": "Wildcard agent permission grant detected. Scope the grant to specific tools or commands (e.g. Bash(git status:*), a named tool list). At commit, 'pragma: allowlist broad-agency' on the same line marks a reviewed exception; the pragma is NOT honored at tool-use, where the scanned text is a live tool argument an appended token could neutralize.", + "params": { + "scan": "added_lines", + "allowlist_pragma": "pragma:\\s*allowlist\\s+broad-agency", + "content_pattern": "Bash\\([*]\\)|\"allow\"\\s*:\\s*\\[[^\\]]*\"\\*\"|\"alwaysAllow\"\\s*:\\s*\\[[^\\]]*\"\\*\"|\"tools\"\\s*:\\s*\\[[^\\]]*\"\\*\"|\"defaultMode\"\\s*:\\s*\"bypassPermissions\"" + } +} \ No newline at end of file diff --git a/.chock/compiled/block-wildcard-agent-permissions/pre-tool-use/gemini_cli-write-hooks.json b/.chock/compiled/block-wildcard-agent-permissions/pre-tool-use/gemini_cli-write-hooks.json new file mode 100644 index 0000000..50d0689 --- /dev/null +++ b/.chock/compiled/block-wildcard-agent-permissions/pre-tool-use/gemini_cli-write-hooks.json @@ -0,0 +1,10 @@ +{ + "matcher": "write_file|replace", + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/gemini_cli.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/block-wildcard-agent-permissions/pre-tool-use/gate.json\"", + "timeout": 30 + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/block-wildcard-agent-permissions/pre-tool-use/pretooluse-write.json b/.chock/compiled/block-wildcard-agent-permissions/pre-tool-use/pretooluse-write.json new file mode 100644 index 0000000..51d828c --- /dev/null +++ b/.chock/compiled/block-wildcard-agent-permissions/pre-tool-use/pretooluse-write.json @@ -0,0 +1,10 @@ +{ + "matcher": "Write|Edit|MultiEdit|NotebookEdit", + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/block-wildcard-agent-permissions/pre-tool-use/gate.json\"", + "timeout": 30 + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/block-wildcard-agent-permissions/stop/antigravity-hooks.json b/.chock/compiled/block-wildcard-agent-permissions/stop/antigravity-hooks.json new file mode 100644 index 0000000..4772383 --- /dev/null +++ b/.chock/compiled/block-wildcard-agent-permissions/stop/antigravity-hooks.json @@ -0,0 +1,14 @@ +{ + "agentseam": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/antigravity.py\" --gate \".chock/compiled/block-wildcard-agent-permissions/stop/gate.json\"" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-wildcard-agent-permissions/stop/codex_cli-hooks.json b/.chock/compiled/block-wildcard-agent-permissions/stop/codex_cli-hooks.json new file mode 100644 index 0000000..fef14ea --- /dev/null +++ b/.chock/compiled/block-wildcard-agent-permissions/stop/codex_cli-hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/codex_cli.py\" --gate \".chock/compiled/block-wildcard-agent-permissions/stop/gate.json\"", + "commandWindows": "& @CHOCK_PYTHON@ \".chock/bin/codex_cli.py\" --gate \".chock/compiled/block-wildcard-agent-permissions/stop/gate.json\"" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-wildcard-agent-permissions/stop/devin-hooks.json b/.chock/compiled/block-wildcard-agent-permissions/stop/devin-hooks.json new file mode 100644 index 0000000..21da800 --- /dev/null +++ b/.chock/compiled/block-wildcard-agent-permissions/stop/devin-hooks.json @@ -0,0 +1,12 @@ +{ + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/devin.py\" --gate \".chock/compiled/block-wildcard-agent-permissions/stop/gate.json\"" + } + ] + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/block-wildcard-agent-permissions/stop/gate.json b/.chock/compiled/block-wildcard-agent-permissions/stop/gate.json new file mode 100644 index 0000000..65c6e90 --- /dev/null +++ b/.chock/compiled/block-wildcard-agent-permissions/stop/gate.json @@ -0,0 +1,14 @@ +{ + "kind": "content_regex", + "on": [ + "commit", + "tool_use" + ], + "action": "block", + "message": "Wildcard agent permission grant detected. Scope the grant to specific tools or commands (e.g. Bash(git status:*), a named tool list). At commit, 'pragma: allowlist broad-agency' on the same line marks a reviewed exception; the pragma is NOT honored at tool-use, where the scanned text is a live tool argument an appended token could neutralize.", + "params": { + "scan": "added_lines", + "allowlist_pragma": "pragma:\\s*allowlist\\s+broad-agency", + "content_pattern": "Bash\\([*]\\)|\"allow\"\\s*:\\s*\\[[^\\]]*\"\\*\"|\"alwaysAllow\"\\s*:\\s*\\[[^\\]]*\"\\*\"|\"tools\"\\s*:\\s*\\[[^\\]]*\"\\*\"|\"defaultMode\"\\s*:\\s*\"bypassPermissions\"" + } +} \ No newline at end of file diff --git a/.chock/compiled/block-wildcard-agent-permissions/stop/gemini_cli-hooks.json b/.chock/compiled/block-wildcard-agent-permissions/stop/gemini_cli-hooks.json new file mode 100644 index 0000000..dd79fd2 --- /dev/null +++ b/.chock/compiled/block-wildcard-agent-permissions/stop/gemini_cli-hooks.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "AfterAgent": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/gemini_cli.py\" --gate \".chock/compiled/block-wildcard-agent-permissions/stop/gate.json\"" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-wildcard-agent-permissions/stop/stop.json b/.chock/compiled/block-wildcard-agent-permissions/stop/stop.json new file mode 100644 index 0000000..52f3c0d --- /dev/null +++ b/.chock/compiled/block-wildcard-agent-permissions/stop/stop.json @@ -0,0 +1,9 @@ +{ + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/block-wildcard-agent-permissions/stop/gate.json\"", + "timeout": 30 + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/block-wildcard-agent-permissions/stop/tabnine-hooks.json b/.chock/compiled/block-wildcard-agent-permissions/stop/tabnine-hooks.json new file mode 100644 index 0000000..3edc313 --- /dev/null +++ b/.chock/compiled/block-wildcard-agent-permissions/stop/tabnine-hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "AfterAgent": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/tabnine.py\" --gate \".chock/compiled/block-wildcard-agent-permissions/stop/gate.json\"", + "name": "agentseam" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/pin-github-actions/pre-tool-use/gate.json b/.chock/compiled/pin-github-actions/pre-tool-use/gate.json new file mode 100644 index 0000000..befd4f1 --- /dev/null +++ b/.chock/compiled/pin-github-actions/pre-tool-use/gate.json @@ -0,0 +1,14 @@ +{ + "kind": "content_regex", + "on": [ + "commit", + "tool_use" + ], + "action": "block", + "message": "Unpinned GitHub Action detected: a workflow references an action by a tag or branch (owner/repo at a movable ref) rather than a full 40-character commit SHA. Pin it to the SHA -- keep the version in a trailing comment for readability -- so a re-tagged or compromised release cannot change what runs. At commit, 'pragma: allowlist unpinned-action' on the same line marks a deliberate exception; the pragma is NOT honored at tool-use, where the scanned text is a live tool argument an appended token could neutralize.", + "params": { + "scan": "added_lines", + "allowlist_pragma": "pragma:\\s*allowlist\\s+unpinned-action", + "content_pattern": "use[s]:\\s*[\"']?[A-Za-z0-9._/-]+@(?![0-9a-fA-F]{40}(?:[\"'\\s#]|$))[A-Za-z0-9._/-]+" + } +} \ No newline at end of file diff --git a/.chock/compiled/pin-github-actions/pre-tool-use/gemini_cli-write-hooks.json b/.chock/compiled/pin-github-actions/pre-tool-use/gemini_cli-write-hooks.json new file mode 100644 index 0000000..74123ab --- /dev/null +++ b/.chock/compiled/pin-github-actions/pre-tool-use/gemini_cli-write-hooks.json @@ -0,0 +1,10 @@ +{ + "matcher": "write_file|replace", + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/gemini_cli.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", + "timeout": 30 + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/pin-github-actions/pre-tool-use/pretooluse-write.json b/.chock/compiled/pin-github-actions/pre-tool-use/pretooluse-write.json new file mode 100644 index 0000000..540fa80 --- /dev/null +++ b/.chock/compiled/pin-github-actions/pre-tool-use/pretooluse-write.json @@ -0,0 +1,10 @@ +{ + "matcher": "Write|Edit|MultiEdit|NotebookEdit", + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", + "timeout": 30 + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/pin-github-actions/stop/antigravity-hooks.json b/.chock/compiled/pin-github-actions/stop/antigravity-hooks.json new file mode 100644 index 0000000..658e55c --- /dev/null +++ b/.chock/compiled/pin-github-actions/stop/antigravity-hooks.json @@ -0,0 +1,14 @@ +{ + "agentseam": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/antigravity.py\" --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/pin-github-actions/stop/codex_cli-hooks.json b/.chock/compiled/pin-github-actions/stop/codex_cli-hooks.json new file mode 100644 index 0000000..5ae4a01 --- /dev/null +++ b/.chock/compiled/pin-github-actions/stop/codex_cli-hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/codex_cli.py\" --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", + "commandWindows": "& @CHOCK_PYTHON@ \".chock/bin/codex_cli.py\" --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/pin-github-actions/stop/devin-hooks.json b/.chock/compiled/pin-github-actions/stop/devin-hooks.json new file mode 100644 index 0000000..e7770bd --- /dev/null +++ b/.chock/compiled/pin-github-actions/stop/devin-hooks.json @@ -0,0 +1,12 @@ +{ + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/devin.py\" --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" + } + ] + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/pin-github-actions/stop/gate.json b/.chock/compiled/pin-github-actions/stop/gate.json new file mode 100644 index 0000000..befd4f1 --- /dev/null +++ b/.chock/compiled/pin-github-actions/stop/gate.json @@ -0,0 +1,14 @@ +{ + "kind": "content_regex", + "on": [ + "commit", + "tool_use" + ], + "action": "block", + "message": "Unpinned GitHub Action detected: a workflow references an action by a tag or branch (owner/repo at a movable ref) rather than a full 40-character commit SHA. Pin it to the SHA -- keep the version in a trailing comment for readability -- so a re-tagged or compromised release cannot change what runs. At commit, 'pragma: allowlist unpinned-action' on the same line marks a deliberate exception; the pragma is NOT honored at tool-use, where the scanned text is a live tool argument an appended token could neutralize.", + "params": { + "scan": "added_lines", + "allowlist_pragma": "pragma:\\s*allowlist\\s+unpinned-action", + "content_pattern": "use[s]:\\s*[\"']?[A-Za-z0-9._/-]+@(?![0-9a-fA-F]{40}(?:[\"'\\s#]|$))[A-Za-z0-9._/-]+" + } +} \ No newline at end of file diff --git a/.chock/compiled/pin-github-actions/stop/gemini_cli-hooks.json b/.chock/compiled/pin-github-actions/stop/gemini_cli-hooks.json new file mode 100644 index 0000000..8c1a1bc --- /dev/null +++ b/.chock/compiled/pin-github-actions/stop/gemini_cli-hooks.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "AfterAgent": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/gemini_cli.py\" --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/pin-github-actions/stop/stop.json b/.chock/compiled/pin-github-actions/stop/stop.json new file mode 100644 index 0000000..20879d5 --- /dev/null +++ b/.chock/compiled/pin-github-actions/stop/stop.json @@ -0,0 +1,9 @@ +{ + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/pin-github-actions/stop/gate.json\"", + "timeout": 30 + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/pin-github-actions/stop/tabnine-hooks.json b/.chock/compiled/pin-github-actions/stop/tabnine-hooks.json new file mode 100644 index 0000000..7383256 --- /dev/null +++ b/.chock/compiled/pin-github-actions/stop/tabnine-hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "AfterAgent": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/tabnine.py\" --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", + "name": "agentseam" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/scan-secrets/pre-tool-use/gate.json b/.chock/compiled/scan-secrets/pre-tool-use/gate.json new file mode 100644 index 0000000..1611c29 --- /dev/null +++ b/.chock/compiled/scan-secrets/pre-tool-use/gate.json @@ -0,0 +1,15 @@ +{ + "kind": "content_regex", + "on": [ + "commit", + "tool_use" + ], + "action": "block", + "message": "Potential secret detected in this change. Remove credentials and rotate any exposed keys. At commit, add '# pragma: allowlist secret' on the same line only for documented test fixtures; the pragma is NOT honored at tool-use, where the scanned text is a live tool argument an appended token could neutralize.", + "params": { + "scan": "added_lines", + "forbidden_path_regex": "(\\.env(\\.(?!(sample|example|template|dist|defaults)$)[^/]+)?|\\.(pem|key|p12|pfx|jks|keystore))$", + "allowlist_pragma": "#\\s*pragma:\\s*allowlist\\s+secret", + "content_pattern": "(?i)(AKIA[0-9A-Z]{16}|ghp_[0-9A-Za-z]{36}|ghs_[0-9A-Za-z]{36}|github_pat_[0-9A-Za-z_]{22,}|xox[bpas]-[0-9A-Za-z-]{10,}|(sk|rk)_live_[0-9A-Za-z]{16,}|sk-proj-[0-9A-Za-z_-]{20,}|sk-ant-[0-9A-Za-z_-]{20,}|AIza[0-9A-Za-z_-]{35}|npm_[0-9A-Za-z]{36}|SG\\.[0-9A-Za-z_-]{16,}\\.[0-9A-Za-z_-]{16,}|eyJ[A-Za-z0-9_-]*\\.[A-Za-z0-9_-]*\\.[A-Za-z0-9_-]*|-----BEGIN (RSA |OPENSSH |EC |DSA )?PRIVATE KEY-----|api[_-]?key\\s*=\\s*[\"'][A-Za-z0-9_\\-]{20,}[\"']|secret[_-]?key\\s*=\\s*[\"'][A-Za-z0-9_\\-]{20,}[\"']|auth[_-]?token\\s*=\\s*[\"'][A-Za-z0-9_\\-]{20,}[\"']|password\\s*=\\s*[\"'][^\"'\\s]{12,}[\"']|(api|secret|auth)[_-]?(key|token)\\s*[=:]\\s*[A-Za-z0-9_\\-]{20,}|password\\s*[=:]\\s*[^\\s\"'${}]{12,})" + } +} \ No newline at end of file diff --git a/.chock/compiled/scan-secrets/pre-tool-use/gemini_cli-write-hooks.json b/.chock/compiled/scan-secrets/pre-tool-use/gemini_cli-write-hooks.json new file mode 100644 index 0000000..8f25619 --- /dev/null +++ b/.chock/compiled/scan-secrets/pre-tool-use/gemini_cli-write-hooks.json @@ -0,0 +1,10 @@ +{ + "matcher": "write_file|replace", + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/gemini_cli.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/scan-secrets/pre-tool-use/gate.json\"", + "timeout": 30 + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/scan-secrets/pre-tool-use/pretooluse-write.json b/.chock/compiled/scan-secrets/pre-tool-use/pretooluse-write.json new file mode 100644 index 0000000..bd00ac4 --- /dev/null +++ b/.chock/compiled/scan-secrets/pre-tool-use/pretooluse-write.json @@ -0,0 +1,10 @@ +{ + "matcher": "Write|Edit|MultiEdit|NotebookEdit", + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/scan-secrets/pre-tool-use/gate.json\"", + "timeout": 30 + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/scan-secrets/stop/antigravity-hooks.json b/.chock/compiled/scan-secrets/stop/antigravity-hooks.json new file mode 100644 index 0000000..f67b150 --- /dev/null +++ b/.chock/compiled/scan-secrets/stop/antigravity-hooks.json @@ -0,0 +1,14 @@ +{ + "agentseam": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/antigravity.py\" --gate \".chock/compiled/scan-secrets/stop/gate.json\"" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/scan-secrets/stop/codex_cli-hooks.json b/.chock/compiled/scan-secrets/stop/codex_cli-hooks.json new file mode 100644 index 0000000..ef10211 --- /dev/null +++ b/.chock/compiled/scan-secrets/stop/codex_cli-hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/codex_cli.py\" --gate \".chock/compiled/scan-secrets/stop/gate.json\"", + "commandWindows": "& @CHOCK_PYTHON@ \".chock/bin/codex_cli.py\" --gate \".chock/compiled/scan-secrets/stop/gate.json\"" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/scan-secrets/stop/devin-hooks.json b/.chock/compiled/scan-secrets/stop/devin-hooks.json new file mode 100644 index 0000000..135aca4 --- /dev/null +++ b/.chock/compiled/scan-secrets/stop/devin-hooks.json @@ -0,0 +1,12 @@ +{ + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/devin.py\" --gate \".chock/compiled/scan-secrets/stop/gate.json\"" + } + ] + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/scan-secrets/stop/gate.json b/.chock/compiled/scan-secrets/stop/gate.json new file mode 100644 index 0000000..1611c29 --- /dev/null +++ b/.chock/compiled/scan-secrets/stop/gate.json @@ -0,0 +1,15 @@ +{ + "kind": "content_regex", + "on": [ + "commit", + "tool_use" + ], + "action": "block", + "message": "Potential secret detected in this change. Remove credentials and rotate any exposed keys. At commit, add '# pragma: allowlist secret' on the same line only for documented test fixtures; the pragma is NOT honored at tool-use, where the scanned text is a live tool argument an appended token could neutralize.", + "params": { + "scan": "added_lines", + "forbidden_path_regex": "(\\.env(\\.(?!(sample|example|template|dist|defaults)$)[^/]+)?|\\.(pem|key|p12|pfx|jks|keystore))$", + "allowlist_pragma": "#\\s*pragma:\\s*allowlist\\s+secret", + "content_pattern": "(?i)(AKIA[0-9A-Z]{16}|ghp_[0-9A-Za-z]{36}|ghs_[0-9A-Za-z]{36}|github_pat_[0-9A-Za-z_]{22,}|xox[bpas]-[0-9A-Za-z-]{10,}|(sk|rk)_live_[0-9A-Za-z]{16,}|sk-proj-[0-9A-Za-z_-]{20,}|sk-ant-[0-9A-Za-z_-]{20,}|AIza[0-9A-Za-z_-]{35}|npm_[0-9A-Za-z]{36}|SG\\.[0-9A-Za-z_-]{16,}\\.[0-9A-Za-z_-]{16,}|eyJ[A-Za-z0-9_-]*\\.[A-Za-z0-9_-]*\\.[A-Za-z0-9_-]*|-----BEGIN (RSA |OPENSSH |EC |DSA )?PRIVATE KEY-----|api[_-]?key\\s*=\\s*[\"'][A-Za-z0-9_\\-]{20,}[\"']|secret[_-]?key\\s*=\\s*[\"'][A-Za-z0-9_\\-]{20,}[\"']|auth[_-]?token\\s*=\\s*[\"'][A-Za-z0-9_\\-]{20,}[\"']|password\\s*=\\s*[\"'][^\"'\\s]{12,}[\"']|(api|secret|auth)[_-]?(key|token)\\s*[=:]\\s*[A-Za-z0-9_\\-]{20,}|password\\s*[=:]\\s*[^\\s\"'${}]{12,})" + } +} \ No newline at end of file diff --git a/.chock/compiled/scan-secrets/stop/gemini_cli-hooks.json b/.chock/compiled/scan-secrets/stop/gemini_cli-hooks.json new file mode 100644 index 0000000..c94647e --- /dev/null +++ b/.chock/compiled/scan-secrets/stop/gemini_cli-hooks.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "AfterAgent": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/gemini_cli.py\" --gate \".chock/compiled/scan-secrets/stop/gate.json\"" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/scan-secrets/stop/stop.json b/.chock/compiled/scan-secrets/stop/stop.json new file mode 100644 index 0000000..c4dd588 --- /dev/null +++ b/.chock/compiled/scan-secrets/stop/stop.json @@ -0,0 +1,9 @@ +{ + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/scan-secrets/stop/gate.json\"", + "timeout": 30 + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/scan-secrets/stop/tabnine-hooks.json b/.chock/compiled/scan-secrets/stop/tabnine-hooks.json new file mode 100644 index 0000000..3ba3a42 --- /dev/null +++ b/.chock/compiled/scan-secrets/stop/tabnine-hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "AfterAgent": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/tabnine.py\" --gate \".chock/compiled/scan-secrets/stop/gate.json\"", + "name": "agentseam" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/coverage.json b/.chock/coverage.json index 331c274..3c94856 100644 --- a/.chock/coverage.json +++ b/.chock/coverage.json @@ -207,13 +207,13 @@ "witnessed": false }, "claude": { - "level": "enforced-at-commit", - "basis": null, + "level": "best-effort", + "basis": "live-run", "witnessed": false }, "codex": { - "level": "enforced-at-commit", - "basis": null, + "level": "best-effort", + "basis": "live-run-partial", "witnessed": false }, "copilot": { @@ -227,13 +227,13 @@ "witnessed": false }, "devin": { - "level": "enforced-at-commit", - "basis": null, + "level": "best-effort", + "basis": "vendor-docs", "witnessed": false }, "gemini": { - "level": "enforced-at-commit", - "basis": null, + "level": "best-effort", + "basis": "vendor-source", "witnessed": false }, "grok": { @@ -252,8 +252,8 @@ "witnessed": false }, "tabnine": { - "level": "enforced-at-commit", - "basis": null, + "level": "best-effort", + "basis": "vendor-docs", "witnessed": false }, "vscode": { @@ -408,13 +408,13 @@ "witnessed": false }, "claude": { - "level": "enforced-at-commit", - "basis": null, + "level": "best-effort", + "basis": "live-run", "witnessed": false }, "codex": { - "level": "enforced-at-commit", - "basis": null, + "level": "best-effort", + "basis": "live-run-partial", "witnessed": false }, "copilot": { @@ -428,13 +428,13 @@ "witnessed": false }, "devin": { - "level": "enforced-at-commit", - "basis": null, + "level": "best-effort", + "basis": "vendor-docs", "witnessed": false }, "gemini": { - "level": "enforced-at-commit", - "basis": null, + "level": "best-effort", + "basis": "vendor-source", "witnessed": false }, "grok": { @@ -453,8 +453,8 @@ "witnessed": false }, "tabnine": { - "level": "enforced-at-commit", - "basis": null, + "level": "best-effort", + "basis": "vendor-docs", "witnessed": false }, "vscode": { @@ -877,13 +877,13 @@ "witnessed": false }, "claude": { - "level": "enforced-at-commit", - "basis": null, + "level": "best-effort", + "basis": "live-run", "witnessed": false }, "codex": { - "level": "enforced-at-commit", - "basis": null, + "level": "best-effort", + "basis": "live-run-partial", "witnessed": false }, "copilot": { @@ -897,13 +897,13 @@ "witnessed": false }, "devin": { - "level": "enforced-at-commit", - "basis": null, + "level": "best-effort", + "basis": "vendor-docs", "witnessed": false }, "gemini": { - "level": "enforced-at-commit", - "basis": null, + "level": "best-effort", + "basis": "vendor-source", "witnessed": false }, "grok": { @@ -922,8 +922,8 @@ "witnessed": false }, "tabnine": { - "level": "enforced-at-commit", - "basis": null, + "level": "best-effort", + "basis": "vendor-docs", "witnessed": false }, "vscode": { @@ -1212,13 +1212,13 @@ "witnessed": false }, "claude": { - "level": "enforced-at-commit", - "basis": null, + "level": "best-effort", + "basis": "live-run", "witnessed": false }, "codex": { - "level": "enforced-at-commit", - "basis": null, + "level": "best-effort", + "basis": "live-run-partial", "witnessed": false }, "copilot": { @@ -1232,13 +1232,13 @@ "witnessed": false }, "devin": { - "level": "enforced-at-commit", - "basis": null, + "level": "best-effort", + "basis": "vendor-docs", "witnessed": false }, "gemini": { - "level": "enforced-at-commit", - "basis": null, + "level": "best-effort", + "basis": "vendor-source", "witnessed": false }, "grok": { @@ -1257,8 +1257,8 @@ "witnessed": false }, "tabnine": { - "level": "enforced-at-commit", - "basis": null, + "level": "best-effort", + "basis": "vendor-docs", "witnessed": false }, "vscode": { diff --git a/.claude/settings.json b/.claude/settings.json index d5a0bc7..76be382 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -32,6 +32,16 @@ } ] }, + { + "matcher": "Write|Edit|MultiEdit|NotebookEdit", + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python3\" \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/block-invisible-unicode/pre-tool-use/gate.json\"", + "timeout": 30 + } + ] + }, { "matcher": "Bash", "hooks": [ @@ -52,6 +62,26 @@ } ] }, + { + "matcher": "Write|Edit|MultiEdit|NotebookEdit", + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python3\" \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/block-wildcard-agent-permissions/pre-tool-use/gate.json\"", + "timeout": 30 + } + ] + }, + { + "matcher": "Write|Edit|MultiEdit|NotebookEdit", + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python3\" \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", + "timeout": 30 + } + ] + }, { "matcher": "Bash", "hooks": [ @@ -81,6 +111,54 @@ "timeout": 30 } ] + }, + { + "matcher": "Write|Edit|MultiEdit|NotebookEdit", + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python3\" \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/scan-secrets/pre-tool-use/gate.json\"", + "timeout": 30 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python3\" \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/block-invisible-unicode/stop/gate.json\"", + "timeout": 30 + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python3\" \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/block-wildcard-agent-permissions/stop/gate.json\"", + "timeout": 30 + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python3\" \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/pin-github-actions/stop/gate.json\"", + "timeout": 30 + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python3\" \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/scan-secrets/stop/gate.json\"", + "timeout": 30 + } + ] } ] } diff --git a/.codex/hooks.json b/.codex/hooks.json index 25ddda7..93a8d54 100644 --- a/.codex/hooks.json +++ b/.codex/hooks.json @@ -71,6 +71,44 @@ ], "matcher": "Bash" } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python3\" \".chock/bin/codex_cli.py\" --gate \".chock/compiled/block-invisible-unicode/stop/gate.json\"", + "commandWindows": "& \"/usr/local/bin/python3\" \".chock/bin/codex_cli.py\" --gate \".chock/compiled/block-invisible-unicode/stop/gate.json\"" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python3\" \".chock/bin/codex_cli.py\" --gate \".chock/compiled/block-wildcard-agent-permissions/stop/gate.json\"", + "commandWindows": "& \"/usr/local/bin/python3\" \".chock/bin/codex_cli.py\" --gate \".chock/compiled/block-wildcard-agent-permissions/stop/gate.json\"" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python3\" \".chock/bin/codex_cli.py\" --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", + "commandWindows": "& \"/usr/local/bin/python3\" \".chock/bin/codex_cli.py\" --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python3\" \".chock/bin/codex_cli.py\" --gate \".chock/compiled/scan-secrets/stop/gate.json\"", + "commandWindows": "& \"/usr/local/bin/python3\" \".chock/bin/codex_cli.py\" --gate \".chock/compiled/scan-secrets/stop/gate.json\"" + } + ] + } ] } } \ No newline at end of file diff --git a/.devin/hooks.v1.json b/.devin/hooks.v1.json index eef2704..5382bcd 100644 --- a/.devin/hooks.v1.json +++ b/.devin/hooks.v1.json @@ -56,5 +56,39 @@ } ] } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python3\" \".chock/bin/devin.py\" --gate \".chock/compiled/block-invisible-unicode/stop/gate.json\"" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python3\" \".chock/bin/devin.py\" --gate \".chock/compiled/block-wildcard-agent-permissions/stop/gate.json\"" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python3\" \".chock/bin/devin.py\" --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python3\" \".chock/bin/devin.py\" --gate \".chock/compiled/scan-secrets/stop/gate.json\"" + } + ] + } ] } \ No newline at end of file diff --git a/.framework-ref b/.framework-ref index b19b521..f979ade 100644 --- a/.framework-ref +++ b/.framework-ref @@ -1 +1 @@ -v0.8.0 +v0.9.0 diff --git a/.gemini/settings.json b/.gemini/settings.json index b10db5c..6868e72 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -64,6 +64,40 @@ ], "matcher": "run_shell_command" } + ], + "AfterAgent": [ + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python3\" \".chock/bin/gemini_cli.py\" --gate \".chock/compiled/block-invisible-unicode/stop/gate.json\"" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python3\" \".chock/bin/gemini_cli.py\" --gate \".chock/compiled/block-wildcard-agent-permissions/stop/gate.json\"" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python3\" \".chock/bin/gemini_cli.py\" --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python3\" \".chock/bin/gemini_cli.py\" --gate \".chock/compiled/scan-secrets/stop/gate.json\"" + } + ] + } ] } } \ No newline at end of file diff --git a/.tabnine/agent/settings.json b/.tabnine/agent/settings.json index 6fda301..b411e65 100644 --- a/.tabnine/agent/settings.json +++ b/.tabnine/agent/settings.json @@ -64,6 +64,44 @@ } ] } + ], + "AfterAgent": [ + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python3\" \".chock/bin/tabnine.py\" --gate \".chock/compiled/block-invisible-unicode/stop/gate.json\"", + "name": "agentseam" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python3\" \".chock/bin/tabnine.py\" --gate \".chock/compiled/block-wildcard-agent-permissions/stop/gate.json\"", + "name": "agentseam" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python3\" \".chock/bin/tabnine.py\" --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", + "name": "agentseam" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python3\" \".chock/bin/tabnine.py\" --gate \".chock/compiled/scan-secrets/stop/gate.json\"", + "name": "agentseam" + } + ] + } ] } } \ No newline at end of file diff --git a/chock.lock b/chock.lock index dbec396..c5353bf 100644 --- a/chock.lock +++ b/chock.lock @@ -32,7 +32,7 @@ "managed": false, "sha256": "3bc00c998d3691f7288fa6c540f6a4ab8fd20aac7b32630d501fb4f28ed507c2", "source": "local", - "artifacts_sha256": "55029605c05c2320fbf5c9e1b2ccb2fc612269ac2b32e5ea04fec228c99d11fc" + "artifacts_sha256": "014076fafdba8ede13f12634d63d26a27e3a79d216ec34efebe15888fb15a475" }, { "id": "block-no-verify", @@ -56,7 +56,7 @@ "managed": false, "sha256": "e12230f761737de3c304f99322e45a5fde8221387e5d4299d43a601ba6f87e0d", "source": "local", - "artifacts_sha256": "662e08e9775b0766aa3ff318091a2e34a5cbfdb17f5fd2da343aec7cb99d1d28" + "artifacts_sha256": "67c7775e90667c7f1c7ca09455a9354a4c6c4cae96736604c2f2fb9f7e4d2c97" }, { "id": "code-safety", @@ -112,7 +112,7 @@ "managed": false, "sha256": "241a90e41349ab8f6c48e0f8c2445c3866876d5aa858c096dc9511448dfb8f58", "source": "local", - "artifacts_sha256": "8cfa8bf1186480b72b4cbf1b874ef988c1dad83fd75e796da2a1d07fb8b9ab39" + "artifacts_sha256": "fa4c620385db9abcd5b639e2dc33b01e406517b76f4233c11d5168f995da3819" }, { "id": "protect-agent-config", @@ -152,7 +152,7 @@ "managed": false, "sha256": "6f8285363e7795bf2cedb78a449b7f5b49d7b188047a7802c12fbdc7f63cad4b", "source": "local", - "artifacts_sha256": "e4d17797bc89a04483d2bfe1a1d39221ae06ff23be506c572690136c4e5d7c71" + "artifacts_sha256": "2630ba912e7cb676e2bfd5e1bdbe94e6cb849fc4cb845461dca7bbc3f351b34f" }, { "id": "token-efficiency", diff --git a/requirements/chock.in b/requirements/chock.in index 6cba060..6927bff 100644 --- a/requirements/chock.in +++ b/requirements/chock.in @@ -1,5 +1,5 @@ -# chock v0.8.0 runtime dependencies (its pyproject at that tag); chock itself installs --no-deps. +# chock v0.9.0 runtime dependencies (its pyproject at that tag); chock itself installs --no-deps. pyyaml>=6.0 jsonschema>=4.18,<5 referencing>=0.35,<0.38 -agentseam==0.2.1 +agentseam==0.3.0 diff --git a/requirements/chock.txt b/requirements/chock.txt index 1791554..ca43171 100644 --- a/requirements/chock.txt +++ b/requirements/chock.txt @@ -1,8 +1,8 @@ # Generated by pip-compile --generate-hashes from requirements/chock.in; regenerate with # pip-compile --generate-hashes --allow-unsafe --strip-extras -o requirements/chock.txt requirements/chock.in -agentseam==0.2.1 \ - --hash=sha256:22b322c7b48229fb8db5525b2f3c30894d455a4b9a66dbb24c98dce861ea1940 \ - --hash=sha256:b9371bf4d6d95ad73a47a26303324fc1344f0701518f4126d95da334471a7fd7 +agentseam==0.3.0 \ + --hash=sha256:04ceaca8671b7720bb174106232bf3d2a6a2eb08fcae2c02cc27274333f275da \ + --hash=sha256:6efa82835bfb8365ad1687dd00fa7e18326b8d9153764444ccb697d807e77d3b # via -r requirements/chock.in attrs==26.1.0 \ --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \