From bd978c275787191c3254f59c7f4583a1c0e3deba Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:30:35 +0000 Subject: [PATCH 1/3] ci: add the CI / check workflow the release check reads (REL-1c) chock-release check 2 reads ci.yml job `check` on every pinner repo and refuses the release when it is absent. This repo had no workflow. The job installs chock from the single pin in README.md (refusing when there is none or more than one distinct pin), runs chock check (with --base on pull requests) and chock sync --check. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- .github/workflows/ci.yml | 51 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..287cce4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,51 @@ +# The release check (chock-release, check 2) reads this file's job `check`: keep the name. +# chock is installed from the pin in README.md so the README stays the single source. +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + fetch-depth: 0 + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: "3.12" + + - name: Install chock from the README pin + run: | + pins="$(grep -E -o 'pip install "chock @ git\+https://github\.com/open-coder-ai/chock@[0-9a-f]{40}"' README.md | sort -u)" + if [ -z "$pins" ]; then + echo "README.md has no 'pip install \"chock @ git+https://github.com/open-coder-ai/chock@<40-hex>\"' line" >&2 + exit 1 + fi + if [ "$(printf '%s\n' "$pins" | wc -l)" -ne 1 ]; then + echo "README.md carries more than one distinct chock pin:" >&2 + printf '%s\n' "$pins" >&2 + exit 1 + fi + bash -e -c "$pins" + + - name: chock check (pull request) + if: github.event_name == 'pull_request' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: chock check --repo . --base "$BASE_SHA" + + - name: chock check (push) + if: github.event_name != 'pull_request' + run: chock check --repo . + + - name: Compiled files match the policies + run: chock sync --repo . --check From 1f0d30400271dcc1a6f29b1033237f61e0c1981e Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:34:18 -0400 Subject: [PATCH 2/3] regen: compiled tree under the README's engine pin Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- .agents/policies/INDEX.md | 2 +- .chock/bin/claude_code.py | 1451 ++++++++++++++-- .chock/bin/gate.py | 1103 +++++++++++- .chock/bin/gemini_cli.py | 1421 +++++++++++++-- .chock/bin/launch.sh | 21 + .chock/bin/vscode_copilot.py | 1543 +++++++++++++++-- .../agent-hooks/agent-hooks.json | 12 +- .../pre-tool-use/antigravity-hooks.json | 3 +- .../pre-tool-use/codex_cli-hooks.json | 5 +- .../pre-tool-use/cursor-hooks.json | 5 +- .../pre-tool-use/devin-hooks.json | 3 +- .../pre-tool-use/gemini_cli-hooks.json | 3 +- .../pre-tool-use/grok-hooks.json | 3 +- .../pre-tool-use/pretooluse.json | 6 +- .../pre-tool-use/tabnine-hooks.json | 5 +- .../pre-tool-use/windsurf-hooks.json | 4 +- .chock/coverage.json | 18 +- 17 files changed, 5205 insertions(+), 403 deletions(-) create mode 100644 .chock/bin/launch.sh diff --git a/.agents/policies/INDEX.md b/.agents/policies/INDEX.md index e01f26c..3f599cf 100644 --- a/.agents/policies/INDEX.md +++ b/.agents/policies/INDEX.md @@ -8,7 +8,7 @@ never(commit|push): --no-verify|-n if(hook_fails): fix_issue; never(skip_hook) -## Gates — enforced automatically at commit/push +## Gates — enforced automatically at commit/push, and in the agent where noted - **protect-main-branch**: Direct commits/pushes to a protected branch (main|master) are blocked. Create a feature branch and open a pull request. diff --git a/.chock/bin/claude_code.py b/.chock/bin/claude_code.py index e4c6207..41a7f48 100755 --- a/.chock/bin/claude_code.py +++ b/.chock/bin/claude_code.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("claude_code"). Do not hand-edit, except the +# Generated by agentseam 0.3.5 -- 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. # @@ -9,21 +9,29 @@ from __future__ import annotations +import contextlib +import io import json _json = json +import os import sys +import hashlib as _chock_hashlib +import fnmatch as _chock_fnmatch +import re as _chock_re import os as _chock_os import shlex as _chock_shlex import shutil as _chock_shutil import subprocess as _chock_subprocess +import time as _chock_time from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.5) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -201,7 +209,7 @@ def tool_input_of(raw): """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" if isinstance(raw, dict): return raw - if isinstance(raw, str) and raw[:1] == "{": + if isinstance(raw, str) and raw.lstrip()[:1] == "{": try: parsed = _json.loads(raw) except (ValueError, RecursionError): @@ -562,6 +570,8 @@ def hj_respond(cfg, decision, event, wire=None): _WINDOWS_KEYS = ("commandWindows", "windows") +_MATCHER_EVENTS = (PRE_TOOL, POST_TOOL, TOOL_FAILURE) + def _hook_dict(cfg, command): entry = {"type": "command", "command": command} for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): @@ -578,20 +588,21 @@ def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): if not name: continue rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: rule["matcher"] = matcher rules.append(rule) return rules def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): gates = cfg["verdicts"]["answer_events"] + allow_silent = cfg["verdicts"].get("allow_silent_events", ()) hooks = {} for ev in canonical_events: name = reverse.get(ev) if not name: continue entry = {"command": command} - if fail_closed and name in gates: + if fail_closed and name in gates and name not in allow_silent: entry["failClosed"] = True hooks.setdefault(name, []).append(entry) return {"version": 1, "hooks": hooks} @@ -616,7 +627,7 @@ def _default_wrapper(cfg, reverse, canonical_events, command, matcher): if not name: continue entry = {"hooks": [_hook_dict(cfg, command)]} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: entry["matcher"] = matcher hooks.setdefault(name, []).append(entry) if hook_entry.get("group"): @@ -646,7 +657,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-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'}}} +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': 'vendor-docs', 'date': '2026-09-27', 'test': 'tests/test_adapter_claude_code.py::test_powershell_is_a_shell_tool_and_its_command_is_parsed'}, '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', 'PowerShell'), '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): @@ -697,19 +708,60 @@ def degrade(decision, event): # >>> agentseam handler >>> +ENGINE_BUDGET_SECONDS = 30 + +def engine_deadline(): + """The monotonic instant this invocation's budget runs out.""" + return _chock_time.monotonic() + ENGINE_BUDGET_SECONDS + +def engine_remaining(deadline): + """Seconds left before `deadline`, never negative.""" + return max(deadline - _chock_time.monotonic(), 0.0) + +def time_left(deadline, cmd): + """Seconds left for a subprocess; raises TimeoutExpired when none are, so the caller's timeout path runs.""" + left = deadline - _chock_time.monotonic() + if left <= 0: + raise _chock_subprocess.TimeoutExpired(cmd, 0) + return left + +def allowed_seconds(expired): + """The time a timed-out call was actually given, as text (what the deadline left it, not the full budget).""" + return f'{max(float(expired.timeout or 0), 0.0):.1f}s' + 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') +_CRASH_MARKERS = ('traceback (most recent call last)', 'syntax error', 'syntaxerror', 'unexpected eof') + +TOOL_UNKNOWN = 'unknown' + +ARGV_FALLBACK_ENV = 'CHOCK_ARGV_FALLBACK' + +_POSIX_BASH = ('bash', '/bin/bash', '/usr/bin/bash') + +_GIT_FOR_WINDOWS = ('C:\\Program Files\\Git', 'C:\\Program Files (x86)\\Git') + +_GIT_BASH_DIRS = (('bin',), ('usr', 'bin')) + +_BASH_EXE = 'bash.exe' + +_WINDOWS_STUB_DIRS = ('/system32/', '/windowsapps/') + +_COREUTILS_MARKER = 'sed.exe' + +_WINDOWS = 'nt' + +_FOUND_BASH = {} GATE_LOG_ENV = 'CHOCK_GATE_LOG' _LOG_MAX_BYTES = 1048576 -_GUARD_TIMEOUT_SECONDS = 30 +_BASH_PROBE_SECONDS = 10 GUARD_BLOCKED = 'blocked' @@ -733,45 +785,109 @@ def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: return _chock_Path(argv[i + 1]) return None -def find_bash(guard: _chock_Path) -> str | None: - """First interpreter that can actually see `guard`, or None.""" - for candidate in _BASH_CANDIDATES: +def _git_roots() -> list[_chock_Path]: + """Git for Windows install roots: from `git` on PATH (cmd/ or mingw64/bin/), then the defaults.""" + git = _chock_shutil.which('git') + near = [_chock_Path(git).parent.parent, _chock_Path(git).parent.parent.parent] if git else [] + return [*near, *(_chock_Path(root) for root in _GIT_FOR_WINDOWS)] + +def bash_candidates() -> list[str]: + """Bash interpreters to try, best first; on Windows Git's own, never the WSL launcher.""" + if _chock_os.name != _WINDOWS: + return list(_POSIX_BASH) + found = [str(root.joinpath(*sub, _BASH_EXE)) for root in _git_roots() for sub in _GIT_BASH_DIRS] + on_path = _chock_shutil.which('bash') + if on_path and (not any((stub in on_path.lower().replace('\\', '/') for stub in _WINDOWS_STUB_DIRS))): + found.append(on_path) + return [c for i, c in enumerate(found) if c not in found[:i] and _chock_Path(c).is_file()] + +def interpreter_env(interpreter: str) -> dict[str, str]: + """The environment a guard runs in: on Windows, Git's usr/bin ahead on PATH for sed and grep.""" + env = dict(_chock_os.environ) + if _chock_os.name != _WINDOWS: + return env + home = _chock_Path(interpreter).parent + usr_bin = next((d for d in (home.parent / 'usr' / 'bin', home) if (d / _COREUTILS_MARKER).is_file()), None) + if usr_bin is not None: + env['PATH'] = str(usr_bin) + _chock_os.pathsep + env.get('PATH', '') + return env + +def find_bash(guard: _chock_Path, deadline: float | None=None) -> str | None: + """First bash that can actually see `guard`, probed once per process; the probes draw on `deadline`.""" + if 'bash' in _FOUND_BASH: + return _FOUND_BASH['bash'] + for candidate in bash_candidates(): + argv = [candidate, '-c', f'test -f "{guard.as_posix()}"'] try: - proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10, check=False) + limit = _BASH_PROBE_SECONDS if deadline is None else min(_BASH_PROBE_SECONDS, time_left(deadline, argv)) + proc = _chock_subprocess.run(argv, capture_output=True, timeout=limit, check=False) except (OSError, _chock_subprocess.SubprocessError): + if deadline is not None and engine_remaining(deadline) <= 0: + return None continue if proc.returncode == 0: + _FOUND_BASH['bash'] = candidate return candidate return None -def find_interpreter(guard: _chock_Path) -> str | None: +def find_interpreter(guard: _chock_Path, deadline: float | None=None) -> 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) + return find_bash(guard, deadline) + +def normalize_tool(tool: str | None) -> str: + """The hook payload's tool name as `bash` | `powershell` | `shell` | `unknown`.""" + name = (tool or '').lower() + if name in ('powershell', 'pwsh'): + return 'powershell' + if name == 'bash': + return 'bash' + if name == 'sh' or 'shell' in name: + return 'shell' + return TOOL_UNKNOWN + +def is_guard_crash(output: str) -> bool: + """True when a guard's exit-1 output is empty or shows an interpreter crash, not a refusal.""" + lowered = output.lower() + return not lowered or any((marker in lowered for marker in _CRASH_MARKERS)) + +def split_command(command: str) -> tuple[list[str], bool]: + """POSIX `shlex.split` argv, else a whitespace split; the flag is True on the fallback.""" + try: + return (_chock_shlex.split(command), False) + except ValueError: + return (command.split(), True) -def run_guard(guard: _chock_Path, command: str) -> str: +def run_guard(guard: _chock_Path, command: str, tool: str='') -> str: """`GUARD_BLOCKED` / `GUARD_ASKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" - return run_guard_detailed(guard, command)[0] + return run_guard_detailed(guard, command, tool)[0] + +def _no_interpreter(guard: _chock_Path, deadline: float) -> tuple[str, str]: + """The ask a guard earns when no shell could run it: the budget ran out probing, or none can see it.""" + if engine_remaining(deadline) <= 0: + print(f'chock: guard timed out after {ENGINE_BUDGET_SECONDS}s finding a shell, not checked', file=sys.stderr) + return (GUARD_ERRORED, '') + reason = f'no usable bash was found to run {guard.name}; on Windows install Git for Windows (it ships bash), elsewhere put bash on PATH' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) -def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: +def run_guard_detailed(guard: _chock_Path, command: str, tool: 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, '') + args, fallback = split_command(command) if not args: return (GUARD_UNCHECKED, '') - interpreter = find_interpreter(guard) + deadline = engine_deadline() + interpreter = find_interpreter(guard, deadline) if interpreter is None: - print(f'chock: no usable interpreter found, {guard.name} not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + return _no_interpreter(guard, deadline) try: - env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} - 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) + env = {**interpreter_env(interpreter), 'CHOCK_RAW_COMMAND': command, 'CHOCK_TOOL': normalize_tool(tool)} + if fallback: + env[ARGV_FALLBACK_ENV] = '1' + proc = _chock_subprocess.run([interpreter, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=time_left(deadline, [interpreter, str(guard)]), check=False) except _chock_subprocess.TimeoutExpired: - print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) + print(f'chock: guard timed out after {ENGINE_BUDGET_SECONDS}s, not checked', file=sys.stderr) return (GUARD_ERRORED, '') except (OSError, UnicodeError) as exc: print(f'chock: guard could not run, not checked: {exc}', file=sys.stderr) @@ -779,10 +895,11 @@ def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: output = ((proc.stderr or '') + (proc.stdout or '')).strip() first_line = output.splitlines()[0].strip() if output else '' if proc.returncode == GUARD_VIOLATION: + if is_guard_crash(output): + print(f'chock: guard {_chock_Path(guard).name} exited 1 without a reason (crash?), not checked', file=sys.stderr) + return (GUARD_ERRORED, '') sys.stderr.write(proc.stdout or '') sys.stderr.write(proc.stderr or '') - if not output: - print(f'chock: blocked by {_chock_Path(guard).name} (guard gave no reason)', file=sys.stderr) return (GUARD_BLOCKED, first_line) if proc.returncode == GUARD_ASK_EXIT: sys.stderr.write(proc.stdout or '') @@ -793,6 +910,17 @@ def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: return (GUARD_ERRORED, '') return (GUARD_CLEAN, '') +def append_gate_log(chock_root: _chock_Path, record: dict) -> None: + """Append one record, stamped, to `/log/gate-events.jsonl`, rotating past the size cap.""" + log_dir = chock_root / 'log' + log_dir.mkdir(parents=True, exist_ok=True) + 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') + stamped = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), **record} + with log_path.open('a', encoding='utf-8') as fh: + fh.write(json.dumps(stamped, ensure_ascii=False) + '\n') + def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> None: """Append one outcome record. Best effort: never raises, never changes the verdict.""" try: @@ -808,23 +936,19 @@ def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> None: break if artifact_root is None: return - log_dir = artifact_root / 'log' - log_dir.mkdir(parents=True, exist_ok=True) - 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': verdict} - with log_path.open('a', encoding='utf-8') as fh: - fh.write(json.dumps(record, ensure_ascii=False) + '\n') + record = {'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': verdict} + append_gate_log(artifact_root, record) except Exception: return def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: """Run the guard named on `argv` (`--guard `) against `command`.""" guard = guard_path_from_argv(argv) - if guard is None or not guard.exists(): + if guard is None: return None - verdict, message = run_guard_detailed(guard, command) + if not guard.exists(): + return (VERDICT_DENY, f"chock guard {guard} is missing, so this command cannot be checked. Run `chock sync --repo .` to reinstall the policy's guards.") + verdict, message = run_guard_detailed(guard, command, tool) logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} if verdict in logged: log_outcome(guard, tool, verdict=logged[verdict]) @@ -833,28 +957,766 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N 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.") + why = message or f"the {guard.stem} guard did not complete (see this hook's stderr)" + return (VERDICT_ESCALATE, f'chock could not check this command against {guard.stem}: {why}. Approving runs it unchecked.') + return None + +_EDIT_KEYS = (('old_string', 'new_string'), ('oldString', 'newString'), ('old_str', 'new_str')) + +_EDIT_LIST = 'edits' + +_CRLF = '\r\n' + +def _edit_call_input(event): + raw = getattr(event, 'raw', None) + tool_input = raw.get('tool_input') if isinstance(raw, dict) else None + if isinstance(tool_input, str) and tool_input[:1] == '{': + try: + tool_input = json.loads(tool_input) + except ValueError: + return {} + return tool_input if isinstance(tool_input, dict) else {} + +def _pair(item): + if not isinstance(item, dict): + return None + for old, new in _EDIT_KEYS: + if isinstance(item.get(old), str) and isinstance(item.get(new), str): + return (item[old], item[new], item.get('replace_all') is True) + return None + +def edit_replacements(event): + """The (old, new, replace_all) replacements an edit call applies, in order; None if not an edit.""" + tool_input = _edit_call_input(event) + listed = tool_input.get(_EDIT_LIST) + found = [_pair(item) for item in (listed if isinstance(listed, list) else [tool_input])] + if not found or None in found: + return None + return found + +def _replace(text, old, new, every): + """`text` with the replacement applied, or None when `old` is not there to replace. + + The file is read with universal newlines, so a CRLF file (a Windows checkout) arrives as + LF; the call's strings are brought to LF too, whichever ending the client sent them with, + rather than calling a real edit a failure over line endings. + """ + old, new = (old.replace(_CRLF, '\n'), new.replace(_CRLF, '\n')) + if old == '': + return new if text == '' else None + if old not in text: + return None + return text.replace(old, new) if every else text.replace(old, new, 1) + +def edited_text(event, root=None): + """The file an edit call would leave behind, or None when it is not an edit or cannot be rebuilt.""" + replacements = edit_replacements(event) + path = getattr(event, 'path', None) + if replacements is None or not path: + return None + target = _chock_Path(path) + if not target.is_absolute(): + target = _chock_Path(root if root is not None else _chock_Path.cwd()) / target + try: + text = target.read_text(encoding='utf-8') if target.exists() else '' + except (OSError, UnicodeDecodeError): + return None + for old, new, every in replacements: + text = _replace(text, old, new, every) + if text is None: + return None + return text + +def added_from_event(event): + """What an edit call introduces -- what `added_lines` means for it -- or {} for any other call.""" + replacements = edit_replacements(event) + path = getattr(event, 'path', None) + if replacements is None or not path: + return {} + return {str(path): '\n'.join((new for _, new, _ in replacements))} + +_BEGIN = '*** Begin Patch' + +_END = '*** End Patch' + +_ADD = '*** Add File: ' + +_DELETE = '*** Delete File: ' + +_UPDATE = '*** Update File: ' + +_MOVE = '*** Move to: ' + +_EOF = '*** End of File' + +_HUNK = '@@' + +_OPS = (('add', _ADD), ('delete', _DELETE), ('update', _UPDATE)) + +def patch_text(event): + """The patch an apply_patch call carries, from its first to its last marker; None otherwise.""" + raw = getattr(event, 'raw', None) + tool_input = raw.get('tool_input') if isinstance(raw, dict) else None + command = tool_input.get('command') if isinstance(tool_input, dict) else None + if isinstance(command, list): + command = '\n'.join((part for part in command if isinstance(part, str))) + start = command.find(_BEGIN) if isinstance(command, str) else -1 + end = command.find(_END, start) if start >= 0 else -1 + return command[start:end + len(_END)] if end >= 0 else None + +def _body_line(current, line): + """Add one line under the file operation `current` is reading.""" + if line.startswith(_MOVE): + current[2] = line[len(_MOVE):].strip() + elif line.startswith(_HUNK): + current[3].append([line[len(_HUNK):].strip(), [], False]) + elif line.startswith(_EOF): + if current[3]: + current[3][-1][2] = True + elif current[0] == 'add': + current[3].append(line[1:] if line.startswith('+') else line) + else: + if not current[3]: + current[3].append(['', [], False]) + current[3][-1][1].append(line if line[:1] in (' ', '-', '+') else ' ' + line) + +def parse_patch(patch): + """[(op, path, moved_to, hunks)]: op is add/delete/update; a hunk is (anchor, lines, at_eof).""" + operations = [] + for line in patch.replace('\r\n', '\n').split('\n')[1:]: + if line.startswith(_END): + break + opened = next(((op, marker) for op, marker in _OPS if line.startswith(marker)), None) + if opened is not None: + operations.append([opened[0], line[len(opened[1]):].strip(), None, []]) + elif operations: + _body_line(operations[-1], line) + return [tuple(operation) for operation in operations] + +def _find(lines, wanted, start): + """Where `wanted` starts in `lines` at or after `start`, exactly or ignoring trailing space.""" + for loose in (False, True): + for index in range(start, len(lines) - len(wanted) + 1): + window = lines[index:index + len(wanted)] + if window == wanted or (loose and [w.rstrip() for w in window] == [w.rstrip() for w in wanted]): + return index + return None + +def apply_hunks(text, hunks): + """`text` with the hunks applied in order, or None when one cannot be placed.""" + lines = text.split('\n') + cursor = 0 + for anchor, body, at_eof in hunks: + if anchor: + found = _find(lines, [anchor], cursor) + if found is None: + found = next((i for i in range(cursor, len(lines)) if anchor.strip() in lines[i]), None) + if found is None: + return None + cursor = found + 1 + old = [line[1:] for line in body if line[:1] in (' ', '-')] + new = [line[1:] for line in body if line[:1] in (' ', '+')] + if not old: + at = len(lines) - (1 if lines and lines[-1] == '' else 0) if at_eof else cursor + else: + at = _find(lines, old, cursor) + if at is None: + return None + lines[at:at + len(old)] = new + cursor = at + len(new) + return '\n'.join(lines) + +def _read(root, path): + target = _chock_Path(path) + if not target.is_absolute(): + target = _chock_Path(root if root is not None else _chock_Path.cwd()) / target + try: + return target.read_text(encoding='utf-8') + except (OSError, UnicodeDecodeError): + return None + +def patched_files(event, root=None): + """{path: text} for every file an apply_patch call adds or updates and can be rebuilt.""" + patch = patch_text(event) + if patch is None: + return {} + files = {} + for op, path, moved_to, body in parse_patch(patch): + if op == 'add': + files[path] = '\n'.join(body) + '\n' + elif op == 'update': + before = _read(root, path) + after = apply_hunks(before, body) if before is not None else None + if after is not None: + files[moved_to or path] = after + return files + +def patch_added(event): + """{path: the lines the patch adds}, which is what `added_lines` means for it.""" + patch = patch_text(event) + if patch is None: + return {} + added = {} + for op, path, moved_to, body in parse_patch(patch): + if op == 'add': + added[path] = '\n'.join(body) + elif op == 'update': + added[moved_to or path] = '\n'.join((line[1:] for _, hunk, _ in body for line in hunk if line[:1] == '+')) + return added + +GATE_BLOCKED = 'blocked' + +GATE_CLEAN = 'clean' + +GATE_ERRORED = 'errored' + +GATE_ASKED = 'asked' + +GATE_WARNED = 'warned' + +VERDICT_WARN = 'warn' + +_EXIT_OUTCOME = {0: GATE_CLEAN, 1: GATE_BLOCKED, 3: GATE_ASKED, 4: GATE_WARNED} + +def runner_outcome(returncode, stderr): + """(outcome, message) for the runner's exit code and stderr: its words, only when it refused or spoke.""" + outcome = _EXIT_OUTCOME.get(returncode, GATE_ERRORED) + return (outcome, '' if outcome == GATE_CLEAN else (stderr or '').strip()) + +def gate_decision(outcome, message, gate): + """The decision an outcome earns, or None when the gate allowed: deny, escalate (ask) or warn.""" + policy = gate.parent.parent.name + spoken = {GATE_BLOCKED: (VERDICT_DENY, message or f'Blocked by chock policy: {policy}'), GATE_ASKED: (VERDICT_ESCALATE, message or f'Chock policy {policy} asks before this write.'), GATE_WARNED: (VERDICT_WARN, message or f'Chock policy {policy} warns about this write.'), GATE_ERRORED: (VERDICT_DENY, f'chock could not check this write: {message}. Refusing rather than reporting an allow it never established.')} + return spoken.get(outcome) + +OUTSIDE_KEY = 'outside_repo' + +_UP = '..' + +_DRIVE_AT = 1 + +_SEPARATORS = ('/', '\\') + +def _slashed(path): + return str(path).replace('\\', '/') + +def is_outside(path): + """Whether a path as the runtime names it lies outside the repository: absolute, or climbing out.""" + text = _slashed(path) + return text.startswith(('/', _UP + '/')) or text[_DRIVE_AT:_DRIVE_AT + 1] == ':' or text == _UP + +def expand_home(pattern): + """`~` (alone or before a separator) as this machine's home directory; anything else as given.""" + text = str(pattern) + if text[:1] == '~' and (len(text) == 1 or text[1] in _SEPARATORS): + return _chock_os.path.expanduser('~') + text[1:] + return text + +def _outside_fold(path): + """`path` with `.` and `..` folded lexically and doubled slashes dropped; a root's leading slash kept.""" + parts = [] + for part in _slashed(path).split('/'): + if part == _UP and parts and (parts[-1] not in ('', _UP)): + parts.pop() + elif part != '.' and (part or not parts): + parts.append(part) + return '/'.join(parts) + +def declared_outside(path, root, patterns, *, windows=None): + """The absolute, slash-separated path when it is outside `root` and matches a declared glob; else None.""" + windows = _chock_os.name == 'nt' if windows is None else windows + text = _slashed(path) + if not is_outside(text) or not patterns: + return None + absolute = text if not text.startswith(_UP) else _slashed(root) + '/' + text + absolute = _outside_fold(absolute) + subject = absolute.lower() if windows else absolute + for pattern in patterns: + wanted = _outside_fold(expand_home(pattern)) + if _chock_fnmatch.fnmatchcase(subject, wanted.lower() if windows else wanted): + return absolute + return None + +def outside_globs(gate): + """The `outside_repo` globs the compiled gate declares; none when it declares none or cannot be read.""" + try: + declared = json.loads(gate.read_text(encoding='utf-8')).get(OUTSIDE_KEY) + except (OSError, ValueError, AttributeError): + return [] + return [g for g in declared if isinstance(g, str)] if isinstance(declared, list) else [] + +def judged_files(files, root, patterns, repo_names): + """`files` ({path: text}) keyed the way a gate judges them. + + A path inside the repository is named by `repo_names(path)`; one outside is kept, under its + absolute path, only when a declared glob matches it. + """ + kept = {} + for path, text in files.items(): + for name in repo_names(path): + if not is_outside(name): + kept[name] = text + continue + found = declared_outside(name, root, patterns) + if found is not None: + kept[found] = text + return kept + +SESSION_MAX_ENTRIES = 500 + +SESSION_PRUNE_SECONDS = 7 * 24 * 3600 + +SESSION_FIELD_CAP = 300 + +SESSION_TRIM_BYTES = 131072 + +SESSION_DEDUPE_TAIL = 8 + +SESSION_STATE_PARTS = ('.chock', 'state') + +SESSION_INPUT_KEYS = ('tool_input', 'toolInput', 'toolArgs', 'input') + +SESSION_PATH_KEYS = ('file_path', 'filePath', 'path', 'notebook_path', 'target_file', 'TargetFile') + +SESSION_URL_KEYS = ('url', 'uri') + +SESSION_RESPONSE_KEYS = ('tool_response', 'toolResponse', 'tool_result', 'toolResult') + +SESSION_UNKNOWN_ID = 'unknown' + +SESSION_ID_MAX = 128 + +SESSION_MASK = '***' + +def tool_call_input(event): + """The tool's arguments as a dict, decoding the JSON-string form some vendors send; {} when absent.""" + raw = getattr(event, 'raw', None) + if not isinstance(raw, dict): + return {} + for key in SESSION_INPUT_KEYS: + value = raw.get(key) + if isinstance(value, str) and value[:1] == '{': + try: + value = json.loads(value) + except ValueError: + continue + if isinstance(value, dict): + return value + return {} + +def session_id_of(event): + """The session id as a safe file stem: separators and leading dots removed, never empty.""" + raw = getattr(event, 'raw', None) or {} + given = getattr(event, 'session_id', None) or (raw.get('session_id') if isinstance(raw, dict) else None) + stem = ''.join((c for c in str(given or '') if c.isalnum() or c in '._-')).lstrip('.') + return stem[:SESSION_ID_MAX] or SESSION_UNKNOWN_ID + +def session_log_path(root, session_id): + return _chock_Path(root).joinpath(*SESSION_STATE_PARTS, session_id + '.jsonl') + +def session_for(event, root): + """What a script gate receives as `session`: the id, the log's absolute path, this call's id.""" + session_id = session_id_of(event) + return {'id': session_id, 'log_path': session_log_path(root, session_id).as_posix(), 'tool_use_id': getattr(event, 'tool_use_id', None)} + +def _mask_assignments(command): + """`command` with the value of every `NAME=value` word masked: an env prefix is a secret's usual home.""" + words = [] + for word in command.split(' '): + name, eq, _ = word.partition('=') + words.append(name + eq + SESSION_MASK if eq and name.isidentifier() else word) + return ' '.join(words) + +def _bare_url(url): + """`url` without userinfo, query and fragment: where a token or a secret is usually carried.""" + url = url.split('#', 1)[0].split('?', 1)[0] + scheme, sep, rest = url.partition('://') + if not sep: + return url + authority, slash, tail = rest.partition('/') + return scheme + sep + authority.rsplit('@', 1)[-1] + slash + tail + +def _first_str(mapping, keys): + for key in keys: + value = mapping.get(key) + if isinstance(value, str) and value: + return value + return None + +def session_summary(event): + """The input of a tool call as {path?, command?, url?}: what a policy asks about, nothing else.""" + tool_input = tool_call_input(event) + path = getattr(event, 'path', None) or _first_str(tool_input, SESSION_PATH_KEYS) + command = getattr(event, 'command', None) or _first_str(tool_input, ('command',)) + url = _first_str(tool_input, SESSION_URL_KEYS) + summary = {} + if isinstance(path, str) and path: + summary['path'] = path[:SESSION_FIELD_CAP] + if isinstance(command, str) and command: + summary['command'] = _mask_assignments(command.splitlines()[0] if command.strip() else '')[:SESSION_FIELD_CAP] + if url: + summary['url'] = _bare_url(url)[:SESSION_FIELD_CAP] + return summary + +def _response_failed(response): + if not isinstance(response, dict): + return False + if response.get('is_error') is True or response.get('isError') is True or response.get('success') is False: + return True + code = response.get('exit_code', response.get('exitCode')) + return bool(response.get('error')) or (isinstance(code, int) and code != 0) + +def session_outcome(event): + """`ok` or `error` for a finished call, from what the payload says; None before it ran.""" + if event.event == 'tool_failure': + return 'error' + if event.event != 'post_tool': + return None + raw = getattr(event, 'raw', None) or {} + failed = any((_response_failed(raw.get(key)) for key in SESSION_RESPONSE_KEYS)) if isinstance(raw, dict) else False + return 'error' if failed else 'ok' + +def _same_call(line, record): + """Whether `line` is `record` already logged: same call id, or, with none, the same second and input.""" + try: + seen = json.loads(line) + except ValueError: + return False + if seen.get('phase') != record['phase']: + return False + if record['tool_use_id']: + return seen.get('tool_use_id') == record['tool_use_id'] + return not seen.get('tool_use_id') and all((seen.get(key) == record[key] for key in ('ts', 'tool', 'input', 'outcome'))) + +def _prune_old(directory): + cutoff = _chock_datetime.now(_chock_timezone.utc).timestamp() - SESSION_PRUNE_SECONDS + for old in directory.glob('*.jsonl'): + try: + if old.stat().st_mtime < cutoff: + old.unlink() + except OSError: + continue + +def _append_bounded(path, line, record): + lines = path.read_text(encoding='utf-8', errors='replace').splitlines() if path.exists() else [] + if any((_same_call(seen, record) for seen in lines[-SESSION_DEDUPE_TAIL:])): + return + if len(lines) < SESSION_MAX_ENTRIES and path.exists() and (path.stat().st_size < SESSION_TRIM_BYTES): + with path.open('a', encoding='utf-8') as fh: + fh.write(line + '\n') + return + kept = [*lines, line][-SESSION_MAX_ENTRIES:] + scratch = path.with_suffix('.tmp') + scratch.write_text('\n'.join(kept) + '\n', encoding='utf-8') + scratch.replace(path) + +def session_record(root, event, phase, outcome=None): + """Append one record for this call to its session's log. Best effort: never raises.""" + try: + path = session_log_path(root, session_id_of(event)) + path.parent.mkdir(parents=True, exist_ok=True) + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'session_id': path.stem, 'phase': phase, 'tool': str(getattr(event, 'tool', None) or ''), 'input': session_summary(event), 'outcome': outcome, 'tool_use_id': getattr(event, 'tool_use_id', None)} + _append_bounded(path, json.dumps(record, ensure_ascii=False, sort_keys=True), record) + _prune_old(path.parent) + except Exception: + return + +REENTRY_CAP = 3 + +STOP_LEDGER_SUFFIX = '.stop.jsonl' + +GATE_LOG_PARTS = ('.chock', 'log', 'gate-events.jsonl') + +STOP_MODE_MASK = 61440 + +STOP_MODE_FILE = 32768 + +STOP_MODE_DIR = 16384 + +STOP_PHASE = 'stop' + +STOP_TURN_KEYS = ('turn_id', 'generation_id') + +STOP_COUNT_KEY = 'loop_count' + +STOP_DIGEST_CHARS = 16 + +REENTRY_CLEAN = 'clean' + +REENTRY_REFUSED = 'refused' + +REENTRY_CAPPED = 'cap-reached' + +REENTRY_UNTRACKED = 'untracked' + +STOP_LOG_BLOCK = 'block' + +STOP_LOG_ALLOW = 'allow' + +STOP_LOG_WARN = 'warn' + +_STOP_LAST = "\nThis is refusal {cap} of {cap} for this turn's end: the next stop ends the turn with these findings still on disk, and a commit will refuse them. A person must review them." + +_STOP_WARNING = 'chock {policy}: this turn ended with findings still on disk{why}. The turn was allowed to end so the client does not loop; a commit will refuse these findings, and a person must review them.\n' + +_STOP_WHY = {REENTRY_CAPPED: ' after {cap} refused stops', REENTRY_UNTRACKED: ' (the stop record under .chock/state could not be written, so re-entries cannot be counted)'} + +_STOP_SINCE = {True: ' (changed since the last refusal)', False: ' (unchanged since the last refusal)'} + +def reentries(event): + """How many times the client says this stop re-entered its hook: `loop_count`, else 1 for `stop_hook_active`.""" + raw = event.raw if isinstance(event.raw, dict) else {} + try: + counted = int(raw.get(STOP_COUNT_KEY) or 0) + except (TypeError, ValueError): + counted = 1 + return max(counted, 1 if raw.get('stop_hook_active') else 0) + +def _stop_turn(event): + """The vendor's turn id, when the payload carries one; None otherwise.""" + raw = event.raw if isinstance(event.raw, dict) else {} + for key in STOP_TURN_KEYS: + value = raw.get(key) + if isinstance(value, str) and value: + return value[:SESSION_ID_MAX] + return None + +def _stop_digest(*parts): + digest = _chock_hashlib.sha256() + for part in parts: + digest.update(str(part).encode('utf-8', 'replace') + b'\x00') + return digest.hexdigest()[:STOP_DIGEST_CHARS] + +def finding_digests(message, writes): + """One digest per flagged file, of its path and content: never the text itself, never the message's wording.""" + if not message: + return [] + lines = [line.strip().lstrip('-').strip() for line in message.splitlines()] + flagged = [p for p in writes if any((line.startswith(p + ':') for line in lines))] or list(writes) + if not flagged: + return [_stop_digest(message)] + return sorted({_stop_digest(p, writes[p]) for p in flagged}) + +def stop_ledger_path(root, session_id): + return _chock_Path(root).joinpath(*SESSION_STATE_PARTS, session_id + STOP_LEDGER_SUFFIX) + +def stop_ledger_safe(path): + """Whether `path`, its directory and `.chock` are what chock made: real directories and a regular file or none. + + lstat, so a symlink, FIFO, socket, device or directory in any place is refused before anything opens it: + opening a FIFO blocks the hook until its timeout, which a client may read as an allow. + """ + for target, kind in ((path, STOP_MODE_FILE), (path.parent, STOP_MODE_DIR), (path.parent.parent, STOP_MODE_DIR)): + try: + mode = _chock_os.lstat(target).st_mode + except FileNotFoundError: + continue + except OSError: + return False + if mode & STOP_MODE_MASK != kind: + return False + return True + +def _stop_lines(path): + """The ledger's lines; none when it is missing, unsafe or cannot be read.""" + if not stop_ledger_safe(path): + return [] + try: + fd = _chock_os.open(path, _chock_os.O_RDONLY | getattr(_chock_os, 'O_NOFOLLOW', 0) | getattr(_chock_os, 'O_NONBLOCK', 0)) + with _chock_os.fdopen(fd, encoding='utf-8', errors='replace') as fh: + return fh.read().splitlines() + except OSError: + return [] + +def _stop_parse(line): + try: + return json.loads(line) + except (ValueError, RecursionError): + return None + +def _stop_well_formed(seen, session_id): + stamp, index = (seen.get('ts'), seen.get('reentry')) + return isinstance(stamp, str) and bool(stamp) and (seen.get('session_id') == session_id) and (type(index) is int) and (index >= 0) + +def _stop_chain(lines, session_id, policy, turn): + """This turn's records, newest first, counting down without a gap to its first stop; None when broken.""" + chain = [] + for line in reversed(lines): + seen = _stop_parse(line) + if not isinstance(seen, dict) or seen.get('phase') != STOP_PHASE or seen.get('policy') != policy: + continue + if turn is not None and seen.get('turn') != turn: + continue + if not _stop_well_formed(seen, session_id): + return None + if chain and seen['reentry'] != chain[-1]['reentry'] - 1: + return None + chain.append(seen) + if seen['reentry'] == 0 or seen.get('anchor') is True: + return chain return None +def _stop_append(path, lines, record): + """Append one record, keeping the last `SESSION_MAX_ENTRIES`; whether it landed.""" + line = json.dumps(record, sort_keys=True) + if not stop_ledger_safe(path): + return False + try: + path.parent.mkdir(parents=True, exist_ok=True) + if len(lines) < SESSION_MAX_ENTRIES and (not path.exists() or path.stat().st_size < SESSION_TRIM_BYTES): + flags = _chock_os.O_WRONLY | _chock_os.O_APPEND | _chock_os.O_CREAT | getattr(_chock_os, 'O_NOFOLLOW', 0) | getattr(_chock_os, 'O_NONBLOCK', 0) + with _chock_os.fdopen(_chock_os.open(path, flags, 384), 'a', encoding='utf-8') as fh: + fh.write(line + '\n') + else: + scratch = path.with_name('%s.%d.tmp' % (path.name, _chock_os.getpid())) + scratch.write_text('\n'.join([*lines[-(SESSION_MAX_ENTRIES - 1):], line]) + '\n', encoding='utf-8') + scratch.replace(path) + _prune_old(path.parent) + except OSError: + return False + return True + +def _stop_gate_log(root, policy, verdict, reentry, findings): + """One gate-log record per re-entry; a warn is held (`would_block`), so it is kept even with the log off. + + `reentry` is (index, reentry_verdict, findings_changed or None). + """ + try: + if not stop_ledger_safe(_chock_Path(root).joinpath(*GATE_LOG_PARTS)): + return + held = verdict == STOP_LOG_WARN + if _chock_os.environ.get(GATE_LOG_ENV) == '0' and (not held): + return + index, reentry_verdict, changed = reentry + record = {'policy_id': policy, 'surface': 'stop-reentry', 'event': 'stop', 'kind': 'reentry', 'reentry': index, 'verdict': verdict, 'reentry_verdict': reentry_verdict, 'match_count': len(findings), 'matches': []} + if held: + record.update({'would_action': STOP_LOG_BLOCK, 'would_block': True}) + if changed is not None: + record['findings_changed'] = changed + append_gate_log(_chock_Path(root).joinpath(SESSION_STATE_PARTS[0]), record) + except Exception: + return + +def _stop_warn(policy, why, changed, message): + """The warning a turn ends with: said on stderr for every client, and returned for a `systemMessage`.""" + text = _STOP_WARNING.format(policy=policy, why=why.format(cap=REENTRY_CAP) + _STOP_SINCE.get(changed, '')) + text += message + with contextlib.suppress(Exception): + sys.stderr.write(text + '\n') + sys.stderr.flush() + return (VERDICT_WARN, text) + +def _stop_changed(chain, findings): + """Whether the findings differ from the last refusal's; None when there is no refusal to compare with.""" + refused = next((r for r in chain or () if r.get('verdict') == STOP_LOG_BLOCK), None) + if refused is None or not isinstance(refused.get('findings'), list): + return None + return sorted(refused['findings']) != findings + +def settle_stop(event, root, gate, decision, writes=None): + """The decision a Stop earns once this turn's earlier Stops are weighed against `decision`.""" + if decision is not None and decision[0] != VERDICT_DENY: + return decision + policy = gate.parent.parent.name + session_id = session_id_of(event) + path = stop_ledger_path(root, session_id) + lines = _stop_lines(path) + message = decision[1] if decision else '' + findings = finding_digests(message, writes or {}) + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'session_id': session_id, 'phase': STOP_PHASE, 'policy': policy, 'turn': _stop_turn(event), 'findings': findings} + signalled = reentries(event) + if not signalled: + _stop_append(path, lines, {**record, 'reentry': 0, 'verdict': STOP_LOG_BLOCK if decision else STOP_LOG_ALLOW}) + return decision + chain = _stop_chain(lines, session_id, policy, record['turn']) + counted = isinstance(event.raw, dict) and STOP_COUNT_KEY in event.raw + index = signalled if counted else chain[0]['reentry'] + 1 if chain else 1 + capped = index > REENTRY_CAP + verdict = STOP_LOG_ALLOW if not decision else STOP_LOG_WARN if capped else STOP_LOG_BLOCK + landed = _stop_append(path, lines, {**record, 'reentry': index, 'verdict': verdict, 'anchor': chain is None}) + changed = _stop_changed(chain, findings) + if decision is None: + _stop_gate_log(root, policy, verdict, (index, REENTRY_CLEAN, None), findings) + return None + if capped: + _stop_gate_log(root, policy, verdict, (index, REENTRY_CAPPED, changed), findings) + return _stop_warn(policy, _STOP_WHY[REENTRY_CAPPED], changed, message) + if not landed and (not counted): + _stop_gate_log(root, policy, STOP_LOG_WARN, (index, REENTRY_UNTRACKED, None), findings) + return _stop_warn(policy, _STOP_WHY[REENTRY_UNTRACKED], None, message) + tracked = REENTRY_REFUSED if chain and landed else REENTRY_UNTRACKED + _stop_gate_log(root, policy, verdict, (index, tracked, changed), findings) + return (VERDICT_DENY, message + (_STOP_LAST.format(cap=REENTRY_CAP) if index == REENTRY_CAP else '')) + +UNREADABLE_STOP_LEDGER = 'unreadable-stop.jsonl' + +UNREADABLE_STOP_PHASE = 'unreadable-stop' + +UNREADABLE_STOP_WINDOW_SECONDS = 600 + +REENTRY_UNREADABLE = 'unreadable' + +_UNREADABLE_STOP_WARNING = 'chock {policy}: {cap} stops in a row had a payload chock could not read, so none of them was judged. The turn was allowed to end so the client does not loop; a commit will judge what is on disk, and a person must look at why the payload is unreadable.\n' + +_UNREADABLE_STOP_LAST = '\nThis is refusal {cap} of {cap} for stops chock cannot read: the next one ends the turn unchecked, and a commit will judge what is on disk. A person must look at why the payload is unreadable.' + +def _unreadable_stop_recent(lines, now): + """The refusals recorded inside the window; a record that does not parse, or has no time, counts as none.""" + kept = [] + for line in lines: + seen = _stop_parse(line) + stamp = seen.get('at') if isinstance(seen, dict) else None + refused = isinstance(seen, dict) and seen.get('phase') == UNREADABLE_STOP_PHASE + if refused and seen.get('verdict') == STOP_LOG_BLOCK and (type(stamp) in (int, float)) and (0 <= now - stamp < UNREADABLE_STOP_WINDOW_SECONDS): + kept.append(stamp) + return kept + +def settle_unreadable_stop(root, policy, refusal): + """(verdict, text) this unreadable Stop earns: a refusal with `refusal`, or a warning once the cap is spent. + + A ledger that is not a regular file in real directories, or cannot be written, keeps refusing: with nothing to count, only a refusal is safe. + """ + path = _chock_Path(root).joinpath(*SESSION_STATE_PARTS, UNREADABLE_STOP_LEDGER) + if not stop_ledger_safe(path): + return (VERDICT_DENY, refusal) + lines = _stop_lines(path) + now = _chock_datetime.now(_chock_timezone.utc).timestamp() + index = len(_unreadable_stop_recent(lines, now)) + 1 + capped = index > REENTRY_CAP + verdict = STOP_LOG_WARN if capped else STOP_LOG_BLOCK + _stop_append(path, lines, {'phase': UNREADABLE_STOP_PHASE, 'verdict': verdict, 'at': now, 'reentry': index}) + _stop_gate_log(root, policy, verdict, (index, REENTRY_UNREADABLE, None), []) + if capped: + text = _UNREADABLE_STOP_WARNING.format(policy=policy, cap=REENTRY_CAP) + with contextlib.suppress(Exception): + sys.stderr.write(text) + sys.stderr.flush() + return (VERDICT_WARN, text) + return (VERDICT_DENY, refusal + (_UNREADABLE_STOP_LAST.format(cap=REENTRY_CAP) if index == REENTRY_CAP else '')) + GATE_FLAG = '--gate' -_GATE_TIMEOUT_SECONDS = 30 +STOP_FLAG = '--stop' _GATE_DEPTH_TO_CHOCK = 3 _RUNNER_PARTS = ('bin', 'gate.py') +_PACKAGED_RUNNER = 'gate.py' + _GIT = 'git' -_DELETED = 'D' +_UTF8 = 'utf-8' -_RENAMED = 'R' +_PATH_ERRORS = 'surrogateescape' -GATE_BLOCKED = 'blocked' +_PARENT = '..' -GATE_CLEAN = 'clean' +_DRIVE_COLON = ':' -GATE_ERRORED = 'errored' +_DELETED = 'D' + +_RENAMED = 'R' VERDICT_DENY = 'deny' @@ -867,72 +1729,145 @@ def gate_path_from_argv(argv): return None def runner_for(gate): - """The vendored gate runner beside this compiled gate, or None when it is not there.""" + """The gate runner: beside the gate in a plugin, under .chock/bin in a repository, else None.""" + packaged = gate.resolve().parent / _PACKAGED_RUNNER + if packaged.exists(): + return packaged parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None 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.""" +def writes_from_event(event, root=None): + """The one file this tool call would write, or none when it carries no file text. + + An edit call carries only the text it inserts; judged alone, that fragment has no imports, + no class and no neighbours, so a gate that reads a whole file (a script kind) finds nothing + in it. An edit is therefore judged as the file it would leave: the file on disk with the + call's replacements applied. When that cannot be rebuilt -- the file is unreadable, or the + text to replace is not in it, so the call itself will fail -- the fragment is judged as + before and the turn's end judges what actually landed. A Codex apply_patch call names its + files only inside the patch; each one it adds or updates is judged as the patch leaves it. + """ + patched = patched_files(event, root) + if patched: + return patched path = getattr(event, 'path', None) content = getattr(event, 'content', None) + edited = edited_text(event, root) + if path and edited is not None: + return {str(path): edited} 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.""" +def _folded(pure): + """`pure` with `..` folded lexically; an absolute path never climbs above its anchor.""" + parts = [] + for part in pure.parts: + if part != _PARENT: + parts.append(part) + elif len(parts) > 1: + parts.pop() + return type(pure)(*parts) + +def repo_relative(path, root): + """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" + text = str(path) + if root is None: + return text + windows = _chock_os.name == 'nt' or str(root)[1:2] == _DRIVE_COLON + flavour = _chock_PureWindowsPath if windows else _chock_PurePosixPath 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] + return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() + except ValueError: + pass + if windows != (_chock_os.name == 'nt'): + return text + try: + return _chock_Path(root, text).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return text + +def repo_paths(path, root): + """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" + lexical = repo_relative(path, root) + if root is None or (str(root)[1:2] == _DRIVE_COLON) != (_chock_os.name == 'nt'): + return (lexical,) + try: + resolved = _chock_Path(root, str(path)).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return (lexical,) + return tuple(dict.fromkeys((lexical, resolved))) + +def changed_paths(repo_root, deadline=None, why=None): + """Every uncommitted path; None (reason appended to `why`) when git gave no answer, [] only outside a repository.""" + deadline, why = (engine_deadline() if deadline is None else deadline, [] if why is None else why) + argv = [_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'] + try: + timeout = time_left(deadline, argv) + proc = _chock_subprocess.run(argv, capture_output=True, text=True, encoding=_UTF8, errors=_PATH_ERRORS, timeout=timeout, check=False) + except _chock_subprocess.TimeoutExpired as exc: + why.append(f'git status did not finish within {allowed_seconds(exc)}') + except (OSError, _chock_subprocess.SubprocessError) as exc: + why.append(f'git status could not run ({exc})') + else: + first = ((proc.stderr or '').strip().splitlines() or [''])[0] + if proc.returncode and 'not a git repository' in first: + return [] + if proc.returncode: + why.append(f'git status failed (exit {proc.returncode}): {first}') + if why: + sys.stderr.write(f'chock: {why[-1]}, worktree not checked\n') + return None + fields = iter([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) + if status.startswith(_RENAMED): + next(fields, None) + if _DELETED not in status and path: + paths.append(path) return paths -def writes_from_worktree(repo_root): - """What this turn actually left on disk, however it was written. +def writes_from_worktree(repo_root, deadline=None, why=None): + """What this turn actually left on disk, however it was written; None when it could not be listed. 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. """ + paths = changed_paths(repo_root, deadline, why) + if paths is None: + return None writes = {} - for path in changed_paths(repo_root): + for path in paths: 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.""" +def run_gate(gate, writes, event, run_in, deadline): + """Ask the vendored runner. Returns (outcome, message) and never decides for itself. + + `run_in` is (root, extra): the working directory, and the stdin keys `extra` adds: `added` (per + edited path, only the text the edit introduces; a runner that predates it judges the whole file, + which only ever refuses more) and the script `session`. The runner gets what is left of `deadline`. + """ + root, extra = run_in runner = runner_for(gate) if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') + argv = [sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event] try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run(argv, input=json.dumps({'writes': writes, **(extra or {})}), capture_output=True, text=True, encoding=_UTF8, errors='replace', timeout=time_left(deadline, argv), check=False, cwd=str(root) if root is not None else None) + except _chock_subprocess.TimeoutExpired as exc: + return (GATE_ERRORED, f'the gate runner gave no verdict within {allowed_seconds(exc)}') 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()) + return runner_outcome(proc.returncode, proc.stderr) _EVENT_ARG = {'pre_tool': 'pre-tool-use', 'stop': 'stop'} @@ -943,33 +1878,182 @@ def root_for(gate): parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None + if parents[_GATE_DEPTH_TO_CHOCK - 1].name != 'compiled' or parents[_GATE_DEPTH_TO_CHOCK].name != '.chock': + return None return parents[_GATE_DEPTH_TO_CHOCK].parent -def 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 {} +def repo_root_for(event, gate): + """The repository under judgement: the compiled layout's root, else where the agent works.""" root = root_for(gate) - return writes_from_worktree(root) if root is not None else {} + if root is not None: + return root + cwd = getattr(event, 'cwd', None) + return _chock_Path(cwd) if cwd else _chock_Path.cwd() + +def writes_for(event, gate, deadline=None, why=None): + """What this event puts under judgement: the call's own text, or what the turn left behind (None: unlisted).""" + if event.event == PRE_TOOL: + return writes_from_event(event, repo_root_for(event, gate)) + return writes_from_worktree(repo_root_for(event, gate), deadline, why) + +def _missing_gate(gate): + """A gate the hook names but that is not on disk: a broken install, so a refusal that says so.""" + return (VERDICT_DENY, f'chock gate {gate} is missing, so this write cannot be checked. Run `chock sync --repo .` to rebuild the compiled gates.') + +def _gate_says(gate, event, name, deadline): + """(decision, judged files): what the compiled gate says about this event, before a re-entered stop is weighed.""" + if not gate.exists(): + return (_missing_gate(gate), {}) + root = repo_root_for(event, gate) + outside = outside_globs(gate) + why = [] + listed = writes_for(event, gate, deadline, why) + if listed is None: + return (gate_decision(GATE_ERRORED, why[-1] if why else 'git status gave no answer', gate), {}) + writes = judged_files(listed, root, outside, lambda path: repo_paths(path, root)) + if not writes: + return (None, writes) + added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} + added = judged_files(added, root, outside, lambda path: repo_paths(path, root)) + added = {path: text for path, text in added.items() if path in writes} + extra = {**({'added': added} if added else {}), 'session': session_for(event, root)} + outcome, message = run_gate(gate, writes, name, (root, extra), deadline) + return (gate_decision(outcome, message, gate), writes) 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: + if gate is None or name is None: 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.') + decision, judged = _gate_says(gate, event, name, engine_deadline()) + if event.event == PRE_TOOL: + return decision + return settle_stop(event, repo_root_for(event, gate), gate, decision, judged) + +TOOL_CALL_FLAG = '--tool-call' + +RECORD_FLAG = '--record' + +TOOL_CALL_EVENT = 'tool_call' + +_TOOL_CALL_PRE = 'pre_tool' + +_TOOL_CALL_POST = ('post_tool', 'tool_failure') + +_TOOL_CALL_SCRIPT_KIND = 'script' + +_TOOL_CALL_REGEX_KIND = 'content_regex' + +_TOOL_CALL_BLOCKED = 'blocked' + +_TOOL_CALL_NEEDS_SESSION = frozenset({_TOOL_CALL_SCRIPT_KIND}) + +_TOOL_CALL_EXIT_VERDICTS = {0: None, 1: 'deny', 3: 'escalate', 4: 'warn'} + +_TOOL_CALL_CEILING = {'block': 'deny', 'ask': 'escalate', 'warn': 'warn'} + +_TOOL_CALL_RANK = {'warn': 0, 'escalate': 1, 'deny': 2} + +_TOOL_CALL_UNDECIDED = ' -- refusing rather than allowing what it never judged' + +def _flag_path(argv, flag): + if flag in argv: + index = argv.index(flag) + if index + 1 < len(argv): + return _chock_Path(argv[index + 1]) return None +def tool_call_matches(globs, tool): + """Whether `tool` (a tool name, exact case) matches any glob; no globs match nothing.""" + return any((_chock_fnmatch.fnmatchcase(tool, str(glob)) for glob in globs)) + +def _tool_call_refusal(spec, spoken): + return spoken or str(spec.get('message') or '').strip() or 'blocked by a chock tool_call gate' + +def _tool_call_regex(spec, tool_input): + """`deny` verdict when the input, as JSON text, matches the gate's `content_pattern`.""" + text = json.dumps(tool_input, sort_keys=True, ensure_ascii=False) + if _chock_re.search(str((spec.get('params') or {}).get('content_pattern', '')), text): + return ('deny', _tool_call_refusal(spec, '')) + return None + +def _tool_call_script(spec, root, payload, deadline=None): + """Run the policy's script on `payload`; a missing script, crash or timeout refuses.""" + named = str((spec.get('params') or {}).get('script', '')) + script = _chock_Path(root) / named + if not script.is_file(): + return ('deny', f'script gate: {named!r} is not installed{_TOOL_CALL_UNDECIDED}') + deadline = engine_deadline() if deadline is None else deadline + argv = [sys.executable, str(script)] + try: + proc = _chock_subprocess.run(argv, input=json.dumps(payload), capture_output=True, text=True, encoding='utf-8', errors='replace', cwd=str(root), timeout=time_left(deadline, argv), check=False) + except _chock_subprocess.TimeoutExpired as exc: + budget = f'gave no verdict within {allowed_seconds(exc)}' + return ('deny', f'script gate: {script.name} {budget}{_TOOL_CALL_UNDECIDED}') + except OSError as exc: + return ('deny', f'script gate: {script.name} could not run ({exc}){_TOOL_CALL_UNDECIDED}') + spoken = ((proc.stderr or '') + (proc.stdout or '')).strip() + if proc.returncode in _TOOL_CALL_EXIT_VERDICTS: + verdict = _TOOL_CALL_EXIT_VERDICTS[proc.returncode] + return None if verdict is None else (verdict, spoken or f'blocked by {script.name}') + first = spoken.splitlines()[0] if spoken else '' + detail = f': {first}' if first else '' + return ('deny', f'script gate: {script.name} exited {proc.returncode}{detail}{_TOOL_CALL_UNDECIDED}') + +def _tool_call_verdict(spec, root, event, deadline): + """None to allow, else (verdict, message) for this call; a kind a tool call cannot answer refuses.""" + tool, tool_input = (str(event.tool or ''), tool_call_input(event)) + kind = spec.get('kind') + if kind == _TOOL_CALL_REGEX_KIND: + return _tool_call_regex(spec, tool_input) + if kind == _TOOL_CALL_SCRIPT_KIND: + payload = {'event': TOOL_CALL_EVENT, 'repo_root': str(root), 'tool': tool, 'input': tool_input, 'session': session_for(event, root)} + return _tool_call_script(spec, root, payload, deadline) + return ('deny', f'chock tool_call gate: kind {kind!r} cannot judge a tool call{_TOOL_CALL_UNDECIDED}') + +def _tool_call_capped(spec, verdict): + """`verdict` held to the gate's declared action; an unknown action keeps the verdict as spoken.""" + ceiling = _TOOL_CALL_CEILING.get(spec.get('action', 'block')) + if verdict is None or ceiling is None: + return verdict + return (min(verdict[0], ceiling, key=_TOOL_CALL_RANK.__getitem__), verdict[1]) + +def _tool_call_spec(gate): + """The compiled gate as a dict, or (None, refusal) when the install is broken.""" + if not gate.exists(): + return (None, f'chock gate {gate} is missing, so this tool call cannot be checked. Run `chock sync --repo .` to rebuild the compiled gates.') + try: + return (json.loads(gate.read_text(encoding='utf-8')), None) + except (OSError, ValueError) as exc: + return (None, f'chock could not read {gate}: {exc}. Refusing rather than reporting an allow it never established.') + +def evaluate_tool_call(argv, event): + """The decision a tool_call gate earns this call, or None; also logs the call for the session. + + `--tool-call ` judges a PreToolUse call and, for a gate whose check reads the session, + logs it after the verdict, so the script reads prior calls only. `--record ` logs what + a call came to, after it ran (a vendor's PostToolUse and failure events). + """ + recording = _flag_path(argv, RECORD_FLAG) + if recording is not None: + if event.event in _TOOL_CALL_POST: + session_record(repo_root_for(event, recording), event, 'post', session_outcome(event)) + return None + gate = _flag_path(argv, TOOL_CALL_FLAG) + if gate is None or event.event != _TOOL_CALL_PRE: + return None + spec, broken = _tool_call_spec(gate) + if spec is None: + return ('deny', broken) + root = repo_root_for(event, gate) + verdict = None + if TOOL_CALL_EVENT in spec.get('on', []) and tool_call_matches((spec.get('params') or {}).get('tools', []), str(event.tool or '')): + verdict = _tool_call_capped(spec, _tool_call_verdict(spec, root, event, engine_deadline())) + if spec.get('kind') in _TOOL_CALL_NEEDS_SESSION: + session_record(root, event, 'pre', _TOOL_CALL_BLOCKED if verdict and verdict[0] == 'deny' else None) + return verdict + _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.)" @@ -981,7 +2065,7 @@ def _repo_root() -> _chock_Path: def _hooks_pre_commit(repo_root: _chock_Path) -> _chock_Path | None: """The active pre-commit hook path, honouring core.hooksPath. None when git is absent.""" try: - proc = _chock_subprocess.run([_GIT, 'rev-parse', '--git-path', 'hooks'], cwd=repo_root, capture_output=True, text=True, timeout=15, check=False) + proc = _chock_subprocess.run([_GIT, 'rev-parse', '--git-path', 'hooks'], cwd=repo_root, capture_output=True, text=True, encoding='utf-8', errors='surrogateescape', timeout=15, check=False) except (OSError, _chock_subprocess.TimeoutExpired): return None if proc.returncode != 0: @@ -1035,6 +2119,86 @@ def _chock_handle_session_start(event): ) return Decision.allow(context=_INSTRUCTION) +_CHOCK_UNREADABLE = ( + "chock could not read this hook's payload, so it could not check the call. " + "Refusing rather than reporting an allow it never established." +) + +#: A payload naming only this vendor's own wire event, so `respond` picks that event's grammar. +_CHOCK_WIRE_RAW = {PRE_TOOL: {'hook_event_name': 'PreToolUse'}, STOP: {'hook_event_name': 'Stop'}} + +#: The words a client reads as a refusal, in any of the answer fields a vendor grammar uses. +_CHOCK_REFUSING = ("deny", "block", "ask", "continue") +_CHOCK_ANSWER_KEYS = ("decision", "permission", "permissionDecision") + + +def _chock_spec_action(path): + try: + action = json.loads(path.read_text(encoding="utf-8")).get("action") + except Exception: + return "block" + return action if action in ("ask", "warn") else "block" + + +def _chock_declared(): + """(strictest action, event, gate) of the judging flags; (None, None, None) where this run judges nothing.""" + argv = sys.argv[1:] + actions = ["block"] if guard_path_from_argv(argv) is not None else [] + event = PRE_TOOL + gate = _flag_path(argv, GATE_FLAG) + if gate is not None: + actions.append(_chock_spec_action(gate)) + event = STOP if STOP_FLAG in argv or gate.parent.name == _EVENT_ARG[STOP] else PRE_TOOL + called = _flag_path(argv, TOOL_CALL_FLAG) + if called is not None: + actions.append(_chock_spec_action(called)) + for action in ("block", "ask", "warn"): + if action in actions: + return action, event, gate + return None, None, None + + +def _chock_refuses(text, code): + if code == 2: + return True + try: + body = json.loads(text) + except Exception: + return False + if not isinstance(body, dict): + return False + nested = body.get("hookSpecificOutput") + answers = [body] + ([nested] if isinstance(nested, dict) else []) + return any(part.get(key) in _CHOCK_REFUSING for part in answers for key in _CHOCK_ANSWER_KEYS) + + +def _chock_unreadable_stop(gate): + """The bounded verdict of an unreadable Stop: a refusal, or a warning once the cap is spent.""" + root = root_for(gate) or _chock_Path.cwd() + return settle_unreadable_stop(root, gate.parent.parent.name, _CHOCK_UNREADABLE) + + +def _chock_unreadable(): + action, kind, gate = _chock_declared() + if action in (None, "warn"): + return "", 0 + event = Event(AGENT, kind, raw=dict(_CHOCK_WIRE_RAW[kind])) + reason = _CHOCK_UNREADABLE + if kind == STOP and gate is not None: + verdict, reason = _chock_unreadable_stop(gate) + if verdict == VERDICT_WARN: + return _chock_warn_respond(degrade(Decision.warn(reason), event), event) + _report(reason + "\n") + decision = Decision.escalate(reason) if action == "ask" else Decision.deny(reason) + text, code = respond(degrade(decision, event), event) + return (text, code) if _chock_refuses(text, code) else ("", 2) + + +def _spoken(verdict): + outcome, reason = verdict + if outcome == VERDICT_WARN: + return Decision.warn(reason) + return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) def _judge(event): @@ -1045,7 +2209,10 @@ def _judge(event): 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 _spoken(gated) + called = evaluate_tool_call(sys.argv[1:], event) + if called is not None: + return _spoken(called) if event.event == "session_start": return _chock_handle_session_start(event) return None @@ -1062,6 +2229,33 @@ def handle(event): "chock could not check this call (%s: %s). Refusing rather than reporting an " "allow it never established." % (type(exc).__name__, exc) ) +def _chock_warn_respond(decision, event): + # A warn never blocks. agentseam reduces WARN to a silent allow for every agent, which drops + # the reason, so the reason is added here in the one channel Claude Code documents for it: + # PreToolUse `additionalContext` (Claude reads it beside the tool result; no permissionDecision + # is sent, since `allow` would also skip the user's own permission prompt) and, at the turn's + # end, `systemMessage` (`_chock_stop_warning`). + text, code = respond(decision, event) + if text or degraded_from(decision) != WARN or not decision.reason: + return text, code + if event.event == PRE_TOOL: + body = {"hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": decision.reason}} + return json.dumps(body), code + return _chock_stop_warning(decision, event) or text, code + + +def _chock_stop_warning(decision, event): + # A warn at the turn's end never blocks: a Stop block would force the turn to continue. agentseam + # reduces WARN to a silent allow, which drops the reason, so it is added back as `systemMessage`, + # which Claude Code, Codex and VS Code Copilot each document as a warning shown to the user. + if event.event != STOP or degraded_from(decision) != WARN or not decision.reason: + return None + return json.dumps({"systemMessage": decision.reason}) + + +def _chock_stop_warn_respond(decision, event): + text, code = respond(decision, event) + return text or _chock_stop_warning(decision, event) or text, code # <<< agentseam handler <<< @@ -1107,6 +2301,55 @@ def _report(text): return +_STDERR_FD = 2 + + +def _divert_fd1(): + """Point fd 1 at stderr (devnull if there is none); the saved fd 1, or None if it could not.""" + try: + saved = os.dup(1) + except OSError: + return None + # With fd 2 closed, dup() hands back 2 itself, and "stderr" would be stdout again. + if saved > _STDERR_FD: + with contextlib.suppress(OSError): + os.dup2(_STDERR_FD, 1) + return saved + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +def _flush(streams): + """Flush each stream that exists; a broken one is no reason to lose the verdict.""" + for stream in streams: + if stream is not None: + with contextlib.suppress(Exception): + stream.flush() + + +@contextlib.contextmanager +def _stdout_to_stderr(): + # Stdout is the verdict channel. A handler's stray print (or a child process it runs) + # ahead of the JSON makes the host fail to parse it, and Claude Code and Gemini CLI then + # treat the hook as a non-blocking error: a deny became an allow, witnessed live. + sink = sys.stderr if sys.stderr is not None else io.StringIO() + held = (sys.stdout, sys.__stdout__) + _flush(held) + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + # A stream the handler held on to (sys.__stdout__, a reference cached at import) + # buffers past redirect_stdout; flushed now it lands on stderr, not after the verdict. + _flush((sink, *held)) + os.dup2(saved, 1) + os.close(saved) + + def _decide(raw): """(stdout_text, exit_code) for one decoded payload: agentseam.dispatch.handle(), inlined.""" event = parse(raw) @@ -1116,7 +2359,9 @@ def _decide(raw): # vocabulary invites a decision made on a false premise. return "", 0 try: - decision = _coerce(handle(event)) + with _stdout_to_stderr(): + result = handle(event) + decision = _coerce(result) except Exception: # A door that cannot decide refuses. Escaping here would exit 1 with a traceback, # which every host reads as a non-blocking error and allows past; instead the @@ -1128,7 +2373,7 @@ def _decide(raw): "policy handler failed (%s); refusing rather than allowing what it could not judge" % sys.exc_info()[0].__name__ ) - return respond(degrade(decision, event), event) + return _chock_warn_respond(degrade(decision, event), event) def main(stdin=None, stdout=None, exit=True): @@ -1138,10 +2383,14 @@ def main(stdin=None, stdout=None, exit=True): try: raw = json.loads(_read_payload(stream)) except Exception: - # Malformed input is not the agent's fault to pay for: allow, stay silent. + raw = None + if not isinstance(raw, dict): + text, code = _chock_unreadable() + if text: + _emit(out, text) if exit: - sys.exit(0) - return 0 + sys.exit(code) + return code try: text, code = _decide(raw) except Exception: diff --git a/.chock/bin/gate.py b/.chock/bin/gate.py index 055c4c3..4e0c8cb 100755 --- a/.chock/bin/gate.py +++ b/.chock/bin/gate.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import difflib import fnmatch import json import os @@ -11,11 +12,18 @@ import shutil import subprocess import sys +import time import tomllib +from collections import Counter from collections.abc import Mapping, Sequence from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path +from typing import Any + +#: One hook invocation's time budget, in seconds (chock.gate.budget). +ENGINE_BUDGET_SECONDS = 30 + _GIT = shutil.which("git") or "git" @@ -25,6 +33,20 @@ class GateResult: allowed: bool message: str = "" matches: list[str] = field(default_factory=list) + #: What a script gate itself chose (`ask` or `warn`) when it did not simply refuse; the + #: gate's declared action still caps it. Empty means "whatever the gate declares". + verdict: str = "" + #: Counts a gate log record carries beside the verdict (a script gate's new and baseline findings). + detail: dict[str, int] = field(default_factory=dict) + #: Rule id (a script finding's optional `rule`) -> how many new findings carried it. + rules: dict[str, int] = field(default_factory=dict) + #: The new findings themselves (a script gate's), for the GitHub annotations; never read for a verdict. + findings: list[dict] = field(default_factory=list) + + +def _is_outside(path: str) -> bool: + """An absolute path: only a write the gate declared in `outside_repo` reaches the runner as one.""" + return path.startswith("/") or path[1:2] == ":" class GateContext: @@ -37,13 +59,19 @@ def __init__( base: str | None = None, head_ref: str | None = None, scope: Sequence[str] | None = None, + own: Sequence[str] = (), + session: Mapping[str, object] | None = None, ) -> None: self.repo_root = Path(repo_root) + #: The session-log handle a script gate receives; None when the hook named no session. + self.session = dict(session) if session else None 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 ()) + #: Path prefixes this gate never judges: its own policy's source and compiled folders. + self.own = tuple(own) def in_scope(self, path: str) -> bool: """Whether this policy may judge this file at all. @@ -52,6 +80,10 @@ def in_scope(self, path: str) -> bool: A gate with no scope sees every changed file, which is what every gate did before applies_to.paths was read. """ + if path.startswith(self.own): + return False + if _is_outside(path): + return True return not self.scope or any(fnmatch.fnmatchcase(path, g) for g in self.scope) def _range(self) -> list[str]: @@ -69,7 +101,7 @@ def _git(self, *args: str) -> str: errors="replace", check=True, ) - except (subprocess.CalledProcessError, FileNotFoundError, UnicodeError): + except (subprocess.CalledProcessError, OSError, UnicodeError): return "" else: return proc.stdout or "" @@ -104,6 +136,14 @@ def head_blob(self, path: str) -> str: """Content before the change, or "" when the path is new in it.""" return self._git("show", f"{self.base or 'HEAD'}:{path}") + def committed_blob(self, path: str) -> str: + """The file as HEAD has it, or "" when HEAD does not.""" + return self._git("show", f"HEAD:{path}") + + def net_added_lines(self, path: str) -> list[str]: + """The lines this change introduces; at a commit that is what `added_lines` already says.""" + return self.added_lines(path) + def current_branch(self) -> str: branch = self._git("symbolic-ref", "--short", "HEAD").strip() if branch: @@ -119,40 +159,82 @@ def push_refs(self) -> list[str]: return refs +def _line_diff(old: str, new: str) -> tuple[list[str], list[str]]: + """(added, removed) lines turning `old` into `new`, by sequence like a `-U0` diff.""" + old_lines, new_lines = old.splitlines(), new.splitlines() + added: list[str] = [] + removed: list[str] = [] + for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(None, old_lines, new_lines, autojunk=False).get_opcodes(): + if tag != "equal": + removed.extend(old_lines[i1:i2]) + added.extend(new_lines[j1:j2]) + return added, removed + + 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. + `writes` is each file as it would be (pre-tool) or is (stop). The baseline it changed from + is the file on disk before the write at pre-tool, and HEAD at the turn's end, where the + disk already holds the writes. `added_lines` is an edit's own text when it carries one + (`added`), else the diff against that baseline, so a whole-file write or a dirty file is + judged on what changed and never on lines that were already there. 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) + def __init__( + self, + repo_root: Path, + writes: Mapping[str, str], + scope: Sequence[str] | None = None, + added: Mapping[str, str] | None = None, + own: Sequence[str] = (), + *, + stop: bool = False, + session: Mapping[str, object] | None = None, + ) -> None: + super().__init__(repo_root=repo_root, scope=scope, own=own, session=session) self._writes = dict(writes) + self._added = dict(added or {}) + self._stop = stop - def staged_paths(self, diff_filter: str = "ACMRT") -> list[str]: # noqa: ARG002 -- no diff to filter + def staged_paths(self, diff_filter: str = "ACMRT") -> list[str]: + """Every write is a present file, so a filter asking only for deletions finds none.""" + if not set(diff_filter) & set("ACMRT"): + return [] 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 _diff(self, path: str) -> tuple[list[str], list[str]]: + return _line_diff(self.head_blob(path), self._writes.get(path, "")) + def added_lines(self, path: str) -> list[str]: - return self._writes.get(path, "").splitlines() + if path in self._added: + return self._added[path].splitlines() + return self._diff(path)[0] - 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 net_added_lines(self, path: str) -> list[str]: + return self._diff(path)[0] + + def removed_lines(self, path: str) -> list[str]: + return self._diff(path)[1] + + def committed_blob(self, path: str) -> str: + """HEAD's file, named from `repo_root` (`./`), which may sit below the git top-level.""" + return self._git("show", f"HEAD:./{path}") def head_blob(self, path: str) -> str: - """What is on disk now, which is what this write would replace.""" - target = self.repo_root / path + """The baseline: HEAD at the turn's end, else what is on disk now, which this write would replace.""" + if self._stop: + return self.committed_blob(path) try: - return target.read_text(encoding="utf-8") + return (self.repo_root / path).read_text(encoding="utf-8") except (OSError, UnicodeDecodeError): return "" @@ -160,7 +242,8 @@ def head_blob(self, path: str) -> str: #: 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"}) +_DEPENDENCY_KIND = "dependency_allowlist" +WRITE_PATH_KINDS = frozenset({"content_regex", "script", _DEPENDENCY_KIND, "test_integrity"}) #: Events at which a line-level waiver is honoured: the ones where a human staged the text. At @@ -169,25 +252,210 @@ def head_blob(self, path: str) -> str: #: evaluator never read it for that reason, and the published policy message says so. WAIVABLE_EVENTS = frozenset({"commit", "push", "ci"}) +#: The event of both agent surfaces (pre-tool-use and stop), by the name policies declare. +TOOL_USE_EVENT = "tool_use" + +#: Env var a person or agent sets to say who is committing: truthy marks an agent, and a value in +#: `_HUMAN_ENV` marks a person and wins over every detected marker. +AGENT_COMMIT_ENV = "CHOCK_AGENT_COMMIT" + +#: Markers Claude Code's Bash tool sets in every command it runs (witnessed; a git hook inherits +#: the environment of the git process). `AI_AGENT` is `claude-code__agent` there. +CLAUDECODE_ENV = "CLAUDECODE" +AI_AGENT_ENV = "AI_AGENT" + +#: Top-level key of `.chock/config.yaml` naming further variables whose presence marks an agent. +CONFIG_AGENT_ENV_KEY = "agent_commit_env" +_CONFIG_PATH = (".chock", "config.yaml") +_CONFIG_KEY_RE = re.compile(rf"^{CONFIG_AGENT_ENV_KEY}:[ \t]*(?P[^#\n]*)") +_CONFIG_ITEM_RE = re.compile(r"^[ \t]+-[ \t]*(?P[^\s#]+)") +_ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + +#: Env var naming, comma separated, the policies whose `ask` a person has answered "go ahead". +ALLOW_ENV = "CHOCK_ALLOW" + +#: The event an agent-made commit is judged at: outside WAIVABLE_EVENTS, so a waiver the commit +#: adds is not honoured. Coverage (`on: [commit]`) is still decided by the name `commit`. +AGENT_COMMIT_EVENT = "agent-commit" + +#: Events where the actor may be the refused agent: a waiver counts only when HEAD already has that line. +HEAD_WAIVER_EVENTS = frozenset({AGENT_COMMIT_EVENT, TOOL_USE_EVENT}) + +_HUMAN_ENV = frozenset({"0", "false", "no", "off"}) + +_OBSERVE_NOTE = ( + "gate: {policy} found a violation that enforce would have stopped ({held}); " + "this repo's rollout level lets it through and records it in .chock/log/gate-events.jsonl." +) + +_AGENT_COMMIT_NOTE = ( + "gate: this commit is treated as an agent's ({signal}), so a waiver it adds " + "was not honoured; only waivers already in HEAD count. A person reviews and waives it, then " + f"commits from their own shell with {AGENT_COMMIT_ENV}=0." +) + + +def _config_agent_env(repo_root: Path) -> list[str]: + """Names under `agent_commit_env:` in `.chock/config.yaml`, read without a YAML parser. + + Only an inline list (`[A, B]`), one scalar, or a block list of `- NAME` lines is read; the + runner is stdlib-only, and anything else yields no names rather than a guess. + """ + try: + lines = repo_root.joinpath(*_CONFIG_PATH).read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeDecodeError): + return [] + raw: list[str] = [] + block = False + for line in lines: + if block: + item = _CONFIG_ITEM_RE.match(line) + if item: + raw.append(item.group("name")) + continue + if not line.strip() or line.lstrip().startswith("#"): + continue + block = False + key = _CONFIG_KEY_RE.match(line) + if key: + rest = key.group("rest").strip() + block = not rest + raw.extend(part for part in rest.strip("[]").split(",") if part.strip()) + names = (name.strip().strip("'\"") for name in raw) + return [name for name in names if _ENV_NAME_RE.match(name)] + + +def agent_signal(repo_root: Path | None = None) -> str | None: + """The marker that makes this an agent's commit, or None for a person's. + + An explicit `CHOCK_AGENT_COMMIT` decides: truthy is an agent, `0|false|no|off` is a person + whatever else is set (an escape hatch for a person's own git in an agent's terminal, which an + agent can set too). Otherwise `CLAUDECODE=1`, a non-empty `AI_AGENT`, or any non-empty variable + named in the repository's `agent_commit_env` marks an agent. + """ + env = os.environ + explicit = env.get(AGENT_COMMIT_ENV, "").strip().lower() + if explicit in _HUMAN_ENV: + return None + if explicit: + return AGENT_COMMIT_ENV + if env.get(CLAUDECODE_ENV) == "1": + return f"{CLAUDECODE_ENV}=1" + if env.get(AI_AGENT_ENV, "").strip(): + return AI_AGENT_ENV + configured = _config_agent_env(repo_root) if repo_root is not None else [] + return next((name for name in configured if env.get(name)), None) + + +def agent_commit(repo_root: Path | None = None) -> bool: + """True when the environment marks this commit as a coding agent's.""" + return agent_signal(repo_root) is not None + + +#: How far a gate may escalate in this repo: `rollout:` in `.chock/config.yaml`, else enforce. +ROLLOUT_ENV = "CHOCK_ROLLOUT" +ROLLOUT_OBSERVE, ROLLOUT_ASK, ROLLOUT_ENFORCE = "observe", "ask", "enforce" +ROLLOUT_RANK = {ROLLOUT_OBSERVE: 0, ROLLOUT_ASK: 1, ROLLOUT_ENFORCE: 2} +_CONFIG_ROLLOUT_RE = re.compile(r"^rollout:[ \t]*(?P[^#\n]*)") + + +def rollout_level(raw: object) -> str: + """The level `raw` names; anything absent, unreadable or unknown is enforce, never a looser guess.""" + value = raw.strip().strip("'\"") if isinstance(raw, str) else None + return value if value in ROLLOUT_RANK else ROLLOUT_ENFORCE + + +def rollout_from_text(text: str) -> str: + """The level a config's text names, read line by line and never by a YAML parser, so every reader + (the runtime, `chock status`, the baseline check) agrees: one top-level `rollout:` line, else enforce.""" + found = [m.group("rest") for m in map(_CONFIG_ROLLOUT_RE.match, text.splitlines()) if m] + return rollout_level(found[0]) if len(found) == 1 else ROLLOUT_ENFORCE + + +def _config_rollout(repo_root: Path) -> str: + """`rollout:` in the working tree's `.chock/config.yaml`; a symlink, or an unreadable file, is enforce.""" + path = repo_root.joinpath(*_CONFIG_PATH) + try: + if path.is_symlink(): + return ROLLOUT_ENFORCE + return rollout_from_text(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError): + return ROLLOUT_ENFORCE + + +def committed_rollout(repo_root: Path, ref: str) -> str: + """`rollout:` as committed at `ref`; a ref or file git cannot show is enforce.""" + try: + shown = subprocess.run( # noqa: S603 -- reading one committed file + ["git", "-C", str(repo_root), "show", f"{ref}:{'/'.join(_CONFIG_PATH)}"], # noqa: S607 + capture_output=True, + text=True, + encoding="utf-8", + timeout=10, + check=False, + ) + except (OSError, subprocess.SubprocessError, UnicodeDecodeError): + return ROLLOUT_ENFORCE + return rollout_from_text(shown.stdout) if shown.returncode == 0 else ROLLOUT_ENFORCE + + +def _stricter(*levels: str) -> str: + return max(levels, key=ROLLOUT_RANK.__getitem__) + + +def rollout(repo_root: Path, event: str, signal: str | None, base: str | None = None) -> str: + """The level in force. Nothing the actor being judged can edit may lower it: + + - at ci, the base's committed level caps the head's, so a pull request cannot lower its own gates; + - an agent (a tool call, the turn's end, an agent's commit) is held to HEAD's committed level too, + so an uncommitted edit to the config never lowers an agent's gates; + - `CHOCK_ROLLOUT` counts only for a person's own commit or push. + """ + level = _config_rollout(repo_root) + if event == "ci": + return _stricter(level, committed_rollout(repo_root, base)) if base else ROLLOUT_ENFORCE + if signal is not None or event not in _GIT_EVENTS: + return _stricter(level, committed_rollout(repo_root, "HEAD")) + override = os.environ.get(ROLLOUT_ENV, "").strip() + return override if override in ROLLOUT_RANK else level + + +def _judged_event(name: str, *, agent: bool) -> str: + """The event a gate is judged at: an agent's commit reads `agent-commit`, not `commit`.""" + return AGENT_COMMIT_EVENT if name == "commit" and agent else name + + +def _waiver_re(params: dict, event: str) -> re.Pattern[str] | None: + """The waiver regex for events that honour a waiver at all, else None.""" + pragma = params.get("allowlist_pragma") if event in WAIVABLE_EVENTS | HEAD_WAIVER_EVENTS else None + return re.compile(pragma) if pragma else None + + +def _honoured(pragma_re: re.Pattern[str] | None, text: str, head: frozenset[str] | None) -> bool: + """A waiver on `text` counts unless `head` is given and the text is not already committed.""" + return bool(pragma_re and pragma_re.search(text) and (head is None or text in head)) + 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 = params.get("allowlist_pragma") if event in WAIVABLE_EVENTS else None - pragma_re = re.compile(pragma) if pragma else None + pragma_re = _waiver_re(params, event) + agent = event in HEAD_WAIVER_EVENTS scan = params.get("scan", "added_lines") diff_filter = params.get("diff_filter", "ACMRT") matches: list[str] = [] for path in ctx.staged_paths(diff_filter): + head_text = ctx.committed_blob(path) if agent else "" + head = frozenset(head_text.splitlines()) if agent else None if path_re and path_re.search(path): - blob = ctx.staged_blob(path) + blob = head_text if agent else ctx.staged_blob(path) if not (pragma_re and pragma_re.search(blob)): matches.append(f"{path}: forbidden path") lines = ctx.staged_blob(path).splitlines() if scan == "staged_blob" else ctx.added_lines(path) for line in lines: - if pragma_re and pragma_re.search(line): + if _honoured(pragma_re, line, head): continue if content_re.search(line): matches.append(f"{path}: content pattern") @@ -306,18 +574,20 @@ 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 _count( + pattern: "re.Pattern[str]", lines: list[str], pragma: re.Pattern[str] | None, head: frozenset[str] | None = None +) -> int: + return sum(1 for line in lines if pattern.search(line) and not _honoured(pragma, line, head)) -def _kind_test_integrity(ctx: GateContext, params: dict, _event: str) -> GateResult: +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 + pragma_re = _waiver_re(params, event) + agent = event in HEAD_WAIVER_EVENTS matches: list[str] = [] added = removed = 0 @@ -327,11 +597,12 @@ def _kind_test_integrity(ctx: GateContext, params: dict, _event: str) -> GateRes 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): + added_lines = ctx.net_added_lines(path) + head = frozenset(ctx.committed_blob(path).splitlines()) if agent else None + if any(_honoured(pragma_re, line, head) for line in added_lines): continue - added += _count(assertion_re, added_lines, pragma_re) - removed += _count(assertion_re, ctx.removed_lines(path), pragma_re) + added += _count(assertion_re, added_lines, pragma_re, head) + removed += _count(assertion_re, ctx.removed_lines(path), pragma_re, head) if dummy_re and any(dummy_re.search(line) for line in added_lines): matches.append(f"{path}: vacuous assertion added") if removed > added: @@ -339,11 +610,186 @@ def _kind_test_integrity(ctx: GateContext, params: dict, _event: str) -> GateRes return GateResult(allowed=not matches, matches=matches) +#: A script gate's budget to answer. Past it the script has not decided, and an undecided +#: gate refuses. +_SCRIPT_TIMEOUT_SECONDS = ENGINE_BUDGET_SECONDS + +#: The exit codes a script gate speaks, the command-guard contract's: 0 allows, 1 blocks, 3 asks, +#: 4 warns. Anything else is not a verdict. The gate's declared action caps what a script may choose. +_SCRIPT_ALLOW, _SCRIPT_BLOCK, _SCRIPT_ASK, _SCRIPT_WARN = 0, 1, 3, 4 + +#: Interpreter-crash signatures, lower-cased: exit 1 with one of these, or with no words at all, +#: is a script that failed, not one that refused. +_CRASH_MARKERS = ("traceback (most recent call last)", "syntax error", "syntaxerror", "unexpected eof") + +_UNDECIDED = " -- refusing rather than allowing what it never judged" + +_FINDINGS_KEY = "findings" + + +def _spawn(script: Path, ctx: GateContext, payload: dict, timeout: float) -> subprocess.CompletedProcess[str]: + return subprocess.run( # noqa: S603 -- the script is the policy's own, named in its manifest + [sys.executable, str(script)], + input=json.dumps(payload), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + cwd=ctx.repo_root, + timeout=timeout, + check=False, + ) + + +def _is_finding(item: object) -> bool: + if not isinstance(item, dict): + return False + line = item.get("line") + return ( + isinstance(item.get("key"), str) + and isinstance(item.get("path"), str) + and isinstance(line, int) + and not isinstance(line, bool) + and isinstance(item.get("message"), str) + and isinstance(item.get("new", False), bool) + ) + + +def _findings_document(stdout: str) -> list[dict] | None: + """The findings a script printed as its one JSON object, or None when stdout is not that document.""" + try: + document = json.loads(stdout) + except ValueError: + return None + found = document.get(_FINDINGS_KEY) if isinstance(document, dict) else None + if not isinstance(found, list) or not all(_is_finding(item) for item in found): + return None + return found + + +#: A finding's optional `rule`: a short label, so a code fragment never reaches the log through it. +_RULE_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9 ._:/-]{0,63}") + + +def _rule_ids(findings: list[dict]) -> dict[str, int]: + """Findings per declared rule id; a finding with no valid `rule` is not counted.""" + declared = (item.get("rule") for item in findings) + return dict(Counter(rule for rule in declared if isinstance(rule, str) and _RULE_ID_RE.fullmatch(rule))) + + +def _new_findings(found: list[dict], baseline: list[dict]) -> list[dict]: + """The findings the baseline does not account for: per path and key, each baseline copy absolves one.""" + unspent = Counter((item["path"], item["key"]) for item in baseline) + fresh: list[dict] = [] + for item in found: + slot = (item["path"], item["key"]) + if item.get("new") or not unspent[slot]: + fresh.append(item) + else: + unspent[slot] -= 1 + return fresh + + +def _baseline_findings(ctx: GateContext, script: Path, material: dict, started: float) -> list[dict]: + """What the script finds in the baseline text of the change's files; empty when it cannot say. + + A new file has no baseline and is left out. A run that fails, times out, prints no findings + document, or has no budget left contributes nothing, which errs toward blocking. + """ + texts = {path: text for path in material["writes"] if (text := ctx.head_blob(path))} + remaining = _SCRIPT_TIMEOUT_SECONDS - (time.monotonic() - started) + if not texts or remaining <= 0: + return [] + try: + proc = _spawn(script, ctx, {**material, "writes": texts, "baseline": True}, remaining) + except (subprocess.TimeoutExpired, OSError): + return [] + return _findings_document(proc.stdout or "") or [] + + +def _exit_verdict(script: Path, proc: subprocess.CompletedProcess[str]) -> GateResult: + """The verdict of a script's exit code alone, with its own words as the reason.""" + spoken = ((proc.stderr or "") + (proc.stdout or "")).strip() + if proc.returncode == _SCRIPT_ALLOW: + return GateResult(allowed=True) + if proc.returncode == _SCRIPT_BLOCK: + if not spoken or any(marker in spoken.lower() for marker in _CRASH_MARKERS): + first = spoken.splitlines()[0] if spoken else "no reason given" + return GateResult(allowed=False, message=f"script gate: {script.name} crashed ({first}){_UNDECIDED}") + return GateResult(allowed=False, message=spoken) + if proc.returncode in (_SCRIPT_ASK, _SCRIPT_WARN): + word = "ask" if proc.returncode == _SCRIPT_ASK else "warn" + return GateResult(allowed=False, message=spoken or f"{script.name} chose to {word}", verdict=word) + first = spoken.splitlines()[0] if spoken else "" + detail = f": {first}" if first else "" + return GateResult(allowed=False, message=f"script gate: {script.name} exited {proc.returncode}{detail}{_UNDECIDED}") + + +def _judge_findings( + ctx: GateContext, script: Path, material: dict, proc: subprocess.CompletedProcess[str], started: float +) -> GateResult: + """Judge only the findings the change introduces; the change-run's exit code is the verdict for them.""" + if proc.returncode not in (_SCRIPT_ALLOW, _SCRIPT_BLOCK, _SCRIPT_ASK, _SCRIPT_WARN): + # A document followed by a crash or an unknown exit is undecided, never a clean pass. + return _exit_verdict(script, proc) + found = _findings_document(proc.stdout or "") or [] + if not found: + return GateResult(allowed=True, detail={"new_findings": 0, "baseline_findings": 0}) + baseline = _baseline_findings(ctx, script, material, started) + fresh = _new_findings(found, baseline) + counts = {"new_findings": len(fresh), "baseline_findings": len(baseline)} + if not fresh or proc.returncode == _SCRIPT_ALLOW: + return GateResult(allowed=True, detail=counts) + lines = [f"{item['path']}:{item['line']}: {item['message']}" for item in fresh] + verdict = {_SCRIPT_ASK: "ask", _SCRIPT_WARN: "warn"}.get(proc.returncode, "") + return GateResult( + allowed=False, matches=lines, verdict=verdict, detail=counts, rules=_rule_ids(fresh), findings=fresh + ) + + +def _kind_script(ctx: GateContext, params: dict, event: str) -> GateResult: + """Hand the material to the policy's own script and carry back its verdict. + + The script reads `{"event", "repo_root", "writes": {path: text}}` on stdin -- the staged + blobs at commit and push, the write itself at tool use and at the turn's end -- so one + script serves every surface the declarative kinds do, and reads them the same way. It + answers with an exit code: 0 allows; 1 refuses, 3 asks, 4 warns, with its own words on stderr. + A missing script, a crash or a timeout is undecided, and an undecided gate takes its declared + action, in this runner's words: a gate that cannot reach a decision never reports an allow it + never established. + + A script that also prints a findings document (`{"findings": [...]}`) is run once more on + the baseline text, and only the findings the change adds are judged (see spec/gate-dsl.md). + """ + named = str(params.get("script", "")) + script = ctx.repo_root / named + if not script.is_file(): + return GateResult(allowed=False, message=f"script gate: {named!r} is not installed{_UNDECIDED}") + writes = {path: ctx.staged_blob(path) for path in ctx.staged_paths()} + if not writes: + return GateResult(allowed=True) + material = {"event": event, "repo_root": str(ctx.repo_root), "writes": writes} + if ctx.session: + material["session"] = ctx.session + started = time.monotonic() + try: + proc = _spawn(script, ctx, material, _SCRIPT_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + budget = f"gave no verdict within {_SCRIPT_TIMEOUT_SECONDS}s" + return GateResult(allowed=False, message=f"script gate: {script.name} {budget}{_UNDECIDED}") + except OSError as exc: + return GateResult(allowed=False, message=f"script gate: {script.name} could not run ({exc}){_UNDECIDED}") + if _findings_document(proc.stdout or "") is None: + return _exit_verdict(script, proc) + return _judge_findings(ctx, script, material, proc, started) + + KINDS = { "content_regex": _kind_content_regex, "forbidden_ref": _kind_forbidden_ref, - "dependency_allowlist": _kind_dependency_allowlist, + _DEPENDENCY_KIND: _kind_dependency_allowlist, "test_integrity": _kind_test_integrity, + "script": _kind_script, } @@ -361,31 +807,49 @@ def _kind_test_integrity(ctx: GateContext, params: dict, _event: str) -> GateRes _MIN_COMPILED_PATH_DEPTH = 4 -def _log_outcome(gate_path: Path, event: str, spec: dict, result: GateResult) -> None: +def _write_log(chock_root: Path, record: dict) -> None: + """Append one record to `/log/gate-events.jsonl`, rotating past the size cap.""" + log_dir = chock_root / "log" + log_dir.mkdir(parents=True, exist_ok=True) + 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") + with log_path.open("a", encoding="utf-8") as fh: + fh.write( + json.dumps({"ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), **record}, ensure_ascii=False) + + "\n" + ) + + +def _log_outcome( + gate_path: Path, + event: str, + spec: dict, + result: GateResult, + verdict: str, + extra: Mapping[str, object] | None = None, +) -> None: """Append one outcome record. Best effort: never raises, never changes the verdict.""" try: - if os.environ.get(GATE_LOG_ENV) == "0": + if os.environ.get(GATE_LOG_ENV) == "0" and not _held(extra): return parents = gate_path.resolve().parents if len(parents) < _MIN_COMPILED_PATH_DEPTH or parents[2].name != "compiled": return - log_dir = parents[3] / "log" - log_dir.mkdir(parents=True, exist_ok=True) - 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": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "policy_id": parents[1].name, "surface": parents[0].name, "event": event, "kind": spec.get("kind"), - "verdict": "allow" if result.allowed else "block", + "verdict": verdict, "match_count": len(result.matches), "matches": result.matches[:_LOG_MATCH_CAP], + **result.detail, } - with log_path.open("a", encoding="utf-8") as fh: - fh.write(json.dumps(record, ensure_ascii=False) + "\n") + if result.rules: + record["rules"] = result.rules + record.update(extra or {}) + _write_log(parents[3], record) except Exception: # noqa: BLE001 -- best effort logging: never raises, never changes the verdict return @@ -393,9 +857,43 @@ def _log_outcome(gate_path: Path, event: str, spec: dict, result: GateResult) -> #: 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"} +_EVENT_NAME = { + "pre-commit": "commit", + "commit-msg": "commit", + "pre-push": "push", + "pre-tool-use": "tool_use", + "stop": "tool_use", +} + +STOP_EVENT = "stop" + +AGENT_EVENTS = ("pre-tool-use", STOP_EVENT) + +#: `script_base` value naming the gate file's own directory as where `params.script` lives. +SCRIPT_BASE_GATE = "gate" + +#: A gate skips only its own policy's folders, so a policy's evals (which carry the very +#: content its gate refuses) never trip it. The rest of `.chock/`, including other policies' +#: compiled output and the vendored runtimes, stays in scope: a file planted there is judged. +COMPILED_PREFIX = ".chock/compiled/" +POLICIES_PREFIX = ".agents/policies/" -AGENT_EVENTS = ("pre-tool-use", "stop") + +def own_paths(gate_path: Path) -> tuple[str, ...]: + """Prefixes a gate never judges: its own policy's shipped and compiled files.""" + parents = gate_path.resolve().parents + if len(parents) < _MIN_COMPILED_PATH_DEPTH or parents[2].name != "compiled": + return () + policy = parents[1].name + return (f"{COMPILED_PREFIX}{policy}/", f"{POLICIES_PREFIX}{policy}/") + + +def _params(gate_path: Path, spec: dict) -> dict: + """The gate's params, with a packaged script gate's program located beside the gate file.""" + params = dict(spec.get("params", {})) + if spec.get("script_base") == SCRIPT_BASE_GATE: + params["script"] = str(gate_path.resolve().parent / str(params.get("script", ""))) + return params def _context( @@ -406,11 +904,14 @@ def _context( base: str | None, head_ref: str | None, writes: Mapping[str, str] | None, + added: Mapping[str, str] | None = None, + own: Sequence[str] = (), + session: Mapping[str, object] | None = 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") + repo_root=repo_root, push_stdin=push_stdin, base=base, head_ref=head_ref, scope=spec.get("paths"), own=own ) if spec.get("kind") not in WRITE_PATH_KINDS: print( @@ -420,10 +921,368 @@ def _context( file=sys.stderr, ) return None - return WriteContext(repo_root=repo_root, writes=writes or {}, scope=spec.get("paths")) + return WriteContext( + repo_root=repo_root, + writes=writes or {}, + scope=spec.get("paths"), + added=added, + own=own, + stop=event == STOP_EVENT, + session=session, + ) + + +#: What a gate does with a violation. The declared action is the ceiling: a script may choose a +#: gentler verdict at run time, never a harsher one. +ACTION_BLOCK, ACTION_ASK, ACTION_WARN = "block", "ask", "warn" +_ACTION_RANK = {ACTION_WARN: 0, ACTION_ASK: 1, ACTION_BLOCK: 2} +_ROLLOUT_CEILING = {ROLLOUT_OBSERVE: ACTION_WARN, ROLLOUT_ASK: ACTION_ASK, ROLLOUT_ENFORCE: ACTION_BLOCK} + +#: Exit codes an agent-event run reports beyond 0 (allow), 1 (block) and 2 (cannot judge): the +#: command-guard contract's own, so the vendored runtimes read a gate as they read a guard. +EXIT_ASK, EXIT_WARN = 3, 4 + +#: The verdict of a run that could not judge, beside the actions a gate can take. +VERDICT_ERROR = "error" + +_GIT_EVENTS = ("pre-commit", "pre-push", "commit-msg") + + +def _policy_id(gate_path: Path) -> str | None: + """The policy a compiled gate belongs to, from `.chock/compiled///`, else None.""" + parents = gate_path.resolve().parents + if len(parents) < _MIN_COMPILED_PATH_DEPTH or parents[2].name != "compiled": + return None + return parents[1].name + + +def _verdict(result: GateResult, declared: str, level: str = ROLLOUT_ENFORCE) -> str: + """`allow`, or the action this violation takes: what the kind chose, capped by the declared action and the rollout.""" + if result.allowed: + return "allow" + chosen = result.verdict or declared + return min(chosen, declared, _ROLLOUT_CEILING[level], key=_ACTION_RANK.__getitem__) + + +#: One sentence that tells the reader to write a `chock: allow` waiver: an agent's waiver is never honoured. +_WAIVER_SENTENCE_RE = re.compile(r"(?:(?<=\.\s)|^)(?:(?!\.\s)[^\n])*?chock: allow(?:(?!\.\s)[^\n])*\.?", re.MULTILINE) +_AGENT_WAIVER_HINT = "If this must stay, ask a person to review it; an agent cannot add the waiver." +_CUSTOMISE_POINTER = ( + "A person customises this policy's rules in .agents/policies/{policy}/manifest.yaml; an agent cannot loosen them." +) -def run( +def _for_agent(text: str) -> str: + """The text with any waiver instruction replaced by the agent's way forward.""" + return _WAIVER_SENTENCE_RE.sub(_AGENT_WAIVER_HINT, text) + + +def _summary(result: GateResult, policy_id: str | None) -> str: + """One line saying how many new findings the gate refused on.""" + return ( + f"{policy_id or 'gate'} refused {len(result.matches)} new finding(s) (only lines this change adds are judged)." + ) + + +def _reason(result: GateResult, spec: dict, event: str = "commit", policy_id: str | None = None) -> str: + """The refusal. A findings gate puts its findings first; at tool use the agent gets them alone.""" + agent = event in HEAD_WAIVER_EVENTS + policy = result.message or spec.get("message", "") + matches = list(result.matches) + if agent: + policy, matches = _for_agent(policy), [_for_agent(m) for m in matches] + if "new_findings" not in result.detail: + return "\n".join([policy, *(f" - {m}" for m in matches)]) + if event == TOOL_USE_EVENT: + return "\n".join([*matches, _CUSTOMISE_POINTER.format(policy=policy_id or "")]) + return "\n".join([*matches, _summary(result, policy_id), policy]) + + +def _report_refusal(result: GateResult, spec: dict, judged: str, signal: str | None, policy_id: str | None) -> None: + reason = _reason(result, spec, judged, policy_id) + print(_encodable(ci_inert(reason) if judged == "ci" else reason, sys.stderr), file=sys.stderr) + if judged == AGENT_COMMIT_EVENT and signal: + print(_AGENT_COMMIT_NOTE.format(signal=signal), file=sys.stderr) + + +def _annotation(policy_id: str | None, reason: str) -> str: + """A GitHub Actions `::warning::` workflow command; the runner reads it from stdout.""" + title = _escape_property(f"chock {policy_id or 'gate'}") + return f"::warning title={title}::{_escape_data(reason)}" + + +#: GitHub shows at most this many error and this many warning annotations per step. +_ANNOTATION_CAP = 10 +_GITHUB_ACTIONS_ENV = "GITHUB_ACTIONS" +_STEP_SUMMARY_ENV = "GITHUB_STEP_SUMMARY" + + +def _github_actions() -> bool: + return os.environ.get(_GITHUB_ACTIONS_ENV) == "true" + + +def _escape_data(text: str) -> str: + return text.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") + + +def _escape_property(text: str) -> str: + return _escape_data(text).replace(":", "%3A").replace(",", "%2C") + + +def _cell(value: object) -> str: + """Text that stays inside one markdown table cell or bullet, whatever a finding holds: no new row, + no HTML, and no link, image or mention (a finding's text comes from the pull request).""" + text = " ".join(str(value).split()) + for char in "\\|[]!@": + text = text.replace(char, "\\" + char) + return text.replace("`", "'").replace("<", "<") + + +_LINE_BREAK = re.compile(r"\r\n|\r|\n") + + +def ci_inert(text: str) -> str: + """`text` with no line a GitHub runner would parse as a workflow command. + + The runner reads commands from stdout and stderr alike, a line at a time (CR, LF or CRLF), after + trimming it. A finding's path or message comes from the pull request, so at ci a line that would + start with `::` is prefixed; every other line is printed as it was. + """ + lines = _LINE_BREAK.split(text) + return "\n".join(f"> {line}" if line.strip().startswith("::") else line for line in lines) + + +def _encodable(text: str, stream: Any) -> str: + """`text` as `stream` can write it: a lone surrogate or a character its encoding lacks becomes `?`.""" + encoding = getattr(stream, "encoding", None) or "utf-8" + return text.encode(encoding, "replace").decode(encoding, "replace") + + +def _repo_relative(path: str, repo_root: Path) -> str: + """The finding's path as a repo-relative, forward-slash path; empty when it is outside the repo.""" + text = path.replace("\\", "/") + if text.startswith("/") or re.match(r"[A-Za-z]:/", text): + try: + text = Path(text).resolve().relative_to(repo_root.resolve()).as_posix() + except (ValueError, OSError): + return "" + parts = [part for part in text.split("/") if part not in ("", ".")] + return "" if ".." in parts else "/".join(parts) + + +def _workflow_command(level: str, finding: dict, policy_id: str | None, repo_root: Path) -> str: + """One `::error` or `::warning` command for a finding; every untrusted part is escaped.""" + rule = finding.get("rule") + title = f"chock {policy_id or 'gate'}" + (f": {rule}" if isinstance(rule, str) and rule else "") + props = [] + if path := _repo_relative(finding["path"], repo_root): + props.append(f"file={_escape_property(path)}") + if finding["line"] > 0: + props.append(f"line={finding['line']}") + props.append(f"title={_escape_property(title)}") + return f"::{level} {','.join(props)}::{_escape_data(finding['message'])}" + + +#: What names one step of one job attempt: every gate the step runs shares its annotation budget. +_STEP_KEYS = ("GITHUB_RUN_ID", "GITHUB_RUN_ATTEMPT", "GITHUB_JOB", "GITHUB_ACTION") + + +def _budget_file() -> Path | None: + """This step's annotation budget under `RUNNER_TEMP`, or None off a runner (each gate then caps alone).""" + temp = os.environ.get("RUNNER_TEMP") + if not temp: + return None + key = "-".join(re.sub(r"[^A-Za-z0-9_.-]", "_", os.environ.get(name, "")) for name in _STEP_KEYS) + return Path(temp) / f"chock-annotations-{key}.json" + + +def _take_budget(level: str, wanted: int) -> int: + """How many of `wanted` annotations of `level` this gate may print, and record them as spent. + + A budget that cannot be read or written prints none: the step summary still lists every finding. + """ + path = _budget_file() + if path is None: + return min(wanted, _ANNOTATION_CAP) + try: + spent = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {} + used = spent.get(level, 0) if isinstance(spent, dict) else _ANNOTATION_CAP + used = used if isinstance(used, int) and not isinstance(used, bool) and used >= 0 else _ANNOTATION_CAP + take = max(0, min(wanted, _ANNOTATION_CAP - used)) + path.write_text(json.dumps({**spent, level: used + take}), encoding="utf-8") + except (OSError, ValueError): + return 0 + return take + + +def _annotation_plan(findings: list[dict], level: str) -> tuple[list[dict], list[dict]]: + """The findings to annotate within this step's budget and those left out, ordered by path then line.""" + ordered = sorted(findings, key=lambda item: (item["path"], item["line"], item["key"])) + take = _take_budget(level, len(ordered)) if ordered else 0 + return ordered[:take], ordered[take:] + + +def _step_summary(result: GateResult, policy_id: str | None, verdict: str, overflow: list[dict]) -> str: + """The markdown appended to the step summary: the gate's row, then any findings past the cap.""" + counts = result.detail + lines = [ + "### Chock gate", + "", + "| policy | new findings | baseline | verdict |", + "|---|---|---|---|", + f"| {_cell(policy_id or 'gate')} | {counts['new_findings']} | {counts['baseline_findings']} | {verdict} |", + ] + if overflow: + lines += ["", f"#### Not annotated ({len(overflow)}: GitHub shows 10 errors and 10 warnings per step)", ""] + for item in overflow: + rule = f"[{_cell(item['rule'])}] " if isinstance(item.get("rule"), str) and item["rule"] else "" + lines.append(f"- {_cell(item['path'])}:{item['line']}: {rule}{_cell(item['message'])}") + return "\n".join(lines) + "\n" + + +def _annotate(result: GateResult, verdict: str, policy_id: str | None, repo_root: Path) -> None: + """On GitHub Actions, print one workflow command per new finding and append the step summary. + + Output only: nothing here reads or changes a verdict or exit code. + """ + if not _github_actions() or "new_findings" not in result.detail: + return + level = "error" if verdict == ACTION_BLOCK else "warning" + shown: list[dict] = [] + overflow: list[dict] = [] + if verdict != "allow": + shown, overflow = _annotation_plan(result.findings, level) + for item in shown: + print(_encodable(_workflow_command(level, item, policy_id, repo_root), sys.stdout)) + summary = os.environ.get(_STEP_SUMMARY_ENV) + if summary: + try: + with open(summary, "a", encoding="utf-8", errors="replace") as handle: + handle.write(_step_summary(result, policy_id, verdict, overflow)) + except OSError as exc: + print(f"gate: cannot write the step summary: {exc}", file=sys.stderr) + + +def _annotate_safely(result: GateResult, verdict: str, policy_id: str | None, repo_root: Path) -> None: + """`_annotate`, whose failure is reported and never reaches the verdict, the refusal or the log.""" + try: + _annotate(result, verdict, policy_id, repo_root) + except Exception as exc: # noqa: BLE001 -- output only: an annotation failure never changes the verdict + print(f"gate: GitHub annotations skipped: {type(exc).__name__}", file=sys.stderr) + + +def allowed_ids() -> set[str]: + """Policy ids a person named in `CHOCK_ALLOW`, comma separated.""" + return {name.strip() for name in os.environ.get(ALLOW_ENV, "").split(",") if name.strip()} + + +def _ask_refusal(policy_id: str | None, signal: str | None) -> str: + """Why a hook refuses an `ask`, and the one way through: a person's explicit override.""" + if policy_id is None: + return f"gate: this gate asks a person, a hook cannot prompt, and it has no policy id for {ALLOW_ENV} to name." + person = f"{ALLOW_ENV}={policy_id}" + if signal: + person = f"{person} {AGENT_COMMIT_ENV}=0" + who = f" This is treated as an agent's ({signal}), and an agent cannot answer for the person." + else: + who = "" + return ( + f"gate: {policy_id} asks a person to decide, and a hook cannot prompt.{who} " + f"To go ahead, a person runs this one command from their own shell with {person} set." + ) + + +def _ask_answered(policy_id: str | None, signal: str | None) -> bool: + """A person's `CHOCK_ALLOW` names this policy and no agent marker is on the command.""" + return policy_id is not None and policy_id in allowed_ids() and signal is None + + +def _deliver(verdict: str, result: GateResult, spec: dict, event: str, policy_id: str | None) -> int: + """Report a `warn` or `ask` in this event's own channel and return its exit code.""" + reason = _reason(result, spec, TOOL_USE_EVENT if event in AGENT_EVENTS else event, policy_id) + if event in AGENT_EVENTS: + print(reason, file=sys.stderr) + # The turn's end has nobody to ask and nothing to withhold: a Stop ask is a warning. + return EXIT_ASK if verdict == ACTION_ASK and event != STOP_EVENT else EXIT_WARN + if event == "ci": + if _github_actions() and result.findings: + print(_encodable(ci_inert(f"gate: warning: {reason}"), sys.stderr), file=sys.stderr) + else: + print(_encodable(_annotation(policy_id, reason), sys.stdout)) + return 0 + print(f"gate: warning: {reason}", file=sys.stderr) + return 0 + + +def _held(extra: Mapping[str, object] | None) -> bool: + """A record of what the rollout level let through: the evidence for enforcing, so it is written + even when `CHOCK_GATE_LOG=0` turns the rest of the log off.""" + return bool(extra and "would_action" in extra) + + +def _actor(signal: str | None) -> dict[str, object]: + """The agent marker a log record carries, when an agent acted.""" + return {"agent": signal} if signal else {} + + +def _held_record(held: str, level: str) -> dict[str, object]: + """What the log keeps of an action the rollout level lowered: the evidence for switching to enforce.""" + return {"rollout": level, "would_action": held, "would_block": held == ACTION_BLOCK} + + +def _log_script_hook( + policy_id: str, event: str, verdict: str, repo_root: Path, extra: Mapping[str, object] | None = None +) -> None: + """Record a script-backed hook's warn or ask; best effort like every gate log write.""" + try: + if os.environ.get(GATE_LOG_ENV) == "0" and not _held(extra): + return + record = {"policy_id": policy_id, "surface": "git-hook", "event": _EVENT_NAME.get(event, event)} + record.update({"kind": "script-hook", "verdict": verdict, "match_count": 0, "matches": []}) + record.update(extra or {}) + _write_log(repo_root / _CONFIG_PATH[0], record) + except Exception: # noqa: BLE001 -- best effort logging: never raises, never changes the verdict + return + + +def script_verdict(policy_id: str, event: str, code: int, repo_root: Path) -> int: + """The exit a script-backed git hook takes for its script's exit `code` (3 asks, 4 warns). + + The script already printed its own words. A warn is allowed; an ask is refused unless a person + named the policy in `CHOCK_ALLOW` and no agent marker is on the command. + """ + signal = agent_signal(repo_root) + level = rollout(repo_root, event, signal) + if code == EXIT_WARN: + _log_script_hook(policy_id, event, ACTION_WARN, repo_root, _actor(signal)) + return 0 + if level == ROLLOUT_OBSERVE: + print(_OBSERVE_NOTE.format(policy=policy_id, held=ACTION_ASK), file=sys.stderr) + _log_script_hook( + policy_id, event, ACTION_WARN, repo_root, {**_held_record(ACTION_ASK, level), **_actor(signal)} + ) + return 0 + if _ask_answered(policy_id, signal): + print(f"gate: {policy_id} asked; allowed by {ALLOW_ENV}.", file=sys.stderr) + _log_script_hook(policy_id, event, "allow", repo_root, {"override": ALLOW_ENV, **_actor(signal)}) + return 0 + print(_ask_refusal(policy_id, signal), file=sys.stderr) + _log_script_hook(policy_id, event, ACTION_ASK, repo_root, _actor(signal)) + return 1 + + +@dataclass(frozen=True) +class Evaluation: + """A gate run up to its verdict: what the kind found, and the actor and rollout level it was judged under.""" + + spec: dict + result: GateResult + level: str + signal: str | None + judged: str + + +def evaluate( gate_path: Path, event: str, push_stdin: str | None, @@ -431,44 +1290,111 @@ def run( base: str | None = None, head_ref: str | None = None, writes: Mapping[str, str] | None = None, -) -> int: + added: Mapping[str, str] | None = None, + session: Mapping[str, object] | None = None, +) -> Evaluation | tuple[int, str]: + """Run a compiled gate's kind without reporting or logging: an Evaluation, or (exit code, verdict) when it ends early.""" gate_path = Path(gate_path) if not gate_path.exists(): - return 0 + print( + "gate: the compiled gate this hook names is missing, so the install is incomplete. " + "Run `chock sync --repo .` to rebuild the compiled gates.", + file=sys.stderr, + ) + return 2, VERDICT_ERROR try: spec = json.loads(gate_path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError) as exc: - print(f"gate: cannot read {gate_path}: {exc}", file=sys.stderr) - return 2 + print(f"gate: cannot read the compiled gate this hook names: {type(exc).__name__}", file=sys.stderr) + return 2, VERDICT_ERROR if event == "ci": name, covered = "ci", "commit" in spec.get("on", []) else: name = _EVENT_NAME.get(event, event) covered = name in spec.get("on", []) if not covered: - return 0 + return 0, "allow" kind = KINDS.get(spec.get("kind")) if kind is None: print(f"gate: unknown kind {spec.get('kind')!r}", file=sys.stderr) - return 2 - ctx = _context(event, spec, repo_root, push_stdin, base, head_ref, writes) + return 2, VERDICT_ERROR + declared = spec.get("action", ACTION_BLOCK) + if declared not in _ACTION_RANK: + print(f"gate: unknown action {declared!r} (block, ask or warn)", file=sys.stderr) + return 2, VERDICT_ERROR + ctx = _context(event, spec, repo_root, push_stdin, base, head_ref, writes, added, own_paths(gate_path), session) if ctx is None: - return 2 + return 2, VERDICT_ERROR 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. " "Fetch it (e.g. actions/checkout with fetch-depth: 0) or pass a base that exists.", file=sys.stderr, ) - return 2 - result = kind(ctx, spec.get("params", {}), name) - _log_outcome(gate_path, name, spec, result) - if not result.allowed: - print(result.message or spec.get("message", ""), file=sys.stderr) - for m in result.matches: - print(f" - {m}", file=sys.stderr) - return 1 - return 0 + return 2, VERDICT_ERROR + signal = agent_signal(repo_root) + judged = _judged_event(name, agent=signal is not None) + result = kind(ctx, _params(gate_path, spec), judged) + return Evaluation(spec, result, rollout(repo_root, event, signal, base), signal, judged) + + +def judge( + gate_path: Path, + event: str, + push_stdin: str | None, + repo_root: Path, + base: str | None = None, + head_ref: str | None = None, + writes: Mapping[str, str] | None = None, + added: Mapping[str, str] | None = None, + session: Mapping[str, object] | None = None, +) -> tuple[int, str]: + """Run a compiled gate: (exit code, verdict), the verdict being allow, block, ask, warn or error.""" + outcome = evaluate(gate_path, event, push_stdin, repo_root, base, head_ref, writes, added, session) + if isinstance(outcome, tuple): + return outcome + if event == "ci": + declared = outcome.spec.get("action", ACTION_BLOCK) + verdict = _verdict(outcome.result, declared, outcome.level) + _annotate_safely(outcome.result, verdict, _policy_id(gate_path), repo_root) + return _conclude(gate_path, outcome.spec, event, outcome.judged, outcome.result, (outcome.signal, outcome.level)) + + +def run(gate_path: Path, event: str, push_stdin: str | None, repo_root: Path, **options: Any) -> int: + """Run a compiled gate and return its process exit code: 0 allow, 1 block, 2 cannot judge, 3 ask, 4 warn.""" + return judge(gate_path, event, push_stdin, repo_root, **options)[0] + + +def _conclude( + gate_path: Path, spec: dict, event: str, judged: str, result: GateResult, actor: tuple[str | None, str] +) -> tuple[int, str]: + """Log the outcome and act on it: (exit code, verdict), and the words in the channel this event has. + + `actor` is the agent marker (or None) and the rollout level in force. + """ + signal, level = actor + declared = spec.get("action", ACTION_BLOCK) + verdict = _verdict(result, declared, level) + held = _verdict(result, declared) + policy_id = _policy_id(gate_path) + answered = verdict == ACTION_ASK and event in _GIT_EVENTS and _ask_answered(policy_id, signal) + extra = {**(_held_record(held, level) if held != verdict else {}), **_actor(signal)} + if answered: + extra["override"] = ALLOW_ENV + _log_outcome(gate_path, judged, spec, result, "allow" if answered else verdict, extra) + if held != verdict: + print(_OBSERVE_NOTE.format(policy=policy_id or "gate", held=held), file=sys.stderr) + if answered: + print(f"gate: {policy_id} asked; allowed by {ALLOW_ENV}.", file=sys.stderr) + return 0, "allow" + if verdict == ACTION_BLOCK: + _report_refusal(result, spec, judged, signal, policy_id) + return 1, verdict + if verdict == ACTION_ASK and event in _GIT_EVENTS: + print(_reason(result, spec, judged, policy_id), file=sys.stderr) + print(_ask_refusal(policy_id, signal), file=sys.stderr) + return 1, verdict + return (0 if verdict == "allow" else _deliver(verdict, result, spec, event, policy_id)), verdict def _repo_root() -> Path: @@ -481,19 +1407,42 @@ 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.""" +def _texts(raw: str, key: str) -> dict[str, str]: + """One {path: text} map off the stdin payload. 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): + texts = payload.get(key) if isinstance(payload, dict) else None + if not isinstance(texts, dict): return {} - return {str(path): str(text) for path, text in writes.items() if isinstance(text, str)} + return {str(path): str(text) for path, text in texts.items() if isinstance(text, str)} + + +def _session(raw: str) -> dict | None: + """The session-log handle the hook handler put on stdin, if any.""" + try: + payload = json.loads(raw or "{}") + except json.JSONDecodeError: + return None + session = payload.get("session") if isinstance(payload, dict) else None + return session if isinstance(session, dict) else None + + +def _writes(raw: str) -> dict[str, str]: + """The files this event puts under judgement, whole.""" + return _texts(raw, "writes") + + +def _utf8_streams() -> None: + """Speak UTF-8 on stdin and stderr whatever the console code page, so a match cannot crash the verdict.""" + for stream in (sys.stdin, sys.stderr): + if hasattr(stream, "reconfigure"): + stream.reconfigure(encoding="utf-8", errors="replace") def main(argv: list[str] | None = None) -> int: + _utf8_streams() 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") @@ -501,15 +1450,33 @@ def main(argv: list[str] | None = None) -> int: 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)") + verdict_p = sub.add_parser("script-verdict", help="Settle a script-backed git hook's exit 3 (ask) or 4 (warn)") + verdict_p.add_argument("--policy", required=True, help="Policy id the script belongs to") + verdict_p.add_argument("--event", required=True, choices=_GIT_EVENTS) + verdict_p.add_argument("--exit", required=True, type=int, choices=[EXIT_ASK, EXIT_WARN], dest="code") args = parser.parse_args(argv) + if args.command == "script-verdict": + return script_verdict(args.policy, args.event, args.code, _repo_root()) + 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())) + # cannot be read back from it. {"writes": {"": ""}, "added": {"": ""}}, + # `added` present only for an edit, carrying the text it introduces. + # Paths are named from the directory the runtime works in, which may sit below the git top-level. + raw = sys.stdin.read() + return run( + Path(args.gate), + args.event, + None, + Path.cwd(), + writes=_writes(raw), + added=_texts(raw, "added"), + session=_session(raw), + ) 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 db789c2..cd96add 100755 --- a/.chock/bin/gemini_cli.py +++ b/.chock/bin/gemini_cli.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("gemini_cli"). Do not hand-edit, except the +# Generated by agentseam 0.3.5 -- 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. # @@ -9,20 +9,29 @@ from __future__ import annotations +import contextlib +import io import json _json = json +import os import sys +import hashlib as _chock_hashlib +import fnmatch as _chock_fnmatch +import re as _chock_re import os as _chock_os import shlex as _chock_shlex +import shutil as _chock_shutil import subprocess as _chock_subprocess +import time as _chock_time from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.5) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -200,7 +209,7 @@ def tool_input_of(raw): """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" if isinstance(raw, dict): return raw - if isinstance(raw, str) and raw[:1] == "{": + if isinstance(raw, str) and raw.lstrip()[:1] == "{": try: parsed = _json.loads(raw) except (ValueError, RecursionError): @@ -530,6 +539,8 @@ def hj_respond(cfg, decision, event, wire=None): _WINDOWS_KEYS = ("commandWindows", "windows") +_MATCHER_EVENTS = (PRE_TOOL, POST_TOOL, TOOL_FAILURE) + def _hook_dict(cfg, command): entry = {"type": "command", "command": command} for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): @@ -546,20 +557,21 @@ def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): if not name: continue rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: rule["matcher"] = matcher rules.append(rule) return rules def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): gates = cfg["verdicts"]["answer_events"] + allow_silent = cfg["verdicts"].get("allow_silent_events", ()) hooks = {} for ev in canonical_events: name = reverse.get(ev) if not name: continue entry = {"command": command} - if fail_closed and name in gates: + if fail_closed and name in gates and name not in allow_silent: entry["failClosed"] = True hooks.setdefault(name, []).append(entry) return {"version": 1, "hooks": hooks} @@ -584,7 +596,7 @@ def _default_wrapper(cfg, reverse, canonical_events, command, matcher): if not name: continue entry = {"hooks": [_hook_dict(cfg, command)]} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: entry["matcher"] = matcher hooks.setdefault(name, []).append(entry) if hook_entry.get("group"): @@ -614,7 +626,7 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos AGENT = "gemini_cli" -VENDOR = {'agent': 'gemini_cli', 'claims': {'client_types': (None, 'gemini_cli', 'gemini'), 'event_key': ('hook_event_name',), 'mode': 'marker', 'reject_markers': ('timestamp', 'project_path', 'prompt_id', 'turn_id'), 'reject_probes': ('looks_like_claude_code',)}, 'config_format': 'json', 'config_path': '.gemini/settings.json', 'display': 'Gemini CLI', 'events': {'AfterAgent': 'stop', 'AfterTool': 'post_tool', 'BeforeAgent': 'prompt_submit', 'BeforeTool': 'pre_tool', 'PreCompress': 'pre_compact', 'SessionEnd': 'session_end', 'SessionStart': 'session_start'}, 'evidence': {'claims': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'vendor-source', 'date': '2026-08-28', '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', 'tool_input.new_str'), 'content_only_for_write_tools': True, 'cwd': ('cwd',), 'output': ('tool_output', 'tool_response'), 'path': ('tool_input.file_path', 'tool_input.absolute_path', 'tool_input.path'), 'prompt': ('prompt', 'user_message'), 'session_id': ('session_id',), 'tool': ('tool_name',)}, 'hook_entry': {'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'tools': {'shell': ('run_shell_command',), 'write': ('write_file', 'replace')}, 'verdicts': {'answer_events': ('AfterAgent', 'AfterTool', 'BeforeAgent', 'BeforeTool'), 'bare_allow': 'inert', 'degrade_notes': {'escalate': '%s (confirmation required; %s cannot prompt from a hook)'}, 'gates': {'AfterAgent': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'AfterTool': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'BeforeAgent': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'BeforeTool': {'grammar': 'G1', 'honours_escalate': True, 'honours_transform': True}}, 'reason_defaults': {'escalate': 'policy requires confirmation', 'escalate_gate': 'confirmation required'}, 'transform_grammar': 'hook_specific_tool_input', 'vocabulary': ('allow', 'ask', 'deny'), 'vocabulary_basis': 'verified', 'words': {'allow': 'allow', 'block': 'deny', 'escalate': 'ask'}}} +VENDOR = {'agent': 'gemini_cli', 'claims': {'client_types': (None, 'gemini_cli', 'gemini'), 'event_key': ('hook_event_name',), 'mode': 'marker', 'notes': "timestamp is in Gemini's documented base input too: rejecting it is a deliberate tie-break toward Tabnine, which sends the same shape and whose deny/allow wire is identical (ask/transform degrade to deny); a declining detect() would allow silently. Name gemini_cli to get ask/transform.", 'reject_markers': ('timestamp', 'project_path', 'prompt_id', 'turn_id'), 'reject_probes': ('looks_like_claude_code',)}, 'config_format': 'json', 'config_path': '.gemini/settings.json', 'display': 'Gemini CLI', 'events': {'AfterAgent': 'stop', 'AfterTool': 'post_tool', 'BeforeAgent': 'prompt_submit', 'BeforeTool': 'pre_tool', 'PreCompress': 'pre_compact', 'SessionEnd': 'session_end', 'SessionStart': 'session_start'}, 'evidence': {'claims': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'vendor-source', 'date': '2026-08-28', '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', 'tool_input.new_str'), 'content_only_for_write_tools': True, 'cwd': ('cwd',), 'output': ('tool_output', 'tool_response'), 'path': ('tool_input.file_path', 'tool_input.absolute_path', 'tool_input.path'), 'prompt': ('prompt', 'user_message'), 'session_id': ('session_id',), 'tool': ('tool_name',)}, 'hook_entry': {'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'tools': {'shell': ('run_shell_command',), 'write': ('write_file', 'replace')}, 'verdicts': {'answer_events': ('AfterAgent', 'AfterTool', 'BeforeAgent', 'BeforeTool'), 'bare_allow': 'inert', 'degrade_notes': {'escalate': '%s (confirmation required; %s cannot prompt from a hook)'}, 'gates': {'AfterAgent': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'AfterTool': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'BeforeAgent': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'BeforeTool': {'grammar': 'G1', 'honours_escalate': True, 'honours_transform': True}}, 'reason_defaults': {'escalate': 'policy requires confirmation', 'escalate_gate': 'confirmation required'}, 'transform_grammar': 'hook_specific_tool_input', 'vocabulary': ('allow', 'ask', 'deny'), 'vocabulary_basis': 'verified', 'words': {'allow': 'allow', 'block': 'deny', 'escalate': 'ask'}}} def claims(raw): @@ -665,19 +677,60 @@ def degrade(decision, event): # >>> agentseam handler >>> +ENGINE_BUDGET_SECONDS = 30 + +def engine_deadline(): + """The monotonic instant this invocation's budget runs out.""" + return _chock_time.monotonic() + ENGINE_BUDGET_SECONDS + +def engine_remaining(deadline): + """Seconds left before `deadline`, never negative.""" + return max(deadline - _chock_time.monotonic(), 0.0) + +def time_left(deadline, cmd): + """Seconds left for a subprocess; raises TimeoutExpired when none are, so the caller's timeout path runs.""" + left = deadline - _chock_time.monotonic() + if left <= 0: + raise _chock_subprocess.TimeoutExpired(cmd, 0) + return left + +def allowed_seconds(expired): + """The time a timed-out call was actually given, as text (what the deadline left it, not the full budget).""" + return f'{max(float(expired.timeout or 0), 0.0):.1f}s' + 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') +_CRASH_MARKERS = ('traceback (most recent call last)', 'syntax error', 'syntaxerror', 'unexpected eof') + +TOOL_UNKNOWN = 'unknown' + +ARGV_FALLBACK_ENV = 'CHOCK_ARGV_FALLBACK' + +_POSIX_BASH = ('bash', '/bin/bash', '/usr/bin/bash') + +_GIT_FOR_WINDOWS = ('C:\\Program Files\\Git', 'C:\\Program Files (x86)\\Git') + +_GIT_BASH_DIRS = (('bin',), ('usr', 'bin')) + +_BASH_EXE = 'bash.exe' + +_WINDOWS_STUB_DIRS = ('/system32/', '/windowsapps/') + +_COREUTILS_MARKER = 'sed.exe' + +_WINDOWS = 'nt' + +_FOUND_BASH = {} GATE_LOG_ENV = 'CHOCK_GATE_LOG' _LOG_MAX_BYTES = 1048576 -_GUARD_TIMEOUT_SECONDS = 30 +_BASH_PROBE_SECONDS = 10 GUARD_BLOCKED = 'blocked' @@ -701,45 +754,109 @@ def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: return _chock_Path(argv[i + 1]) return None -def find_bash(guard: _chock_Path) -> str | None: - """First interpreter that can actually see `guard`, or None.""" - for candidate in _BASH_CANDIDATES: +def _git_roots() -> list[_chock_Path]: + """Git for Windows install roots: from `git` on PATH (cmd/ or mingw64/bin/), then the defaults.""" + git = _chock_shutil.which('git') + near = [_chock_Path(git).parent.parent, _chock_Path(git).parent.parent.parent] if git else [] + return [*near, *(_chock_Path(root) for root in _GIT_FOR_WINDOWS)] + +def bash_candidates() -> list[str]: + """Bash interpreters to try, best first; on Windows Git's own, never the WSL launcher.""" + if _chock_os.name != _WINDOWS: + return list(_POSIX_BASH) + found = [str(root.joinpath(*sub, _BASH_EXE)) for root in _git_roots() for sub in _GIT_BASH_DIRS] + on_path = _chock_shutil.which('bash') + if on_path and (not any((stub in on_path.lower().replace('\\', '/') for stub in _WINDOWS_STUB_DIRS))): + found.append(on_path) + return [c for i, c in enumerate(found) if c not in found[:i] and _chock_Path(c).is_file()] + +def interpreter_env(interpreter: str) -> dict[str, str]: + """The environment a guard runs in: on Windows, Git's usr/bin ahead on PATH for sed and grep.""" + env = dict(_chock_os.environ) + if _chock_os.name != _WINDOWS: + return env + home = _chock_Path(interpreter).parent + usr_bin = next((d for d in (home.parent / 'usr' / 'bin', home) if (d / _COREUTILS_MARKER).is_file()), None) + if usr_bin is not None: + env['PATH'] = str(usr_bin) + _chock_os.pathsep + env.get('PATH', '') + return env + +def find_bash(guard: _chock_Path, deadline: float | None=None) -> str | None: + """First bash that can actually see `guard`, probed once per process; the probes draw on `deadline`.""" + if 'bash' in _FOUND_BASH: + return _FOUND_BASH['bash'] + for candidate in bash_candidates(): + argv = [candidate, '-c', f'test -f "{guard.as_posix()}"'] try: - proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10, check=False) + limit = _BASH_PROBE_SECONDS if deadline is None else min(_BASH_PROBE_SECONDS, time_left(deadline, argv)) + proc = _chock_subprocess.run(argv, capture_output=True, timeout=limit, check=False) except (OSError, _chock_subprocess.SubprocessError): + if deadline is not None and engine_remaining(deadline) <= 0: + return None continue if proc.returncode == 0: + _FOUND_BASH['bash'] = candidate return candidate return None -def find_interpreter(guard: _chock_Path) -> str | None: +def find_interpreter(guard: _chock_Path, deadline: float | None=None) -> 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) + return find_bash(guard, deadline) + +def normalize_tool(tool: str | None) -> str: + """The hook payload's tool name as `bash` | `powershell` | `shell` | `unknown`.""" + name = (tool or '').lower() + if name in ('powershell', 'pwsh'): + return 'powershell' + if name == 'bash': + return 'bash' + if name == 'sh' or 'shell' in name: + return 'shell' + return TOOL_UNKNOWN + +def is_guard_crash(output: str) -> bool: + """True when a guard's exit-1 output is empty or shows an interpreter crash, not a refusal.""" + lowered = output.lower() + return not lowered or any((marker in lowered for marker in _CRASH_MARKERS)) + +def split_command(command: str) -> tuple[list[str], bool]: + """POSIX `shlex.split` argv, else a whitespace split; the flag is True on the fallback.""" + try: + return (_chock_shlex.split(command), False) + except ValueError: + return (command.split(), True) -def run_guard(guard: _chock_Path, command: str) -> str: +def run_guard(guard: _chock_Path, command: str, tool: str='') -> str: """`GUARD_BLOCKED` / `GUARD_ASKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" - return run_guard_detailed(guard, command)[0] + return run_guard_detailed(guard, command, tool)[0] + +def _no_interpreter(guard: _chock_Path, deadline: float) -> tuple[str, str]: + """The ask a guard earns when no shell could run it: the budget ran out probing, or none can see it.""" + if engine_remaining(deadline) <= 0: + print(f'chock: guard timed out after {ENGINE_BUDGET_SECONDS}s finding a shell, not checked', file=sys.stderr) + return (GUARD_ERRORED, '') + reason = f'no usable bash was found to run {guard.name}; on Windows install Git for Windows (it ships bash), elsewhere put bash on PATH' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) -def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: +def run_guard_detailed(guard: _chock_Path, command: str, tool: 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, '') + args, fallback = split_command(command) if not args: return (GUARD_UNCHECKED, '') - interpreter = find_interpreter(guard) + deadline = engine_deadline() + interpreter = find_interpreter(guard, deadline) if interpreter is None: - print(f'chock: no usable interpreter found, {guard.name} not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + return _no_interpreter(guard, deadline) try: - env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} - 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) + env = {**interpreter_env(interpreter), 'CHOCK_RAW_COMMAND': command, 'CHOCK_TOOL': normalize_tool(tool)} + if fallback: + env[ARGV_FALLBACK_ENV] = '1' + proc = _chock_subprocess.run([interpreter, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=time_left(deadline, [interpreter, str(guard)]), check=False) except _chock_subprocess.TimeoutExpired: - print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) + print(f'chock: guard timed out after {ENGINE_BUDGET_SECONDS}s, not checked', file=sys.stderr) return (GUARD_ERRORED, '') except (OSError, UnicodeError) as exc: print(f'chock: guard could not run, not checked: {exc}', file=sys.stderr) @@ -747,10 +864,11 @@ def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: output = ((proc.stderr or '') + (proc.stdout or '')).strip() first_line = output.splitlines()[0].strip() if output else '' if proc.returncode == GUARD_VIOLATION: + if is_guard_crash(output): + print(f'chock: guard {_chock_Path(guard).name} exited 1 without a reason (crash?), not checked', file=sys.stderr) + return (GUARD_ERRORED, '') sys.stderr.write(proc.stdout or '') sys.stderr.write(proc.stderr or '') - if not output: - print(f'chock: blocked by {_chock_Path(guard).name} (guard gave no reason)', file=sys.stderr) return (GUARD_BLOCKED, first_line) if proc.returncode == GUARD_ASK_EXIT: sys.stderr.write(proc.stdout or '') @@ -761,6 +879,17 @@ def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: return (GUARD_ERRORED, '') return (GUARD_CLEAN, '') +def append_gate_log(chock_root: _chock_Path, record: dict) -> None: + """Append one record, stamped, to `/log/gate-events.jsonl`, rotating past the size cap.""" + log_dir = chock_root / 'log' + log_dir.mkdir(parents=True, exist_ok=True) + 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') + stamped = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), **record} + with log_path.open('a', encoding='utf-8') as fh: + fh.write(json.dumps(stamped, ensure_ascii=False) + '\n') + def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> None: """Append one outcome record. Best effort: never raises, never changes the verdict.""" try: @@ -776,23 +905,19 @@ def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> None: break if artifact_root is None: return - log_dir = artifact_root / 'log' - log_dir.mkdir(parents=True, exist_ok=True) - 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': verdict} - with log_path.open('a', encoding='utf-8') as fh: - fh.write(json.dumps(record, ensure_ascii=False) + '\n') + record = {'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': verdict} + append_gate_log(artifact_root, record) except Exception: return def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: """Run the guard named on `argv` (`--guard `) against `command`.""" guard = guard_path_from_argv(argv) - if guard is None or not guard.exists(): + if guard is None: return None - verdict, message = run_guard_detailed(guard, command) + if not guard.exists(): + return (VERDICT_DENY, f"chock guard {guard} is missing, so this command cannot be checked. Run `chock sync --repo .` to reinstall the policy's guards.") + verdict, message = run_guard_detailed(guard, command, tool) logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} if verdict in logged: log_outcome(guard, tool, verdict=logged[verdict]) @@ -801,28 +926,766 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N 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.") + why = message or f"the {guard.stem} guard did not complete (see this hook's stderr)" + return (VERDICT_ESCALATE, f'chock could not check this command against {guard.stem}: {why}. Approving runs it unchecked.') return None +_EDIT_KEYS = (('old_string', 'new_string'), ('oldString', 'newString'), ('old_str', 'new_str')) + +_EDIT_LIST = 'edits' + +_CRLF = '\r\n' + +def _edit_call_input(event): + raw = getattr(event, 'raw', None) + tool_input = raw.get('tool_input') if isinstance(raw, dict) else None + if isinstance(tool_input, str) and tool_input[:1] == '{': + try: + tool_input = json.loads(tool_input) + except ValueError: + return {} + return tool_input if isinstance(tool_input, dict) else {} + +def _pair(item): + if not isinstance(item, dict): + return None + for old, new in _EDIT_KEYS: + if isinstance(item.get(old), str) and isinstance(item.get(new), str): + return (item[old], item[new], item.get('replace_all') is True) + return None + +def edit_replacements(event): + """The (old, new, replace_all) replacements an edit call applies, in order; None if not an edit.""" + tool_input = _edit_call_input(event) + listed = tool_input.get(_EDIT_LIST) + found = [_pair(item) for item in (listed if isinstance(listed, list) else [tool_input])] + if not found or None in found: + return None + return found + +def _replace(text, old, new, every): + """`text` with the replacement applied, or None when `old` is not there to replace. + + The file is read with universal newlines, so a CRLF file (a Windows checkout) arrives as + LF; the call's strings are brought to LF too, whichever ending the client sent them with, + rather than calling a real edit a failure over line endings. + """ + old, new = (old.replace(_CRLF, '\n'), new.replace(_CRLF, '\n')) + if old == '': + return new if text == '' else None + if old not in text: + return None + return text.replace(old, new) if every else text.replace(old, new, 1) + +def edited_text(event, root=None): + """The file an edit call would leave behind, or None when it is not an edit or cannot be rebuilt.""" + replacements = edit_replacements(event) + path = getattr(event, 'path', None) + if replacements is None or not path: + return None + target = _chock_Path(path) + if not target.is_absolute(): + target = _chock_Path(root if root is not None else _chock_Path.cwd()) / target + try: + text = target.read_text(encoding='utf-8') if target.exists() else '' + except (OSError, UnicodeDecodeError): + return None + for old, new, every in replacements: + text = _replace(text, old, new, every) + if text is None: + return None + return text + +def added_from_event(event): + """What an edit call introduces -- what `added_lines` means for it -- or {} for any other call.""" + replacements = edit_replacements(event) + path = getattr(event, 'path', None) + if replacements is None or not path: + return {} + return {str(path): '\n'.join((new for _, new, _ in replacements))} + +_BEGIN = '*** Begin Patch' + +_END = '*** End Patch' + +_ADD = '*** Add File: ' + +_DELETE = '*** Delete File: ' + +_UPDATE = '*** Update File: ' + +_MOVE = '*** Move to: ' + +_EOF = '*** End of File' + +_HUNK = '@@' + +_OPS = (('add', _ADD), ('delete', _DELETE), ('update', _UPDATE)) + +def patch_text(event): + """The patch an apply_patch call carries, from its first to its last marker; None otherwise.""" + raw = getattr(event, 'raw', None) + tool_input = raw.get('tool_input') if isinstance(raw, dict) else None + command = tool_input.get('command') if isinstance(tool_input, dict) else None + if isinstance(command, list): + command = '\n'.join((part for part in command if isinstance(part, str))) + start = command.find(_BEGIN) if isinstance(command, str) else -1 + end = command.find(_END, start) if start >= 0 else -1 + return command[start:end + len(_END)] if end >= 0 else None + +def _body_line(current, line): + """Add one line under the file operation `current` is reading.""" + if line.startswith(_MOVE): + current[2] = line[len(_MOVE):].strip() + elif line.startswith(_HUNK): + current[3].append([line[len(_HUNK):].strip(), [], False]) + elif line.startswith(_EOF): + if current[3]: + current[3][-1][2] = True + elif current[0] == 'add': + current[3].append(line[1:] if line.startswith('+') else line) + else: + if not current[3]: + current[3].append(['', [], False]) + current[3][-1][1].append(line if line[:1] in (' ', '-', '+') else ' ' + line) + +def parse_patch(patch): + """[(op, path, moved_to, hunks)]: op is add/delete/update; a hunk is (anchor, lines, at_eof).""" + operations = [] + for line in patch.replace('\r\n', '\n').split('\n')[1:]: + if line.startswith(_END): + break + opened = next(((op, marker) for op, marker in _OPS if line.startswith(marker)), None) + if opened is not None: + operations.append([opened[0], line[len(opened[1]):].strip(), None, []]) + elif operations: + _body_line(operations[-1], line) + return [tuple(operation) for operation in operations] + +def _find(lines, wanted, start): + """Where `wanted` starts in `lines` at or after `start`, exactly or ignoring trailing space.""" + for loose in (False, True): + for index in range(start, len(lines) - len(wanted) + 1): + window = lines[index:index + len(wanted)] + if window == wanted or (loose and [w.rstrip() for w in window] == [w.rstrip() for w in wanted]): + return index + return None + +def apply_hunks(text, hunks): + """`text` with the hunks applied in order, or None when one cannot be placed.""" + lines = text.split('\n') + cursor = 0 + for anchor, body, at_eof in hunks: + if anchor: + found = _find(lines, [anchor], cursor) + if found is None: + found = next((i for i in range(cursor, len(lines)) if anchor.strip() in lines[i]), None) + if found is None: + return None + cursor = found + 1 + old = [line[1:] for line in body if line[:1] in (' ', '-')] + new = [line[1:] for line in body if line[:1] in (' ', '+')] + if not old: + at = len(lines) - (1 if lines and lines[-1] == '' else 0) if at_eof else cursor + else: + at = _find(lines, old, cursor) + if at is None: + return None + lines[at:at + len(old)] = new + cursor = at + len(new) + return '\n'.join(lines) + +def _read(root, path): + target = _chock_Path(path) + if not target.is_absolute(): + target = _chock_Path(root if root is not None else _chock_Path.cwd()) / target + try: + return target.read_text(encoding='utf-8') + except (OSError, UnicodeDecodeError): + return None + +def patched_files(event, root=None): + """{path: text} for every file an apply_patch call adds or updates and can be rebuilt.""" + patch = patch_text(event) + if patch is None: + return {} + files = {} + for op, path, moved_to, body in parse_patch(patch): + if op == 'add': + files[path] = '\n'.join(body) + '\n' + elif op == 'update': + before = _read(root, path) + after = apply_hunks(before, body) if before is not None else None + if after is not None: + files[moved_to or path] = after + return files + +def patch_added(event): + """{path: the lines the patch adds}, which is what `added_lines` means for it.""" + patch = patch_text(event) + if patch is None: + return {} + added = {} + for op, path, moved_to, body in parse_patch(patch): + if op == 'add': + added[path] = '\n'.join(body) + elif op == 'update': + added[moved_to or path] = '\n'.join((line[1:] for _, hunk, _ in body for line in hunk if line[:1] == '+')) + return added + +GATE_BLOCKED = 'blocked' + +GATE_CLEAN = 'clean' + +GATE_ERRORED = 'errored' + +GATE_ASKED = 'asked' + +GATE_WARNED = 'warned' + +VERDICT_WARN = 'warn' + +_EXIT_OUTCOME = {0: GATE_CLEAN, 1: GATE_BLOCKED, 3: GATE_ASKED, 4: GATE_WARNED} + +def runner_outcome(returncode, stderr): + """(outcome, message) for the runner's exit code and stderr: its words, only when it refused or spoke.""" + outcome = _EXIT_OUTCOME.get(returncode, GATE_ERRORED) + return (outcome, '' if outcome == GATE_CLEAN else (stderr or '').strip()) + +def gate_decision(outcome, message, gate): + """The decision an outcome earns, or None when the gate allowed: deny, escalate (ask) or warn.""" + policy = gate.parent.parent.name + spoken = {GATE_BLOCKED: (VERDICT_DENY, message or f'Blocked by chock policy: {policy}'), GATE_ASKED: (VERDICT_ESCALATE, message or f'Chock policy {policy} asks before this write.'), GATE_WARNED: (VERDICT_WARN, message or f'Chock policy {policy} warns about this write.'), GATE_ERRORED: (VERDICT_DENY, f'chock could not check this write: {message}. Refusing rather than reporting an allow it never established.')} + return spoken.get(outcome) + +OUTSIDE_KEY = 'outside_repo' + +_UP = '..' + +_DRIVE_AT = 1 + +_SEPARATORS = ('/', '\\') + +def _slashed(path): + return str(path).replace('\\', '/') + +def is_outside(path): + """Whether a path as the runtime names it lies outside the repository: absolute, or climbing out.""" + text = _slashed(path) + return text.startswith(('/', _UP + '/')) or text[_DRIVE_AT:_DRIVE_AT + 1] == ':' or text == _UP + +def expand_home(pattern): + """`~` (alone or before a separator) as this machine's home directory; anything else as given.""" + text = str(pattern) + if text[:1] == '~' and (len(text) == 1 or text[1] in _SEPARATORS): + return _chock_os.path.expanduser('~') + text[1:] + return text + +def _outside_fold(path): + """`path` with `.` and `..` folded lexically and doubled slashes dropped; a root's leading slash kept.""" + parts = [] + for part in _slashed(path).split('/'): + if part == _UP and parts and (parts[-1] not in ('', _UP)): + parts.pop() + elif part != '.' and (part or not parts): + parts.append(part) + return '/'.join(parts) + +def declared_outside(path, root, patterns, *, windows=None): + """The absolute, slash-separated path when it is outside `root` and matches a declared glob; else None.""" + windows = _chock_os.name == 'nt' if windows is None else windows + text = _slashed(path) + if not is_outside(text) or not patterns: + return None + absolute = text if not text.startswith(_UP) else _slashed(root) + '/' + text + absolute = _outside_fold(absolute) + subject = absolute.lower() if windows else absolute + for pattern in patterns: + wanted = _outside_fold(expand_home(pattern)) + if _chock_fnmatch.fnmatchcase(subject, wanted.lower() if windows else wanted): + return absolute + return None + +def outside_globs(gate): + """The `outside_repo` globs the compiled gate declares; none when it declares none or cannot be read.""" + try: + declared = json.loads(gate.read_text(encoding='utf-8')).get(OUTSIDE_KEY) + except (OSError, ValueError, AttributeError): + return [] + return [g for g in declared if isinstance(g, str)] if isinstance(declared, list) else [] + +def judged_files(files, root, patterns, repo_names): + """`files` ({path: text}) keyed the way a gate judges them. + + A path inside the repository is named by `repo_names(path)`; one outside is kept, under its + absolute path, only when a declared glob matches it. + """ + kept = {} + for path, text in files.items(): + for name in repo_names(path): + if not is_outside(name): + kept[name] = text + continue + found = declared_outside(name, root, patterns) + if found is not None: + kept[found] = text + return kept + +SESSION_MAX_ENTRIES = 500 + +SESSION_PRUNE_SECONDS = 7 * 24 * 3600 + +SESSION_FIELD_CAP = 300 + +SESSION_TRIM_BYTES = 131072 + +SESSION_DEDUPE_TAIL = 8 + +SESSION_STATE_PARTS = ('.chock', 'state') + +SESSION_INPUT_KEYS = ('tool_input', 'toolInput', 'toolArgs', 'input') + +SESSION_PATH_KEYS = ('file_path', 'filePath', 'path', 'notebook_path', 'target_file', 'TargetFile') + +SESSION_URL_KEYS = ('url', 'uri') + +SESSION_RESPONSE_KEYS = ('tool_response', 'toolResponse', 'tool_result', 'toolResult') + +SESSION_UNKNOWN_ID = 'unknown' + +SESSION_ID_MAX = 128 + +SESSION_MASK = '***' + +def tool_call_input(event): + """The tool's arguments as a dict, decoding the JSON-string form some vendors send; {} when absent.""" + raw = getattr(event, 'raw', None) + if not isinstance(raw, dict): + return {} + for key in SESSION_INPUT_KEYS: + value = raw.get(key) + if isinstance(value, str) and value[:1] == '{': + try: + value = json.loads(value) + except ValueError: + continue + if isinstance(value, dict): + return value + return {} + +def session_id_of(event): + """The session id as a safe file stem: separators and leading dots removed, never empty.""" + raw = getattr(event, 'raw', None) or {} + given = getattr(event, 'session_id', None) or (raw.get('session_id') if isinstance(raw, dict) else None) + stem = ''.join((c for c in str(given or '') if c.isalnum() or c in '._-')).lstrip('.') + return stem[:SESSION_ID_MAX] or SESSION_UNKNOWN_ID + +def session_log_path(root, session_id): + return _chock_Path(root).joinpath(*SESSION_STATE_PARTS, session_id + '.jsonl') + +def session_for(event, root): + """What a script gate receives as `session`: the id, the log's absolute path, this call's id.""" + session_id = session_id_of(event) + return {'id': session_id, 'log_path': session_log_path(root, session_id).as_posix(), 'tool_use_id': getattr(event, 'tool_use_id', None)} + +def _mask_assignments(command): + """`command` with the value of every `NAME=value` word masked: an env prefix is a secret's usual home.""" + words = [] + for word in command.split(' '): + name, eq, _ = word.partition('=') + words.append(name + eq + SESSION_MASK if eq and name.isidentifier() else word) + return ' '.join(words) + +def _bare_url(url): + """`url` without userinfo, query and fragment: where a token or a secret is usually carried.""" + url = url.split('#', 1)[0].split('?', 1)[0] + scheme, sep, rest = url.partition('://') + if not sep: + return url + authority, slash, tail = rest.partition('/') + return scheme + sep + authority.rsplit('@', 1)[-1] + slash + tail + +def _first_str(mapping, keys): + for key in keys: + value = mapping.get(key) + if isinstance(value, str) and value: + return value + return None + +def session_summary(event): + """The input of a tool call as {path?, command?, url?}: what a policy asks about, nothing else.""" + tool_input = tool_call_input(event) + path = getattr(event, 'path', None) or _first_str(tool_input, SESSION_PATH_KEYS) + command = getattr(event, 'command', None) or _first_str(tool_input, ('command',)) + url = _first_str(tool_input, SESSION_URL_KEYS) + summary = {} + if isinstance(path, str) and path: + summary['path'] = path[:SESSION_FIELD_CAP] + if isinstance(command, str) and command: + summary['command'] = _mask_assignments(command.splitlines()[0] if command.strip() else '')[:SESSION_FIELD_CAP] + if url: + summary['url'] = _bare_url(url)[:SESSION_FIELD_CAP] + return summary + +def _response_failed(response): + if not isinstance(response, dict): + return False + if response.get('is_error') is True or response.get('isError') is True or response.get('success') is False: + return True + code = response.get('exit_code', response.get('exitCode')) + return bool(response.get('error')) or (isinstance(code, int) and code != 0) + +def session_outcome(event): + """`ok` or `error` for a finished call, from what the payload says; None before it ran.""" + if event.event == 'tool_failure': + return 'error' + if event.event != 'post_tool': + return None + raw = getattr(event, 'raw', None) or {} + failed = any((_response_failed(raw.get(key)) for key in SESSION_RESPONSE_KEYS)) if isinstance(raw, dict) else False + return 'error' if failed else 'ok' + +def _same_call(line, record): + """Whether `line` is `record` already logged: same call id, or, with none, the same second and input.""" + try: + seen = json.loads(line) + except ValueError: + return False + if seen.get('phase') != record['phase']: + return False + if record['tool_use_id']: + return seen.get('tool_use_id') == record['tool_use_id'] + return not seen.get('tool_use_id') and all((seen.get(key) == record[key] for key in ('ts', 'tool', 'input', 'outcome'))) + +def _prune_old(directory): + cutoff = _chock_datetime.now(_chock_timezone.utc).timestamp() - SESSION_PRUNE_SECONDS + for old in directory.glob('*.jsonl'): + try: + if old.stat().st_mtime < cutoff: + old.unlink() + except OSError: + continue + +def _append_bounded(path, line, record): + lines = path.read_text(encoding='utf-8', errors='replace').splitlines() if path.exists() else [] + if any((_same_call(seen, record) for seen in lines[-SESSION_DEDUPE_TAIL:])): + return + if len(lines) < SESSION_MAX_ENTRIES and path.exists() and (path.stat().st_size < SESSION_TRIM_BYTES): + with path.open('a', encoding='utf-8') as fh: + fh.write(line + '\n') + return + kept = [*lines, line][-SESSION_MAX_ENTRIES:] + scratch = path.with_suffix('.tmp') + scratch.write_text('\n'.join(kept) + '\n', encoding='utf-8') + scratch.replace(path) + +def session_record(root, event, phase, outcome=None): + """Append one record for this call to its session's log. Best effort: never raises.""" + try: + path = session_log_path(root, session_id_of(event)) + path.parent.mkdir(parents=True, exist_ok=True) + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'session_id': path.stem, 'phase': phase, 'tool': str(getattr(event, 'tool', None) or ''), 'input': session_summary(event), 'outcome': outcome, 'tool_use_id': getattr(event, 'tool_use_id', None)} + _append_bounded(path, json.dumps(record, ensure_ascii=False, sort_keys=True), record) + _prune_old(path.parent) + except Exception: + return + +REENTRY_CAP = 3 + +STOP_LEDGER_SUFFIX = '.stop.jsonl' + +GATE_LOG_PARTS = ('.chock', 'log', 'gate-events.jsonl') + +STOP_MODE_MASK = 61440 + +STOP_MODE_FILE = 32768 + +STOP_MODE_DIR = 16384 + +STOP_PHASE = 'stop' + +STOP_TURN_KEYS = ('turn_id', 'generation_id') + +STOP_COUNT_KEY = 'loop_count' + +STOP_DIGEST_CHARS = 16 + +REENTRY_CLEAN = 'clean' + +REENTRY_REFUSED = 'refused' + +REENTRY_CAPPED = 'cap-reached' + +REENTRY_UNTRACKED = 'untracked' + +STOP_LOG_BLOCK = 'block' + +STOP_LOG_ALLOW = 'allow' + +STOP_LOG_WARN = 'warn' + +_STOP_LAST = "\nThis is refusal {cap} of {cap} for this turn's end: the next stop ends the turn with these findings still on disk, and a commit will refuse them. A person must review them." + +_STOP_WARNING = 'chock {policy}: this turn ended with findings still on disk{why}. The turn was allowed to end so the client does not loop; a commit will refuse these findings, and a person must review them.\n' + +_STOP_WHY = {REENTRY_CAPPED: ' after {cap} refused stops', REENTRY_UNTRACKED: ' (the stop record under .chock/state could not be written, so re-entries cannot be counted)'} + +_STOP_SINCE = {True: ' (changed since the last refusal)', False: ' (unchanged since the last refusal)'} + +def reentries(event): + """How many times the client says this stop re-entered its hook: `loop_count`, else 1 for `stop_hook_active`.""" + raw = event.raw if isinstance(event.raw, dict) else {} + try: + counted = int(raw.get(STOP_COUNT_KEY) or 0) + except (TypeError, ValueError): + counted = 1 + return max(counted, 1 if raw.get('stop_hook_active') else 0) + +def _stop_turn(event): + """The vendor's turn id, when the payload carries one; None otherwise.""" + raw = event.raw if isinstance(event.raw, dict) else {} + for key in STOP_TURN_KEYS: + value = raw.get(key) + if isinstance(value, str) and value: + return value[:SESSION_ID_MAX] + return None + +def _stop_digest(*parts): + digest = _chock_hashlib.sha256() + for part in parts: + digest.update(str(part).encode('utf-8', 'replace') + b'\x00') + return digest.hexdigest()[:STOP_DIGEST_CHARS] + +def finding_digests(message, writes): + """One digest per flagged file, of its path and content: never the text itself, never the message's wording.""" + if not message: + return [] + lines = [line.strip().lstrip('-').strip() for line in message.splitlines()] + flagged = [p for p in writes if any((line.startswith(p + ':') for line in lines))] or list(writes) + if not flagged: + return [_stop_digest(message)] + return sorted({_stop_digest(p, writes[p]) for p in flagged}) + +def stop_ledger_path(root, session_id): + return _chock_Path(root).joinpath(*SESSION_STATE_PARTS, session_id + STOP_LEDGER_SUFFIX) + +def stop_ledger_safe(path): + """Whether `path`, its directory and `.chock` are what chock made: real directories and a regular file or none. + + lstat, so a symlink, FIFO, socket, device or directory in any place is refused before anything opens it: + opening a FIFO blocks the hook until its timeout, which a client may read as an allow. + """ + for target, kind in ((path, STOP_MODE_FILE), (path.parent, STOP_MODE_DIR), (path.parent.parent, STOP_MODE_DIR)): + try: + mode = _chock_os.lstat(target).st_mode + except FileNotFoundError: + continue + except OSError: + return False + if mode & STOP_MODE_MASK != kind: + return False + return True + +def _stop_lines(path): + """The ledger's lines; none when it is missing, unsafe or cannot be read.""" + if not stop_ledger_safe(path): + return [] + try: + fd = _chock_os.open(path, _chock_os.O_RDONLY | getattr(_chock_os, 'O_NOFOLLOW', 0) | getattr(_chock_os, 'O_NONBLOCK', 0)) + with _chock_os.fdopen(fd, encoding='utf-8', errors='replace') as fh: + return fh.read().splitlines() + except OSError: + return [] + +def _stop_parse(line): + try: + return json.loads(line) + except (ValueError, RecursionError): + return None + +def _stop_well_formed(seen, session_id): + stamp, index = (seen.get('ts'), seen.get('reentry')) + return isinstance(stamp, str) and bool(stamp) and (seen.get('session_id') == session_id) and (type(index) is int) and (index >= 0) + +def _stop_chain(lines, session_id, policy, turn): + """This turn's records, newest first, counting down without a gap to its first stop; None when broken.""" + chain = [] + for line in reversed(lines): + seen = _stop_parse(line) + if not isinstance(seen, dict) or seen.get('phase') != STOP_PHASE or seen.get('policy') != policy: + continue + if turn is not None and seen.get('turn') != turn: + continue + if not _stop_well_formed(seen, session_id): + return None + if chain and seen['reentry'] != chain[-1]['reentry'] - 1: + return None + chain.append(seen) + if seen['reentry'] == 0 or seen.get('anchor') is True: + return chain + return None + +def _stop_append(path, lines, record): + """Append one record, keeping the last `SESSION_MAX_ENTRIES`; whether it landed.""" + line = json.dumps(record, sort_keys=True) + if not stop_ledger_safe(path): + return False + try: + path.parent.mkdir(parents=True, exist_ok=True) + if len(lines) < SESSION_MAX_ENTRIES and (not path.exists() or path.stat().st_size < SESSION_TRIM_BYTES): + flags = _chock_os.O_WRONLY | _chock_os.O_APPEND | _chock_os.O_CREAT | getattr(_chock_os, 'O_NOFOLLOW', 0) | getattr(_chock_os, 'O_NONBLOCK', 0) + with _chock_os.fdopen(_chock_os.open(path, flags, 384), 'a', encoding='utf-8') as fh: + fh.write(line + '\n') + else: + scratch = path.with_name('%s.%d.tmp' % (path.name, _chock_os.getpid())) + scratch.write_text('\n'.join([*lines[-(SESSION_MAX_ENTRIES - 1):], line]) + '\n', encoding='utf-8') + scratch.replace(path) + _prune_old(path.parent) + except OSError: + return False + return True + +def _stop_gate_log(root, policy, verdict, reentry, findings): + """One gate-log record per re-entry; a warn is held (`would_block`), so it is kept even with the log off. + + `reentry` is (index, reentry_verdict, findings_changed or None). + """ + try: + if not stop_ledger_safe(_chock_Path(root).joinpath(*GATE_LOG_PARTS)): + return + held = verdict == STOP_LOG_WARN + if _chock_os.environ.get(GATE_LOG_ENV) == '0' and (not held): + return + index, reentry_verdict, changed = reentry + record = {'policy_id': policy, 'surface': 'stop-reentry', 'event': 'stop', 'kind': 'reentry', 'reentry': index, 'verdict': verdict, 'reentry_verdict': reentry_verdict, 'match_count': len(findings), 'matches': []} + if held: + record.update({'would_action': STOP_LOG_BLOCK, 'would_block': True}) + if changed is not None: + record['findings_changed'] = changed + append_gate_log(_chock_Path(root).joinpath(SESSION_STATE_PARTS[0]), record) + except Exception: + return + +def _stop_warn(policy, why, changed, message): + """The warning a turn ends with: said on stderr for every client, and returned for a `systemMessage`.""" + text = _STOP_WARNING.format(policy=policy, why=why.format(cap=REENTRY_CAP) + _STOP_SINCE.get(changed, '')) + text += message + with contextlib.suppress(Exception): + sys.stderr.write(text + '\n') + sys.stderr.flush() + return (VERDICT_WARN, text) + +def _stop_changed(chain, findings): + """Whether the findings differ from the last refusal's; None when there is no refusal to compare with.""" + refused = next((r for r in chain or () if r.get('verdict') == STOP_LOG_BLOCK), None) + if refused is None or not isinstance(refused.get('findings'), list): + return None + return sorted(refused['findings']) != findings + +def settle_stop(event, root, gate, decision, writes=None): + """The decision a Stop earns once this turn's earlier Stops are weighed against `decision`.""" + if decision is not None and decision[0] != VERDICT_DENY: + return decision + policy = gate.parent.parent.name + session_id = session_id_of(event) + path = stop_ledger_path(root, session_id) + lines = _stop_lines(path) + message = decision[1] if decision else '' + findings = finding_digests(message, writes or {}) + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'session_id': session_id, 'phase': STOP_PHASE, 'policy': policy, 'turn': _stop_turn(event), 'findings': findings} + signalled = reentries(event) + if not signalled: + _stop_append(path, lines, {**record, 'reentry': 0, 'verdict': STOP_LOG_BLOCK if decision else STOP_LOG_ALLOW}) + return decision + chain = _stop_chain(lines, session_id, policy, record['turn']) + counted = isinstance(event.raw, dict) and STOP_COUNT_KEY in event.raw + index = signalled if counted else chain[0]['reentry'] + 1 if chain else 1 + capped = index > REENTRY_CAP + verdict = STOP_LOG_ALLOW if not decision else STOP_LOG_WARN if capped else STOP_LOG_BLOCK + landed = _stop_append(path, lines, {**record, 'reentry': index, 'verdict': verdict, 'anchor': chain is None}) + changed = _stop_changed(chain, findings) + if decision is None: + _stop_gate_log(root, policy, verdict, (index, REENTRY_CLEAN, None), findings) + return None + if capped: + _stop_gate_log(root, policy, verdict, (index, REENTRY_CAPPED, changed), findings) + return _stop_warn(policy, _STOP_WHY[REENTRY_CAPPED], changed, message) + if not landed and (not counted): + _stop_gate_log(root, policy, STOP_LOG_WARN, (index, REENTRY_UNTRACKED, None), findings) + return _stop_warn(policy, _STOP_WHY[REENTRY_UNTRACKED], None, message) + tracked = REENTRY_REFUSED if chain and landed else REENTRY_UNTRACKED + _stop_gate_log(root, policy, verdict, (index, tracked, changed), findings) + return (VERDICT_DENY, message + (_STOP_LAST.format(cap=REENTRY_CAP) if index == REENTRY_CAP else '')) + +UNREADABLE_STOP_LEDGER = 'unreadable-stop.jsonl' + +UNREADABLE_STOP_PHASE = 'unreadable-stop' + +UNREADABLE_STOP_WINDOW_SECONDS = 600 + +REENTRY_UNREADABLE = 'unreadable' + +_UNREADABLE_STOP_WARNING = 'chock {policy}: {cap} stops in a row had a payload chock could not read, so none of them was judged. The turn was allowed to end so the client does not loop; a commit will judge what is on disk, and a person must look at why the payload is unreadable.\n' + +_UNREADABLE_STOP_LAST = '\nThis is refusal {cap} of {cap} for stops chock cannot read: the next one ends the turn unchecked, and a commit will judge what is on disk. A person must look at why the payload is unreadable.' + +def _unreadable_stop_recent(lines, now): + """The refusals recorded inside the window; a record that does not parse, or has no time, counts as none.""" + kept = [] + for line in lines: + seen = _stop_parse(line) + stamp = seen.get('at') if isinstance(seen, dict) else None + refused = isinstance(seen, dict) and seen.get('phase') == UNREADABLE_STOP_PHASE + if refused and seen.get('verdict') == STOP_LOG_BLOCK and (type(stamp) in (int, float)) and (0 <= now - stamp < UNREADABLE_STOP_WINDOW_SECONDS): + kept.append(stamp) + return kept + +def settle_unreadable_stop(root, policy, refusal): + """(verdict, text) this unreadable Stop earns: a refusal with `refusal`, or a warning once the cap is spent. + + A ledger that is not a regular file in real directories, or cannot be written, keeps refusing: with nothing to count, only a refusal is safe. + """ + path = _chock_Path(root).joinpath(*SESSION_STATE_PARTS, UNREADABLE_STOP_LEDGER) + if not stop_ledger_safe(path): + return (VERDICT_DENY, refusal) + lines = _stop_lines(path) + now = _chock_datetime.now(_chock_timezone.utc).timestamp() + index = len(_unreadable_stop_recent(lines, now)) + 1 + capped = index > REENTRY_CAP + verdict = STOP_LOG_WARN if capped else STOP_LOG_BLOCK + _stop_append(path, lines, {'phase': UNREADABLE_STOP_PHASE, 'verdict': verdict, 'at': now, 'reentry': index}) + _stop_gate_log(root, policy, verdict, (index, REENTRY_UNREADABLE, None), []) + if capped: + text = _UNREADABLE_STOP_WARNING.format(policy=policy, cap=REENTRY_CAP) + with contextlib.suppress(Exception): + sys.stderr.write(text) + sys.stderr.flush() + return (VERDICT_WARN, text) + return (VERDICT_DENY, refusal + (_UNREADABLE_STOP_LAST.format(cap=REENTRY_CAP) if index == REENTRY_CAP else '')) + GATE_FLAG = '--gate' -_GATE_TIMEOUT_SECONDS = 30 +STOP_FLAG = '--stop' _GATE_DEPTH_TO_CHOCK = 3 _RUNNER_PARTS = ('bin', 'gate.py') +_PACKAGED_RUNNER = 'gate.py' + _GIT = 'git' -_DELETED = 'D' +_UTF8 = 'utf-8' -_RENAMED = 'R' +_PATH_ERRORS = 'surrogateescape' -GATE_BLOCKED = 'blocked' +_PARENT = '..' -GATE_CLEAN = 'clean' +_DRIVE_COLON = ':' -GATE_ERRORED = 'errored' +_DELETED = 'D' + +_RENAMED = 'R' VERDICT_DENY = 'deny' @@ -835,72 +1698,145 @@ def gate_path_from_argv(argv): return None def runner_for(gate): - """The vendored gate runner beside this compiled gate, or None when it is not there.""" + """The gate runner: beside the gate in a plugin, under .chock/bin in a repository, else None.""" + packaged = gate.resolve().parent / _PACKAGED_RUNNER + if packaged.exists(): + return packaged parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None 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.""" +def writes_from_event(event, root=None): + """The one file this tool call would write, or none when it carries no file text. + + An edit call carries only the text it inserts; judged alone, that fragment has no imports, + no class and no neighbours, so a gate that reads a whole file (a script kind) finds nothing + in it. An edit is therefore judged as the file it would leave: the file on disk with the + call's replacements applied. When that cannot be rebuilt -- the file is unreadable, or the + text to replace is not in it, so the call itself will fail -- the fragment is judged as + before and the turn's end judges what actually landed. A Codex apply_patch call names its + files only inside the patch; each one it adds or updates is judged as the patch leaves it. + """ + patched = patched_files(event, root) + if patched: + return patched path = getattr(event, 'path', None) content = getattr(event, 'content', None) + edited = edited_text(event, root) + if path and edited is not None: + return {str(path): edited} 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.""" +def _folded(pure): + """`pure` with `..` folded lexically; an absolute path never climbs above its anchor.""" + parts = [] + for part in pure.parts: + if part != _PARENT: + parts.append(part) + elif len(parts) > 1: + parts.pop() + return type(pure)(*parts) + +def repo_relative(path, root): + """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" + text = str(path) + if root is None: + return text + windows = _chock_os.name == 'nt' or str(root)[1:2] == _DRIVE_COLON + flavour = _chock_PureWindowsPath if windows else _chock_PurePosixPath 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] + return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() + except ValueError: + pass + if windows != (_chock_os.name == 'nt'): + return text + try: + return _chock_Path(root, text).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return text + +def repo_paths(path, root): + """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" + lexical = repo_relative(path, root) + if root is None or (str(root)[1:2] == _DRIVE_COLON) != (_chock_os.name == 'nt'): + return (lexical,) + try: + resolved = _chock_Path(root, str(path)).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return (lexical,) + return tuple(dict.fromkeys((lexical, resolved))) + +def changed_paths(repo_root, deadline=None, why=None): + """Every uncommitted path; None (reason appended to `why`) when git gave no answer, [] only outside a repository.""" + deadline, why = (engine_deadline() if deadline is None else deadline, [] if why is None else why) + argv = [_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'] + try: + timeout = time_left(deadline, argv) + proc = _chock_subprocess.run(argv, capture_output=True, text=True, encoding=_UTF8, errors=_PATH_ERRORS, timeout=timeout, check=False) + except _chock_subprocess.TimeoutExpired as exc: + why.append(f'git status did not finish within {allowed_seconds(exc)}') + except (OSError, _chock_subprocess.SubprocessError) as exc: + why.append(f'git status could not run ({exc})') + else: + first = ((proc.stderr or '').strip().splitlines() or [''])[0] + if proc.returncode and 'not a git repository' in first: + return [] + if proc.returncode: + why.append(f'git status failed (exit {proc.returncode}): {first}') + if why: + sys.stderr.write(f'chock: {why[-1]}, worktree not checked\n') + return None + fields = iter([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) + if status.startswith(_RENAMED): + next(fields, None) + if _DELETED not in status and path: + paths.append(path) return paths -def writes_from_worktree(repo_root): - """What this turn actually left on disk, however it was written. +def writes_from_worktree(repo_root, deadline=None, why=None): + """What this turn actually left on disk, however it was written; None when it could not be listed. 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. """ + paths = changed_paths(repo_root, deadline, why) + if paths is None: + return None writes = {} - for path in changed_paths(repo_root): + for path in paths: 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.""" +def run_gate(gate, writes, event, run_in, deadline): + """Ask the vendored runner. Returns (outcome, message) and never decides for itself. + + `run_in` is (root, extra): the working directory, and the stdin keys `extra` adds: `added` (per + edited path, only the text the edit introduces; a runner that predates it judges the whole file, + which only ever refuses more) and the script `session`. The runner gets what is left of `deadline`. + """ + root, extra = run_in runner = runner_for(gate) if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') + argv = [sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event] try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run(argv, input=json.dumps({'writes': writes, **(extra or {})}), capture_output=True, text=True, encoding=_UTF8, errors='replace', timeout=time_left(deadline, argv), check=False, cwd=str(root) if root is not None else None) + except _chock_subprocess.TimeoutExpired as exc: + return (GATE_ERRORED, f'the gate runner gave no verdict within {allowed_seconds(exc)}') 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()) + return runner_outcome(proc.returncode, proc.stderr) _EVENT_ARG = {'pre_tool': 'pre-tool-use', 'stop': 'stop'} @@ -911,33 +1847,262 @@ def root_for(gate): parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None + if parents[_GATE_DEPTH_TO_CHOCK - 1].name != 'compiled' or parents[_GATE_DEPTH_TO_CHOCK].name != '.chock': + return None return parents[_GATE_DEPTH_TO_CHOCK].parent -def 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 {} +def repo_root_for(event, gate): + """The repository under judgement: the compiled layout's root, else where the agent works.""" root = root_for(gate) - return writes_from_worktree(root) if root is not None else {} + if root is not None: + return root + cwd = getattr(event, 'cwd', None) + return _chock_Path(cwd) if cwd else _chock_Path.cwd() + +def writes_for(event, gate, deadline=None, why=None): + """What this event puts under judgement: the call's own text, or what the turn left behind (None: unlisted).""" + if event.event == PRE_TOOL: + return writes_from_event(event, repo_root_for(event, gate)) + return writes_from_worktree(repo_root_for(event, gate), deadline, why) + +def _missing_gate(gate): + """A gate the hook names but that is not on disk: a broken install, so a refusal that says so.""" + return (VERDICT_DENY, f'chock gate {gate} is missing, so this write cannot be checked. Run `chock sync --repo .` to rebuild the compiled gates.') + +def _gate_says(gate, event, name, deadline): + """(decision, judged files): what the compiled gate says about this event, before a re-entered stop is weighed.""" + if not gate.exists(): + return (_missing_gate(gate), {}) + root = repo_root_for(event, gate) + outside = outside_globs(gate) + why = [] + listed = writes_for(event, gate, deadline, why) + if listed is None: + return (gate_decision(GATE_ERRORED, why[-1] if why else 'git status gave no answer', gate), {}) + writes = judged_files(listed, root, outside, lambda path: repo_paths(path, root)) + if not writes: + return (None, writes) + added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} + added = judged_files(added, root, outside, lambda path: repo_paths(path, root)) + added = {path: text for path, text in added.items() if path in writes} + extra = {**({'added': added} if added else {}), 'session': session_for(event, root)} + outcome, message = run_gate(gate, writes, name, (root, extra), deadline) + return (gate_decision(outcome, message, gate), writes) 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: + if gate is None or name is None: 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.') + decision, judged = _gate_says(gate, event, name, engine_deadline()) + if event.event == PRE_TOOL: + return decision + return settle_stop(event, repo_root_for(event, gate), gate, decision, judged) + +TOOL_CALL_FLAG = '--tool-call' + +RECORD_FLAG = '--record' + +TOOL_CALL_EVENT = 'tool_call' + +_TOOL_CALL_PRE = 'pre_tool' + +_TOOL_CALL_POST = ('post_tool', 'tool_failure') + +_TOOL_CALL_SCRIPT_KIND = 'script' + +_TOOL_CALL_REGEX_KIND = 'content_regex' + +_TOOL_CALL_BLOCKED = 'blocked' + +_TOOL_CALL_NEEDS_SESSION = frozenset({_TOOL_CALL_SCRIPT_KIND}) + +_TOOL_CALL_EXIT_VERDICTS = {0: None, 1: 'deny', 3: 'escalate', 4: 'warn'} + +_TOOL_CALL_CEILING = {'block': 'deny', 'ask': 'escalate', 'warn': 'warn'} + +_TOOL_CALL_RANK = {'warn': 0, 'escalate': 1, 'deny': 2} + +_TOOL_CALL_UNDECIDED = ' -- refusing rather than allowing what it never judged' + +def _flag_path(argv, flag): + if flag in argv: + index = argv.index(flag) + if index + 1 < len(argv): + return _chock_Path(argv[index + 1]) + return None + +def tool_call_matches(globs, tool): + """Whether `tool` (a tool name, exact case) matches any glob; no globs match nothing.""" + return any((_chock_fnmatch.fnmatchcase(tool, str(glob)) for glob in globs)) + +def _tool_call_refusal(spec, spoken): + return spoken or str(spec.get('message') or '').strip() or 'blocked by a chock tool_call gate' + +def _tool_call_regex(spec, tool_input): + """`deny` verdict when the input, as JSON text, matches the gate's `content_pattern`.""" + text = json.dumps(tool_input, sort_keys=True, ensure_ascii=False) + if _chock_re.search(str((spec.get('params') or {}).get('content_pattern', '')), text): + return ('deny', _tool_call_refusal(spec, '')) return None +def _tool_call_script(spec, root, payload, deadline=None): + """Run the policy's script on `payload`; a missing script, crash or timeout refuses.""" + named = str((spec.get('params') or {}).get('script', '')) + script = _chock_Path(root) / named + if not script.is_file(): + return ('deny', f'script gate: {named!r} is not installed{_TOOL_CALL_UNDECIDED}') + deadline = engine_deadline() if deadline is None else deadline + argv = [sys.executable, str(script)] + try: + proc = _chock_subprocess.run(argv, input=json.dumps(payload), capture_output=True, text=True, encoding='utf-8', errors='replace', cwd=str(root), timeout=time_left(deadline, argv), check=False) + except _chock_subprocess.TimeoutExpired as exc: + budget = f'gave no verdict within {allowed_seconds(exc)}' + return ('deny', f'script gate: {script.name} {budget}{_TOOL_CALL_UNDECIDED}') + except OSError as exc: + return ('deny', f'script gate: {script.name} could not run ({exc}){_TOOL_CALL_UNDECIDED}') + spoken = ((proc.stderr or '') + (proc.stdout or '')).strip() + if proc.returncode in _TOOL_CALL_EXIT_VERDICTS: + verdict = _TOOL_CALL_EXIT_VERDICTS[proc.returncode] + return None if verdict is None else (verdict, spoken or f'blocked by {script.name}') + first = spoken.splitlines()[0] if spoken else '' + detail = f': {first}' if first else '' + return ('deny', f'script gate: {script.name} exited {proc.returncode}{detail}{_TOOL_CALL_UNDECIDED}') + +def _tool_call_verdict(spec, root, event, deadline): + """None to allow, else (verdict, message) for this call; a kind a tool call cannot answer refuses.""" + tool, tool_input = (str(event.tool or ''), tool_call_input(event)) + kind = spec.get('kind') + if kind == _TOOL_CALL_REGEX_KIND: + return _tool_call_regex(spec, tool_input) + if kind == _TOOL_CALL_SCRIPT_KIND: + payload = {'event': TOOL_CALL_EVENT, 'repo_root': str(root), 'tool': tool, 'input': tool_input, 'session': session_for(event, root)} + return _tool_call_script(spec, root, payload, deadline) + return ('deny', f'chock tool_call gate: kind {kind!r} cannot judge a tool call{_TOOL_CALL_UNDECIDED}') + +def _tool_call_capped(spec, verdict): + """`verdict` held to the gate's declared action; an unknown action keeps the verdict as spoken.""" + ceiling = _TOOL_CALL_CEILING.get(spec.get('action', 'block')) + if verdict is None or ceiling is None: + return verdict + return (min(verdict[0], ceiling, key=_TOOL_CALL_RANK.__getitem__), verdict[1]) + +def _tool_call_spec(gate): + """The compiled gate as a dict, or (None, refusal) when the install is broken.""" + if not gate.exists(): + return (None, f'chock gate {gate} is missing, so this tool call cannot be checked. Run `chock sync --repo .` to rebuild the compiled gates.') + try: + return (json.loads(gate.read_text(encoding='utf-8')), None) + except (OSError, ValueError) as exc: + return (None, f'chock could not read {gate}: {exc}. Refusing rather than reporting an allow it never established.') + +def evaluate_tool_call(argv, event): + """The decision a tool_call gate earns this call, or None; also logs the call for the session. + + `--tool-call ` judges a PreToolUse call and, for a gate whose check reads the session, + logs it after the verdict, so the script reads prior calls only. `--record ` logs what + a call came to, after it ran (a vendor's PostToolUse and failure events). + """ + recording = _flag_path(argv, RECORD_FLAG) + if recording is not None: + if event.event in _TOOL_CALL_POST: + session_record(repo_root_for(event, recording), event, 'post', session_outcome(event)) + return None + gate = _flag_path(argv, TOOL_CALL_FLAG) + if gate is None or event.event != _TOOL_CALL_PRE: + return None + spec, broken = _tool_call_spec(gate) + if spec is None: + return ('deny', broken) + root = repo_root_for(event, gate) + verdict = None + if TOOL_CALL_EVENT in spec.get('on', []) and tool_call_matches((spec.get('params') or {}).get('tools', []), str(event.tool or '')): + verdict = _tool_call_capped(spec, _tool_call_verdict(spec, root, event, engine_deadline())) + if spec.get('kind') in _TOOL_CALL_NEEDS_SESSION: + session_record(root, event, 'pre', _TOOL_CALL_BLOCKED if verdict and verdict[0] == 'deny' else None) + return verdict +_CHOCK_UNREADABLE = ( + "chock could not read this hook's payload, so it could not check the call. " + "Refusing rather than reporting an allow it never established." +) + +#: A payload naming only this vendor's own wire event, so `respond` picks that event's grammar. +_CHOCK_WIRE_RAW = {PRE_TOOL: {'hook_event_name': 'BeforeTool'}, STOP: {'hook_event_name': 'AfterAgent'}} + +#: The words a client reads as a refusal, in any of the answer fields a vendor grammar uses. +_CHOCK_REFUSING = ("deny", "block", "ask", "continue") +_CHOCK_ANSWER_KEYS = ("decision", "permission", "permissionDecision") + + +def _chock_spec_action(path): + try: + action = json.loads(path.read_text(encoding="utf-8")).get("action") + except Exception: + return "block" + return action if action in ("ask", "warn") else "block" + + +def _chock_declared(): + """(strictest action, event, gate) of the judging flags; (None, None, None) where this run judges nothing.""" + argv = sys.argv[1:] + actions = ["block"] if guard_path_from_argv(argv) is not None else [] + event = PRE_TOOL + gate = _flag_path(argv, GATE_FLAG) + if gate is not None: + actions.append(_chock_spec_action(gate)) + event = STOP if STOP_FLAG in argv or gate.parent.name == _EVENT_ARG[STOP] else PRE_TOOL + called = _flag_path(argv, TOOL_CALL_FLAG) + if called is not None: + actions.append(_chock_spec_action(called)) + for action in ("block", "ask", "warn"): + if action in actions: + return action, event, gate + return None, None, None + + +def _chock_refuses(text, code): + if code == 2: + return True + try: + body = json.loads(text) + except Exception: + return False + if not isinstance(body, dict): + return False + nested = body.get("hookSpecificOutput") + answers = [body] + ([nested] if isinstance(nested, dict) else []) + return any(part.get(key) in _CHOCK_REFUSING for part in answers for key in _CHOCK_ANSWER_KEYS) + + +def _chock_unreadable_stop(gate): + """The bounded verdict of an unreadable Stop: a refusal, or a warning once the cap is spent.""" + root = root_for(gate) or _chock_Path.cwd() + return settle_unreadable_stop(root, gate.parent.parent.name, _CHOCK_UNREADABLE) + + +def _chock_unreadable(): + action, kind, gate = _chock_declared() + if action in (None, "warn"): + return "", 0 + event = Event(AGENT, kind, raw=dict(_CHOCK_WIRE_RAW[kind])) + reason = _CHOCK_UNREADABLE + if kind == STOP and gate is not None: + verdict, reason = _chock_unreadable_stop(gate) + if verdict == VERDICT_WARN: + return respond(degrade(Decision.warn(reason), event), event) + _report(reason + "\n") + decision = Decision.escalate(reason) if action == "ask" else Decision.deny(reason) + text, code = respond(degrade(decision, event), event) + return (text, code) if _chock_refuses(text, code) else ("", 2) + + +def _spoken(verdict): + outcome, reason = verdict + if outcome == VERDICT_WARN: + return Decision.warn(reason) + return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + def _judge(event): if event.event == "pre_tool" and event.command: @@ -947,7 +2112,10 @@ def _judge(event): 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 _spoken(gated) + called = evaluate_tool_call(sys.argv[1:], event) + if called is not None: + return _spoken(called) return None @@ -1007,6 +2175,55 @@ def _report(text): return +_STDERR_FD = 2 + + +def _divert_fd1(): + """Point fd 1 at stderr (devnull if there is none); the saved fd 1, or None if it could not.""" + try: + saved = os.dup(1) + except OSError: + return None + # With fd 2 closed, dup() hands back 2 itself, and "stderr" would be stdout again. + if saved > _STDERR_FD: + with contextlib.suppress(OSError): + os.dup2(_STDERR_FD, 1) + return saved + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +def _flush(streams): + """Flush each stream that exists; a broken one is no reason to lose the verdict.""" + for stream in streams: + if stream is not None: + with contextlib.suppress(Exception): + stream.flush() + + +@contextlib.contextmanager +def _stdout_to_stderr(): + # Stdout is the verdict channel. A handler's stray print (or a child process it runs) + # ahead of the JSON makes the host fail to parse it, and Claude Code and Gemini CLI then + # treat the hook as a non-blocking error: a deny became an allow, witnessed live. + sink = sys.stderr if sys.stderr is not None else io.StringIO() + held = (sys.stdout, sys.__stdout__) + _flush(held) + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + # A stream the handler held on to (sys.__stdout__, a reference cached at import) + # buffers past redirect_stdout; flushed now it lands on stderr, not after the verdict. + _flush((sink, *held)) + os.dup2(saved, 1) + os.close(saved) + + def _decide(raw): """(stdout_text, exit_code) for one decoded payload: agentseam.dispatch.handle(), inlined.""" event = parse(raw) @@ -1016,7 +2233,9 @@ def _decide(raw): # vocabulary invites a decision made on a false premise. return "", 0 try: - decision = _coerce(handle(event)) + with _stdout_to_stderr(): + result = handle(event) + decision = _coerce(result) except Exception: # A door that cannot decide refuses. Escaping here would exit 1 with a traceback, # which every host reads as a non-blocking error and allows past; instead the @@ -1038,10 +2257,14 @@ def main(stdin=None, stdout=None, exit=True): try: raw = json.loads(_read_payload(stream)) except Exception: - # Malformed input is not the agent's fault to pay for: allow, stay silent. + raw = None + if not isinstance(raw, dict): + text, code = _chock_unreadable() + if text: + _emit(out, text) if exit: - sys.exit(0) - return 0 + sys.exit(code) + return code try: text, code = _decide(raw) except Exception: diff --git a/.chock/bin/launch.sh b/.chock/bin/launch.sh new file mode 100644 index 0000000..5db2694 --- /dev/null +++ b/.chock/bin/launch.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# chock hook launcher. Every agent hook chock writes runs: +# git -c "alias.chock-hook=!test -f || { ...; exit 2; }; sh " chock-hook [args...] +# git runs the alias from the repository's top level under its own sh (bash, PowerShell +# and cmd.exe all pass that string through unchanged), so relative paths resolve and no +# absolute interpreter path is ever committed. This script picks the first Python that +# actually runs: `chock.python` (written into .git/config by `chock sync`), then PATH. +# A candidate must execute, not merely exist: Windows' python3.exe Store alias exists and +# exits 9009. With no working Python it refuses (exit 2) and says why -- never allows. +runtime="$1" +shift +configured="$(git config --get chock.python 2>/dev/null)" +# The recorded interpreter is probed like the rest: a venv whose base Python was removed +# still exists, and exec'ing it exits non-zero without a verdict, which agents let through. +for candidate in "$configured" python3 python py; do + if [ -n "$candidate" ] && "$candidate" -c 'import sys; sys.exit(sys.version_info < (3, 11,))' /dev/null 2>&1; then + exec "$candidate" -X utf8 "$runtime" "$@" + fi +done +echo "chock: no working Python 3.11+ found (tried chock.python, python3, python, py), so this hook cannot check anything. Install Python, or point chock at one: git config chock.python /path/to/python" >&2 +exit 2 diff --git a/.chock/bin/vscode_copilot.py b/.chock/bin/vscode_copilot.py index 6218e70..2139ffe 100755 --- a/.chock/bin/vscode_copilot.py +++ b/.chock/bin/vscode_copilot.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.1 -- bundle("vscode_copilot"). Do not hand-edit, except the +# Generated by agentseam 0.3.5 -- 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. # @@ -9,20 +9,29 @@ from __future__ import annotations +import contextlib +import io import json _json = json +import os import sys +import hashlib as _chock_hashlib +import fnmatch as _chock_fnmatch +import re as _chock_re import os as _chock_os import shlex as _chock_shlex +import shutil as _chock_shutil import subprocess as _chock_subprocess +import time as _chock_time from datetime import datetime as _chock_datetime, timezone as _chock_timezone from pathlib import Path as _chock_Path +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.1) +# contract (agentseam 0.3.5) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -200,7 +209,7 @@ def tool_input_of(raw): """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" if isinstance(raw, dict): return raw - if isinstance(raw, str) and raw[:1] == "{": + if isinstance(raw, str) and raw.lstrip()[:1] == "{": try: parsed = _json.loads(raw) except (ValueError, RecursionError): @@ -218,10 +227,25 @@ def tool_input_of(raw): """PowerShell's one rule that breaks hook commands, shared by the vendors it affects.""" +#: `pwsh -Command` exits 1 whenever the last native command failed, whatever its code, so a +#: hook's exit 2 (block) reached the host as 1 (a non-blocking error): openai/codex#48183. +#: When no native command ran at all (the interpreter is not on PATH), $LASTEXITCODE is $null +#: and `exit $null` is 0 -- an allow; nothing judged the call, so that refuses (2) instead. +_KEEP_EXIT = "; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE" + +#: The suffix before the $null guard; an entry carrying it is upgraded, not suffixed twice. +_BARE_EXIT = "; exit $LASTEXITCODE" + def powershell_command(command): - """`command` rewritten so PowerShell will actually run it.""" - return command if command.lstrip().startswith("&") else "& " + command + """`command` rewritten so PowerShell will actually run it and keep its exit code.""" + body = command if command.lstrip().startswith("&") else "& " + command + body = body.rstrip() + if body.endswith(_KEEP_EXIT): + return body + if body.endswith(_BARE_EXIT): + body = body[: -len(_BARE_EXIT)] + return body + _KEEP_EXIT # ------------------------------------------------------------------------------ @@ -266,6 +290,18 @@ def powershell_command(command): _VSCODE_ENVELOPE = "timestamp" +#: Copilot CLI's own camelCase input (docs.github.com/en/copilot/reference/hooks-configuration, +#: read 2026-09-27): {sessionId, timestamp, cwd, toolName, toolArgs[, toolResult]} and no event +#: name at all. `toolArgs` is documented as the parsed arguments; the JSON-string form earlier +#: CLI builds sent is decoded too. +_CLI_ARGS = "toolArgs" +_CLI_RESULT = "toolResult" +_CLI_KEYS = (_CLI_ARGS, "toolName", "sessionId") + +#: The camelCase `agentStop` payload (live capture, Windows, 2026-09-28): {sessionId, timestamp +#: (epoch ms), cwd, transcriptPath, stopReason, stop_hook_active} and no event name either. +_CLI_STOP = "stopReason" + def _name(raw): """The payload's own event name as text; None when it names none, UNKNOWN when the @@ -289,15 +325,47 @@ def claims(raw): return False if name in _CLAIMABLE: return True + if name is None and ( + _CLI_ARGS in raw or _CLI_STOP in raw or ("toolName" in raw and ("sessionId" in raw or _VSCODE_ENVELOPE in raw)) + ): + # A CLI call may omit toolArgs; unclaimed, it would pass unseen as "unrecognized". + return True ti = raw.get("tool_input") return raw.get("tool_name") in MEMORY_TOOLS and isinstance(ti, dict) and "command" in ti +def _tool_input(raw): + """The tool's arguments: VS Code's `tool_input`, else the CLI's `toolArgs` (object or JSON text).""" + ti = raw.get("tool_input") + return tool_input_of(raw.get(_CLI_ARGS) if ti is None else ti) + + +def is_cli_native(raw): + """True for Copilot CLI's camelCase payloads, which get its top-level permission answer.""" + if not isinstance(raw, dict): + return False + name = _name(raw) + if name is None: + return any(k in raw for k in _CLI_KEYS) + return name in _CLAIMABLE + + +def _cli_output(raw): + result = raw.get(_CLI_RESULT) + return result.get("textResultForLlm") if isinstance(result, dict) else None + + +def _implied_name(raw): + """The event a payload with no name of its own is: agentStop, postToolUse, else preToolUse.""" + if _CLI_STOP in raw and _CLI_ARGS not in raw: + return "agentStop" + return "postToolUse" if _CLI_RESULT in raw else "preToolUse" + + def parse(raw): if not isinstance(raw, dict): return Event(AGENT, UNKNOWN, raw=raw) - ti = raw.get("tool_input") - ti = tool_input_of(ti) + ti = _tool_input(raw) tool = raw.get("tool_name") or raw.get("toolName") path = content = None if tool in MEMORY_TOOLS: @@ -308,8 +376,8 @@ def parse(raw): path = ti.get("path") else: path = ti.get("filePath") or ti.get("file_path") or ti.get("path") - content = ti.get("content") or ti.get("newText") or ti.get("new_str") - name = _name(raw) or "preToolUse" + content = ti.get("content") or ti.get("newText") or ti.get("new_str") or ti.get("file_text") + name = _name(raw) or _implied_name(raw) return Event( AGENT, EVENT_MAP.get(name, UNKNOWN), @@ -318,8 +386,8 @@ def parse(raw): path=path, content=content, prompt=raw.get("prompt"), - output=raw.get("tool_output") or raw.get("tool_response"), - session_id=raw.get("session_id"), + output=raw.get("tool_output") or raw.get("tool_response") or _cli_output(raw), + session_id=raw.get("session_id") or raw.get("sessionId"), tool_use_id=raw.get("tool_use_id"), cwd=raw.get("cwd"), raw=raw, @@ -328,8 +396,7 @@ def parse(raw): def is_memory_write(event): """True when this event is a memory-tool content write (VS Code's memory surface).""" - ti = event.raw.get("tool_input") - ti = tool_input_of(ti) + ti = _tool_input(event.raw) return event.tool in MEMORY_TOOLS and ti.get("command") in MEMORY_WRITE_COMMANDS @@ -393,6 +460,18 @@ def _pre_tool_out(decision, event): return out +def _cli_pre_tool_out(decision): + """The CLI's documented top-level answer; it has no input rewrite, so a rewrite blocks.""" + if decision.outcome == VOUCH: + out = {"permissionDecision": "allow"} + if decision.reason: + out[_PERMISSION_DECISION_REASON] = decision.reason + return out + if decision.outcome == ASK: + return {"permissionDecision": "ask", _PERMISSION_DECISION_REASON: decision.reason or "confirmation required"} + return {"permissionDecision": "deny", _PERMISSION_DECISION_REASON: _refusal_reason(decision)} + + def respond(decision, event): """Three dialects, one per event group -- not one gate shape everywhere.""" import json as _json # noqa: PLC0415 (bundler.py keeps this vendored file's own function-local @@ -411,6 +490,9 @@ def respond(decision, event): if event.event != PRE_TOOL or decision.outcome == ALLOW: return "", 0 + if is_cli_native(event.raw): + return _json.dumps(_cli_pre_tool_out(decision)), 0 + return _json.dumps({"hookSpecificOutput": _pre_tool_out(decision, event)}), 0 @@ -433,7 +515,8 @@ def hook_config(canonical_events, command, matcher=None): # noqa: ARG001 (every for ev in canonical_events: name = REVERSE_EVENT_MAP.get(ev) if name: - entry = {"type": "command", "command": command, "windows": powershell_command(command)} + windows = powershell_command(command) + entry = {"type": "command", "command": command, "bash": command, "powershell": windows, "windows": windows} hooks.setdefault(name, []).append(entry) return {"hooks": hooks} @@ -473,19 +556,60 @@ def degrade(decision, event): # >>> agentseam handler >>> +ENGINE_BUDGET_SECONDS = 30 + +def engine_deadline(): + """The monotonic instant this invocation's budget runs out.""" + return _chock_time.monotonic() + ENGINE_BUDGET_SECONDS + +def engine_remaining(deadline): + """Seconds left before `deadline`, never negative.""" + return max(deadline - _chock_time.monotonic(), 0.0) + +def time_left(deadline, cmd): + """Seconds left for a subprocess; raises TimeoutExpired when none are, so the caller's timeout path runs.""" + left = deadline - _chock_time.monotonic() + if left <= 0: + raise _chock_subprocess.TimeoutExpired(cmd, 0) + return left + +def allowed_seconds(expired): + """The time a timed-out call was actually given, as text (what the deadline left it, not the full budget).""" + return f'{max(float(expired.timeout or 0), 0.0):.1f}s' + 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') +_CRASH_MARKERS = ('traceback (most recent call last)', 'syntax error', 'syntaxerror', 'unexpected eof') + +TOOL_UNKNOWN = 'unknown' + +ARGV_FALLBACK_ENV = 'CHOCK_ARGV_FALLBACK' + +_POSIX_BASH = ('bash', '/bin/bash', '/usr/bin/bash') + +_GIT_FOR_WINDOWS = ('C:\\Program Files\\Git', 'C:\\Program Files (x86)\\Git') + +_GIT_BASH_DIRS = (('bin',), ('usr', 'bin')) + +_BASH_EXE = 'bash.exe' + +_WINDOWS_STUB_DIRS = ('/system32/', '/windowsapps/') + +_COREUTILS_MARKER = 'sed.exe' + +_WINDOWS = 'nt' + +_FOUND_BASH = {} GATE_LOG_ENV = 'CHOCK_GATE_LOG' _LOG_MAX_BYTES = 1048576 -_GUARD_TIMEOUT_SECONDS = 30 +_BASH_PROBE_SECONDS = 10 GUARD_BLOCKED = 'blocked' @@ -509,45 +633,109 @@ def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: return _chock_Path(argv[i + 1]) return None -def find_bash(guard: _chock_Path) -> str | None: - """First interpreter that can actually see `guard`, or None.""" - for candidate in _BASH_CANDIDATES: +def _git_roots() -> list[_chock_Path]: + """Git for Windows install roots: from `git` on PATH (cmd/ or mingw64/bin/), then the defaults.""" + git = _chock_shutil.which('git') + near = [_chock_Path(git).parent.parent, _chock_Path(git).parent.parent.parent] if git else [] + return [*near, *(_chock_Path(root) for root in _GIT_FOR_WINDOWS)] + +def bash_candidates() -> list[str]: + """Bash interpreters to try, best first; on Windows Git's own, never the WSL launcher.""" + if _chock_os.name != _WINDOWS: + return list(_POSIX_BASH) + found = [str(root.joinpath(*sub, _BASH_EXE)) for root in _git_roots() for sub in _GIT_BASH_DIRS] + on_path = _chock_shutil.which('bash') + if on_path and (not any((stub in on_path.lower().replace('\\', '/') for stub in _WINDOWS_STUB_DIRS))): + found.append(on_path) + return [c for i, c in enumerate(found) if c not in found[:i] and _chock_Path(c).is_file()] + +def interpreter_env(interpreter: str) -> dict[str, str]: + """The environment a guard runs in: on Windows, Git's usr/bin ahead on PATH for sed and grep.""" + env = dict(_chock_os.environ) + if _chock_os.name != _WINDOWS: + return env + home = _chock_Path(interpreter).parent + usr_bin = next((d for d in (home.parent / 'usr' / 'bin', home) if (d / _COREUTILS_MARKER).is_file()), None) + if usr_bin is not None: + env['PATH'] = str(usr_bin) + _chock_os.pathsep + env.get('PATH', '') + return env + +def find_bash(guard: _chock_Path, deadline: float | None=None) -> str | None: + """First bash that can actually see `guard`, probed once per process; the probes draw on `deadline`.""" + if 'bash' in _FOUND_BASH: + return _FOUND_BASH['bash'] + for candidate in bash_candidates(): + argv = [candidate, '-c', f'test -f "{guard.as_posix()}"'] try: - proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10, check=False) + limit = _BASH_PROBE_SECONDS if deadline is None else min(_BASH_PROBE_SECONDS, time_left(deadline, argv)) + proc = _chock_subprocess.run(argv, capture_output=True, timeout=limit, check=False) except (OSError, _chock_subprocess.SubprocessError): + if deadline is not None and engine_remaining(deadline) <= 0: + return None continue if proc.returncode == 0: + _FOUND_BASH['bash'] = candidate return candidate return None -def find_interpreter(guard: _chock_Path) -> str | None: +def find_interpreter(guard: _chock_Path, deadline: float | None=None) -> 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) + return find_bash(guard, deadline) + +def normalize_tool(tool: str | None) -> str: + """The hook payload's tool name as `bash` | `powershell` | `shell` | `unknown`.""" + name = (tool or '').lower() + if name in ('powershell', 'pwsh'): + return 'powershell' + if name == 'bash': + return 'bash' + if name == 'sh' or 'shell' in name: + return 'shell' + return TOOL_UNKNOWN + +def is_guard_crash(output: str) -> bool: + """True when a guard's exit-1 output is empty or shows an interpreter crash, not a refusal.""" + lowered = output.lower() + return not lowered or any((marker in lowered for marker in _CRASH_MARKERS)) + +def split_command(command: str) -> tuple[list[str], bool]: + """POSIX `shlex.split` argv, else a whitespace split; the flag is True on the fallback.""" + try: + return (_chock_shlex.split(command), False) + except ValueError: + return (command.split(), True) -def run_guard(guard: _chock_Path, command: str) -> str: +def run_guard(guard: _chock_Path, command: str, tool: str='') -> str: """`GUARD_BLOCKED` / `GUARD_ASKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" - return run_guard_detailed(guard, command)[0] + return run_guard_detailed(guard, command, tool)[0] + +def _no_interpreter(guard: _chock_Path, deadline: float) -> tuple[str, str]: + """The ask a guard earns when no shell could run it: the budget ran out probing, or none can see it.""" + if engine_remaining(deadline) <= 0: + print(f'chock: guard timed out after {ENGINE_BUDGET_SECONDS}s finding a shell, not checked', file=sys.stderr) + return (GUARD_ERRORED, '') + reason = f'no usable bash was found to run {guard.name}; on Windows install Git for Windows (it ships bash), elsewhere put bash on PATH' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) -def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: +def run_guard_detailed(guard: _chock_Path, command: str, tool: 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, '') + args, fallback = split_command(command) if not args: return (GUARD_UNCHECKED, '') - interpreter = find_interpreter(guard) + deadline = engine_deadline() + interpreter = find_interpreter(guard, deadline) if interpreter is None: - print(f'chock: no usable interpreter found, {guard.name} not checked', file=sys.stderr) - return (GUARD_UNCHECKED, '') + return _no_interpreter(guard, deadline) try: - env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} - 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) + env = {**interpreter_env(interpreter), 'CHOCK_RAW_COMMAND': command, 'CHOCK_TOOL': normalize_tool(tool)} + if fallback: + env[ARGV_FALLBACK_ENV] = '1' + proc = _chock_subprocess.run([interpreter, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=time_left(deadline, [interpreter, str(guard)]), check=False) except _chock_subprocess.TimeoutExpired: - print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) + print(f'chock: guard timed out after {ENGINE_BUDGET_SECONDS}s, not checked', file=sys.stderr) return (GUARD_ERRORED, '') except (OSError, UnicodeError) as exc: print(f'chock: guard could not run, not checked: {exc}', file=sys.stderr) @@ -555,10 +743,11 @@ def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: output = ((proc.stderr or '') + (proc.stdout or '')).strip() first_line = output.splitlines()[0].strip() if output else '' if proc.returncode == GUARD_VIOLATION: + if is_guard_crash(output): + print(f'chock: guard {_chock_Path(guard).name} exited 1 without a reason (crash?), not checked', file=sys.stderr) + return (GUARD_ERRORED, '') sys.stderr.write(proc.stdout or '') sys.stderr.write(proc.stderr or '') - if not output: - print(f'chock: blocked by {_chock_Path(guard).name} (guard gave no reason)', file=sys.stderr) return (GUARD_BLOCKED, first_line) if proc.returncode == GUARD_ASK_EXIT: sys.stderr.write(proc.stdout or '') @@ -569,6 +758,17 @@ def run_guard_detailed(guard: _chock_Path, command: str) -> tuple[str, str]: return (GUARD_ERRORED, '') return (GUARD_CLEAN, '') +def append_gate_log(chock_root: _chock_Path, record: dict) -> None: + """Append one record, stamped, to `/log/gate-events.jsonl`, rotating past the size cap.""" + log_dir = chock_root / 'log' + log_dir.mkdir(parents=True, exist_ok=True) + 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') + stamped = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), **record} + with log_path.open('a', encoding='utf-8') as fh: + fh.write(json.dumps(stamped, ensure_ascii=False) + '\n') + def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> None: """Append one outcome record. Best effort: never raises, never changes the verdict.""" try: @@ -584,23 +784,19 @@ def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> None: break if artifact_root is None: return - log_dir = artifact_root / 'log' - log_dir.mkdir(parents=True, exist_ok=True) - 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': verdict} - with log_path.open('a', encoding='utf-8') as fh: - fh.write(json.dumps(record, ensure_ascii=False) + '\n') + record = {'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': verdict} + append_gate_log(artifact_root, record) except Exception: return def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: """Run the guard named on `argv` (`--guard `) against `command`.""" guard = guard_path_from_argv(argv) - if guard is None or not guard.exists(): + if guard is None: return None - verdict, message = run_guard_detailed(guard, command) + if not guard.exists(): + return (VERDICT_DENY, f"chock guard {guard} is missing, so this command cannot be checked. Run `chock sync --repo .` to reinstall the policy's guards.") + verdict, message = run_guard_detailed(guard, command, tool) logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} if verdict in logged: log_outcome(guard, tool, verdict=logged[verdict]) @@ -609,28 +805,766 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N 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.") + why = message or f"the {guard.stem} guard did not complete (see this hook's stderr)" + return (VERDICT_ESCALATE, f'chock could not check this command against {guard.stem}: {why}. Approving runs it unchecked.') + return None + +_EDIT_KEYS = (('old_string', 'new_string'), ('oldString', 'newString'), ('old_str', 'new_str')) + +_EDIT_LIST = 'edits' + +_CRLF = '\r\n' + +def _edit_call_input(event): + raw = getattr(event, 'raw', None) + tool_input = raw.get('tool_input') if isinstance(raw, dict) else None + if isinstance(tool_input, str) and tool_input[:1] == '{': + try: + tool_input = json.loads(tool_input) + except ValueError: + return {} + return tool_input if isinstance(tool_input, dict) else {} + +def _pair(item): + if not isinstance(item, dict): + return None + for old, new in _EDIT_KEYS: + if isinstance(item.get(old), str) and isinstance(item.get(new), str): + return (item[old], item[new], item.get('replace_all') is True) + return None + +def edit_replacements(event): + """The (old, new, replace_all) replacements an edit call applies, in order; None if not an edit.""" + tool_input = _edit_call_input(event) + listed = tool_input.get(_EDIT_LIST) + found = [_pair(item) for item in (listed if isinstance(listed, list) else [tool_input])] + if not found or None in found: + return None + return found + +def _replace(text, old, new, every): + """`text` with the replacement applied, or None when `old` is not there to replace. + + The file is read with universal newlines, so a CRLF file (a Windows checkout) arrives as + LF; the call's strings are brought to LF too, whichever ending the client sent them with, + rather than calling a real edit a failure over line endings. + """ + old, new = (old.replace(_CRLF, '\n'), new.replace(_CRLF, '\n')) + if old == '': + return new if text == '' else None + if old not in text: + return None + return text.replace(old, new) if every else text.replace(old, new, 1) + +def edited_text(event, root=None): + """The file an edit call would leave behind, or None when it is not an edit or cannot be rebuilt.""" + replacements = edit_replacements(event) + path = getattr(event, 'path', None) + if replacements is None or not path: + return None + target = _chock_Path(path) + if not target.is_absolute(): + target = _chock_Path(root if root is not None else _chock_Path.cwd()) / target + try: + text = target.read_text(encoding='utf-8') if target.exists() else '' + except (OSError, UnicodeDecodeError): + return None + for old, new, every in replacements: + text = _replace(text, old, new, every) + if text is None: + return None + return text + +def added_from_event(event): + """What an edit call introduces -- what `added_lines` means for it -- or {} for any other call.""" + replacements = edit_replacements(event) + path = getattr(event, 'path', None) + if replacements is None or not path: + return {} + return {str(path): '\n'.join((new for _, new, _ in replacements))} + +_BEGIN = '*** Begin Patch' + +_END = '*** End Patch' + +_ADD = '*** Add File: ' + +_DELETE = '*** Delete File: ' + +_UPDATE = '*** Update File: ' + +_MOVE = '*** Move to: ' + +_EOF = '*** End of File' + +_HUNK = '@@' + +_OPS = (('add', _ADD), ('delete', _DELETE), ('update', _UPDATE)) + +def patch_text(event): + """The patch an apply_patch call carries, from its first to its last marker; None otherwise.""" + raw = getattr(event, 'raw', None) + tool_input = raw.get('tool_input') if isinstance(raw, dict) else None + command = tool_input.get('command') if isinstance(tool_input, dict) else None + if isinstance(command, list): + command = '\n'.join((part for part in command if isinstance(part, str))) + start = command.find(_BEGIN) if isinstance(command, str) else -1 + end = command.find(_END, start) if start >= 0 else -1 + return command[start:end + len(_END)] if end >= 0 else None + +def _body_line(current, line): + """Add one line under the file operation `current` is reading.""" + if line.startswith(_MOVE): + current[2] = line[len(_MOVE):].strip() + elif line.startswith(_HUNK): + current[3].append([line[len(_HUNK):].strip(), [], False]) + elif line.startswith(_EOF): + if current[3]: + current[3][-1][2] = True + elif current[0] == 'add': + current[3].append(line[1:] if line.startswith('+') else line) + else: + if not current[3]: + current[3].append(['', [], False]) + current[3][-1][1].append(line if line[:1] in (' ', '-', '+') else ' ' + line) + +def parse_patch(patch): + """[(op, path, moved_to, hunks)]: op is add/delete/update; a hunk is (anchor, lines, at_eof).""" + operations = [] + for line in patch.replace('\r\n', '\n').split('\n')[1:]: + if line.startswith(_END): + break + opened = next(((op, marker) for op, marker in _OPS if line.startswith(marker)), None) + if opened is not None: + operations.append([opened[0], line[len(opened[1]):].strip(), None, []]) + elif operations: + _body_line(operations[-1], line) + return [tuple(operation) for operation in operations] + +def _find(lines, wanted, start): + """Where `wanted` starts in `lines` at or after `start`, exactly or ignoring trailing space.""" + for loose in (False, True): + for index in range(start, len(lines) - len(wanted) + 1): + window = lines[index:index + len(wanted)] + if window == wanted or (loose and [w.rstrip() for w in window] == [w.rstrip() for w in wanted]): + return index + return None + +def apply_hunks(text, hunks): + """`text` with the hunks applied in order, or None when one cannot be placed.""" + lines = text.split('\n') + cursor = 0 + for anchor, body, at_eof in hunks: + if anchor: + found = _find(lines, [anchor], cursor) + if found is None: + found = next((i for i in range(cursor, len(lines)) if anchor.strip() in lines[i]), None) + if found is None: + return None + cursor = found + 1 + old = [line[1:] for line in body if line[:1] in (' ', '-')] + new = [line[1:] for line in body if line[:1] in (' ', '+')] + if not old: + at = len(lines) - (1 if lines and lines[-1] == '' else 0) if at_eof else cursor + else: + at = _find(lines, old, cursor) + if at is None: + return None + lines[at:at + len(old)] = new + cursor = at + len(new) + return '\n'.join(lines) + +def _read(root, path): + target = _chock_Path(path) + if not target.is_absolute(): + target = _chock_Path(root if root is not None else _chock_Path.cwd()) / target + try: + return target.read_text(encoding='utf-8') + except (OSError, UnicodeDecodeError): + return None + +def patched_files(event, root=None): + """{path: text} for every file an apply_patch call adds or updates and can be rebuilt.""" + patch = patch_text(event) + if patch is None: + return {} + files = {} + for op, path, moved_to, body in parse_patch(patch): + if op == 'add': + files[path] = '\n'.join(body) + '\n' + elif op == 'update': + before = _read(root, path) + after = apply_hunks(before, body) if before is not None else None + if after is not None: + files[moved_to or path] = after + return files + +def patch_added(event): + """{path: the lines the patch adds}, which is what `added_lines` means for it.""" + patch = patch_text(event) + if patch is None: + return {} + added = {} + for op, path, moved_to, body in parse_patch(patch): + if op == 'add': + added[path] = '\n'.join(body) + elif op == 'update': + added[moved_to or path] = '\n'.join((line[1:] for _, hunk, _ in body for line in hunk if line[:1] == '+')) + return added + +GATE_BLOCKED = 'blocked' + +GATE_CLEAN = 'clean' + +GATE_ERRORED = 'errored' + +GATE_ASKED = 'asked' + +GATE_WARNED = 'warned' + +VERDICT_WARN = 'warn' + +_EXIT_OUTCOME = {0: GATE_CLEAN, 1: GATE_BLOCKED, 3: GATE_ASKED, 4: GATE_WARNED} + +def runner_outcome(returncode, stderr): + """(outcome, message) for the runner's exit code and stderr: its words, only when it refused or spoke.""" + outcome = _EXIT_OUTCOME.get(returncode, GATE_ERRORED) + return (outcome, '' if outcome == GATE_CLEAN else (stderr or '').strip()) + +def gate_decision(outcome, message, gate): + """The decision an outcome earns, or None when the gate allowed: deny, escalate (ask) or warn.""" + policy = gate.parent.parent.name + spoken = {GATE_BLOCKED: (VERDICT_DENY, message or f'Blocked by chock policy: {policy}'), GATE_ASKED: (VERDICT_ESCALATE, message or f'Chock policy {policy} asks before this write.'), GATE_WARNED: (VERDICT_WARN, message or f'Chock policy {policy} warns about this write.'), GATE_ERRORED: (VERDICT_DENY, f'chock could not check this write: {message}. Refusing rather than reporting an allow it never established.')} + return spoken.get(outcome) + +OUTSIDE_KEY = 'outside_repo' + +_UP = '..' + +_DRIVE_AT = 1 + +_SEPARATORS = ('/', '\\') + +def _slashed(path): + return str(path).replace('\\', '/') + +def is_outside(path): + """Whether a path as the runtime names it lies outside the repository: absolute, or climbing out.""" + text = _slashed(path) + return text.startswith(('/', _UP + '/')) or text[_DRIVE_AT:_DRIVE_AT + 1] == ':' or text == _UP + +def expand_home(pattern): + """`~` (alone or before a separator) as this machine's home directory; anything else as given.""" + text = str(pattern) + if text[:1] == '~' and (len(text) == 1 or text[1] in _SEPARATORS): + return _chock_os.path.expanduser('~') + text[1:] + return text + +def _outside_fold(path): + """`path` with `.` and `..` folded lexically and doubled slashes dropped; a root's leading slash kept.""" + parts = [] + for part in _slashed(path).split('/'): + if part == _UP and parts and (parts[-1] not in ('', _UP)): + parts.pop() + elif part != '.' and (part or not parts): + parts.append(part) + return '/'.join(parts) + +def declared_outside(path, root, patterns, *, windows=None): + """The absolute, slash-separated path when it is outside `root` and matches a declared glob; else None.""" + windows = _chock_os.name == 'nt' if windows is None else windows + text = _slashed(path) + if not is_outside(text) or not patterns: + return None + absolute = text if not text.startswith(_UP) else _slashed(root) + '/' + text + absolute = _outside_fold(absolute) + subject = absolute.lower() if windows else absolute + for pattern in patterns: + wanted = _outside_fold(expand_home(pattern)) + if _chock_fnmatch.fnmatchcase(subject, wanted.lower() if windows else wanted): + return absolute + return None + +def outside_globs(gate): + """The `outside_repo` globs the compiled gate declares; none when it declares none or cannot be read.""" + try: + declared = json.loads(gate.read_text(encoding='utf-8')).get(OUTSIDE_KEY) + except (OSError, ValueError, AttributeError): + return [] + return [g for g in declared if isinstance(g, str)] if isinstance(declared, list) else [] + +def judged_files(files, root, patterns, repo_names): + """`files` ({path: text}) keyed the way a gate judges them. + + A path inside the repository is named by `repo_names(path)`; one outside is kept, under its + absolute path, only when a declared glob matches it. + """ + kept = {} + for path, text in files.items(): + for name in repo_names(path): + if not is_outside(name): + kept[name] = text + continue + found = declared_outside(name, root, patterns) + if found is not None: + kept[found] = text + return kept + +SESSION_MAX_ENTRIES = 500 + +SESSION_PRUNE_SECONDS = 7 * 24 * 3600 + +SESSION_FIELD_CAP = 300 + +SESSION_TRIM_BYTES = 131072 + +SESSION_DEDUPE_TAIL = 8 + +SESSION_STATE_PARTS = ('.chock', 'state') + +SESSION_INPUT_KEYS = ('tool_input', 'toolInput', 'toolArgs', 'input') + +SESSION_PATH_KEYS = ('file_path', 'filePath', 'path', 'notebook_path', 'target_file', 'TargetFile') + +SESSION_URL_KEYS = ('url', 'uri') + +SESSION_RESPONSE_KEYS = ('tool_response', 'toolResponse', 'tool_result', 'toolResult') + +SESSION_UNKNOWN_ID = 'unknown' + +SESSION_ID_MAX = 128 + +SESSION_MASK = '***' + +def tool_call_input(event): + """The tool's arguments as a dict, decoding the JSON-string form some vendors send; {} when absent.""" + raw = getattr(event, 'raw', None) + if not isinstance(raw, dict): + return {} + for key in SESSION_INPUT_KEYS: + value = raw.get(key) + if isinstance(value, str) and value[:1] == '{': + try: + value = json.loads(value) + except ValueError: + continue + if isinstance(value, dict): + return value + return {} + +def session_id_of(event): + """The session id as a safe file stem: separators and leading dots removed, never empty.""" + raw = getattr(event, 'raw', None) or {} + given = getattr(event, 'session_id', None) or (raw.get('session_id') if isinstance(raw, dict) else None) + stem = ''.join((c for c in str(given or '') if c.isalnum() or c in '._-')).lstrip('.') + return stem[:SESSION_ID_MAX] or SESSION_UNKNOWN_ID + +def session_log_path(root, session_id): + return _chock_Path(root).joinpath(*SESSION_STATE_PARTS, session_id + '.jsonl') + +def session_for(event, root): + """What a script gate receives as `session`: the id, the log's absolute path, this call's id.""" + session_id = session_id_of(event) + return {'id': session_id, 'log_path': session_log_path(root, session_id).as_posix(), 'tool_use_id': getattr(event, 'tool_use_id', None)} + +def _mask_assignments(command): + """`command` with the value of every `NAME=value` word masked: an env prefix is a secret's usual home.""" + words = [] + for word in command.split(' '): + name, eq, _ = word.partition('=') + words.append(name + eq + SESSION_MASK if eq and name.isidentifier() else word) + return ' '.join(words) + +def _bare_url(url): + """`url` without userinfo, query and fragment: where a token or a secret is usually carried.""" + url = url.split('#', 1)[0].split('?', 1)[0] + scheme, sep, rest = url.partition('://') + if not sep: + return url + authority, slash, tail = rest.partition('/') + return scheme + sep + authority.rsplit('@', 1)[-1] + slash + tail + +def _first_str(mapping, keys): + for key in keys: + value = mapping.get(key) + if isinstance(value, str) and value: + return value + return None + +def session_summary(event): + """The input of a tool call as {path?, command?, url?}: what a policy asks about, nothing else.""" + tool_input = tool_call_input(event) + path = getattr(event, 'path', None) or _first_str(tool_input, SESSION_PATH_KEYS) + command = getattr(event, 'command', None) or _first_str(tool_input, ('command',)) + url = _first_str(tool_input, SESSION_URL_KEYS) + summary = {} + if isinstance(path, str) and path: + summary['path'] = path[:SESSION_FIELD_CAP] + if isinstance(command, str) and command: + summary['command'] = _mask_assignments(command.splitlines()[0] if command.strip() else '')[:SESSION_FIELD_CAP] + if url: + summary['url'] = _bare_url(url)[:SESSION_FIELD_CAP] + return summary + +def _response_failed(response): + if not isinstance(response, dict): + return False + if response.get('is_error') is True or response.get('isError') is True or response.get('success') is False: + return True + code = response.get('exit_code', response.get('exitCode')) + return bool(response.get('error')) or (isinstance(code, int) and code != 0) + +def session_outcome(event): + """`ok` or `error` for a finished call, from what the payload says; None before it ran.""" + if event.event == 'tool_failure': + return 'error' + if event.event != 'post_tool': + return None + raw = getattr(event, 'raw', None) or {} + failed = any((_response_failed(raw.get(key)) for key in SESSION_RESPONSE_KEYS)) if isinstance(raw, dict) else False + return 'error' if failed else 'ok' + +def _same_call(line, record): + """Whether `line` is `record` already logged: same call id, or, with none, the same second and input.""" + try: + seen = json.loads(line) + except ValueError: + return False + if seen.get('phase') != record['phase']: + return False + if record['tool_use_id']: + return seen.get('tool_use_id') == record['tool_use_id'] + return not seen.get('tool_use_id') and all((seen.get(key) == record[key] for key in ('ts', 'tool', 'input', 'outcome'))) + +def _prune_old(directory): + cutoff = _chock_datetime.now(_chock_timezone.utc).timestamp() - SESSION_PRUNE_SECONDS + for old in directory.glob('*.jsonl'): + try: + if old.stat().st_mtime < cutoff: + old.unlink() + except OSError: + continue + +def _append_bounded(path, line, record): + lines = path.read_text(encoding='utf-8', errors='replace').splitlines() if path.exists() else [] + if any((_same_call(seen, record) for seen in lines[-SESSION_DEDUPE_TAIL:])): + return + if len(lines) < SESSION_MAX_ENTRIES and path.exists() and (path.stat().st_size < SESSION_TRIM_BYTES): + with path.open('a', encoding='utf-8') as fh: + fh.write(line + '\n') + return + kept = [*lines, line][-SESSION_MAX_ENTRIES:] + scratch = path.with_suffix('.tmp') + scratch.write_text('\n'.join(kept) + '\n', encoding='utf-8') + scratch.replace(path) + +def session_record(root, event, phase, outcome=None): + """Append one record for this call to its session's log. Best effort: never raises.""" + try: + path = session_log_path(root, session_id_of(event)) + path.parent.mkdir(parents=True, exist_ok=True) + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'session_id': path.stem, 'phase': phase, 'tool': str(getattr(event, 'tool', None) or ''), 'input': session_summary(event), 'outcome': outcome, 'tool_use_id': getattr(event, 'tool_use_id', None)} + _append_bounded(path, json.dumps(record, ensure_ascii=False, sort_keys=True), record) + _prune_old(path.parent) + except Exception: + return + +REENTRY_CAP = 3 + +STOP_LEDGER_SUFFIX = '.stop.jsonl' + +GATE_LOG_PARTS = ('.chock', 'log', 'gate-events.jsonl') + +STOP_MODE_MASK = 61440 + +STOP_MODE_FILE = 32768 + +STOP_MODE_DIR = 16384 + +STOP_PHASE = 'stop' + +STOP_TURN_KEYS = ('turn_id', 'generation_id') + +STOP_COUNT_KEY = 'loop_count' + +STOP_DIGEST_CHARS = 16 + +REENTRY_CLEAN = 'clean' + +REENTRY_REFUSED = 'refused' + +REENTRY_CAPPED = 'cap-reached' + +REENTRY_UNTRACKED = 'untracked' + +STOP_LOG_BLOCK = 'block' + +STOP_LOG_ALLOW = 'allow' + +STOP_LOG_WARN = 'warn' + +_STOP_LAST = "\nThis is refusal {cap} of {cap} for this turn's end: the next stop ends the turn with these findings still on disk, and a commit will refuse them. A person must review them." + +_STOP_WARNING = 'chock {policy}: this turn ended with findings still on disk{why}. The turn was allowed to end so the client does not loop; a commit will refuse these findings, and a person must review them.\n' + +_STOP_WHY = {REENTRY_CAPPED: ' after {cap} refused stops', REENTRY_UNTRACKED: ' (the stop record under .chock/state could not be written, so re-entries cannot be counted)'} + +_STOP_SINCE = {True: ' (changed since the last refusal)', False: ' (unchanged since the last refusal)'} + +def reentries(event): + """How many times the client says this stop re-entered its hook: `loop_count`, else 1 for `stop_hook_active`.""" + raw = event.raw if isinstance(event.raw, dict) else {} + try: + counted = int(raw.get(STOP_COUNT_KEY) or 0) + except (TypeError, ValueError): + counted = 1 + return max(counted, 1 if raw.get('stop_hook_active') else 0) + +def _stop_turn(event): + """The vendor's turn id, when the payload carries one; None otherwise.""" + raw = event.raw if isinstance(event.raw, dict) else {} + for key in STOP_TURN_KEYS: + value = raw.get(key) + if isinstance(value, str) and value: + return value[:SESSION_ID_MAX] return None +def _stop_digest(*parts): + digest = _chock_hashlib.sha256() + for part in parts: + digest.update(str(part).encode('utf-8', 'replace') + b'\x00') + return digest.hexdigest()[:STOP_DIGEST_CHARS] + +def finding_digests(message, writes): + """One digest per flagged file, of its path and content: never the text itself, never the message's wording.""" + if not message: + return [] + lines = [line.strip().lstrip('-').strip() for line in message.splitlines()] + flagged = [p for p in writes if any((line.startswith(p + ':') for line in lines))] or list(writes) + if not flagged: + return [_stop_digest(message)] + return sorted({_stop_digest(p, writes[p]) for p in flagged}) + +def stop_ledger_path(root, session_id): + return _chock_Path(root).joinpath(*SESSION_STATE_PARTS, session_id + STOP_LEDGER_SUFFIX) + +def stop_ledger_safe(path): + """Whether `path`, its directory and `.chock` are what chock made: real directories and a regular file or none. + + lstat, so a symlink, FIFO, socket, device or directory in any place is refused before anything opens it: + opening a FIFO blocks the hook until its timeout, which a client may read as an allow. + """ + for target, kind in ((path, STOP_MODE_FILE), (path.parent, STOP_MODE_DIR), (path.parent.parent, STOP_MODE_DIR)): + try: + mode = _chock_os.lstat(target).st_mode + except FileNotFoundError: + continue + except OSError: + return False + if mode & STOP_MODE_MASK != kind: + return False + return True + +def _stop_lines(path): + """The ledger's lines; none when it is missing, unsafe or cannot be read.""" + if not stop_ledger_safe(path): + return [] + try: + fd = _chock_os.open(path, _chock_os.O_RDONLY | getattr(_chock_os, 'O_NOFOLLOW', 0) | getattr(_chock_os, 'O_NONBLOCK', 0)) + with _chock_os.fdopen(fd, encoding='utf-8', errors='replace') as fh: + return fh.read().splitlines() + except OSError: + return [] + +def _stop_parse(line): + try: + return json.loads(line) + except (ValueError, RecursionError): + return None + +def _stop_well_formed(seen, session_id): + stamp, index = (seen.get('ts'), seen.get('reentry')) + return isinstance(stamp, str) and bool(stamp) and (seen.get('session_id') == session_id) and (type(index) is int) and (index >= 0) + +def _stop_chain(lines, session_id, policy, turn): + """This turn's records, newest first, counting down without a gap to its first stop; None when broken.""" + chain = [] + for line in reversed(lines): + seen = _stop_parse(line) + if not isinstance(seen, dict) or seen.get('phase') != STOP_PHASE or seen.get('policy') != policy: + continue + if turn is not None and seen.get('turn') != turn: + continue + if not _stop_well_formed(seen, session_id): + return None + if chain and seen['reentry'] != chain[-1]['reentry'] - 1: + return None + chain.append(seen) + if seen['reentry'] == 0 or seen.get('anchor') is True: + return chain + return None + +def _stop_append(path, lines, record): + """Append one record, keeping the last `SESSION_MAX_ENTRIES`; whether it landed.""" + line = json.dumps(record, sort_keys=True) + if not stop_ledger_safe(path): + return False + try: + path.parent.mkdir(parents=True, exist_ok=True) + if len(lines) < SESSION_MAX_ENTRIES and (not path.exists() or path.stat().st_size < SESSION_TRIM_BYTES): + flags = _chock_os.O_WRONLY | _chock_os.O_APPEND | _chock_os.O_CREAT | getattr(_chock_os, 'O_NOFOLLOW', 0) | getattr(_chock_os, 'O_NONBLOCK', 0) + with _chock_os.fdopen(_chock_os.open(path, flags, 384), 'a', encoding='utf-8') as fh: + fh.write(line + '\n') + else: + scratch = path.with_name('%s.%d.tmp' % (path.name, _chock_os.getpid())) + scratch.write_text('\n'.join([*lines[-(SESSION_MAX_ENTRIES - 1):], line]) + '\n', encoding='utf-8') + scratch.replace(path) + _prune_old(path.parent) + except OSError: + return False + return True + +def _stop_gate_log(root, policy, verdict, reentry, findings): + """One gate-log record per re-entry; a warn is held (`would_block`), so it is kept even with the log off. + + `reentry` is (index, reentry_verdict, findings_changed or None). + """ + try: + if not stop_ledger_safe(_chock_Path(root).joinpath(*GATE_LOG_PARTS)): + return + held = verdict == STOP_LOG_WARN + if _chock_os.environ.get(GATE_LOG_ENV) == '0' and (not held): + return + index, reentry_verdict, changed = reentry + record = {'policy_id': policy, 'surface': 'stop-reentry', 'event': 'stop', 'kind': 'reentry', 'reentry': index, 'verdict': verdict, 'reentry_verdict': reentry_verdict, 'match_count': len(findings), 'matches': []} + if held: + record.update({'would_action': STOP_LOG_BLOCK, 'would_block': True}) + if changed is not None: + record['findings_changed'] = changed + append_gate_log(_chock_Path(root).joinpath(SESSION_STATE_PARTS[0]), record) + except Exception: + return + +def _stop_warn(policy, why, changed, message): + """The warning a turn ends with: said on stderr for every client, and returned for a `systemMessage`.""" + text = _STOP_WARNING.format(policy=policy, why=why.format(cap=REENTRY_CAP) + _STOP_SINCE.get(changed, '')) + text += message + with contextlib.suppress(Exception): + sys.stderr.write(text + '\n') + sys.stderr.flush() + return (VERDICT_WARN, text) + +def _stop_changed(chain, findings): + """Whether the findings differ from the last refusal's; None when there is no refusal to compare with.""" + refused = next((r for r in chain or () if r.get('verdict') == STOP_LOG_BLOCK), None) + if refused is None or not isinstance(refused.get('findings'), list): + return None + return sorted(refused['findings']) != findings + +def settle_stop(event, root, gate, decision, writes=None): + """The decision a Stop earns once this turn's earlier Stops are weighed against `decision`.""" + if decision is not None and decision[0] != VERDICT_DENY: + return decision + policy = gate.parent.parent.name + session_id = session_id_of(event) + path = stop_ledger_path(root, session_id) + lines = _stop_lines(path) + message = decision[1] if decision else '' + findings = finding_digests(message, writes or {}) + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'session_id': session_id, 'phase': STOP_PHASE, 'policy': policy, 'turn': _stop_turn(event), 'findings': findings} + signalled = reentries(event) + if not signalled: + _stop_append(path, lines, {**record, 'reentry': 0, 'verdict': STOP_LOG_BLOCK if decision else STOP_LOG_ALLOW}) + return decision + chain = _stop_chain(lines, session_id, policy, record['turn']) + counted = isinstance(event.raw, dict) and STOP_COUNT_KEY in event.raw + index = signalled if counted else chain[0]['reentry'] + 1 if chain else 1 + capped = index > REENTRY_CAP + verdict = STOP_LOG_ALLOW if not decision else STOP_LOG_WARN if capped else STOP_LOG_BLOCK + landed = _stop_append(path, lines, {**record, 'reentry': index, 'verdict': verdict, 'anchor': chain is None}) + changed = _stop_changed(chain, findings) + if decision is None: + _stop_gate_log(root, policy, verdict, (index, REENTRY_CLEAN, None), findings) + return None + if capped: + _stop_gate_log(root, policy, verdict, (index, REENTRY_CAPPED, changed), findings) + return _stop_warn(policy, _STOP_WHY[REENTRY_CAPPED], changed, message) + if not landed and (not counted): + _stop_gate_log(root, policy, STOP_LOG_WARN, (index, REENTRY_UNTRACKED, None), findings) + return _stop_warn(policy, _STOP_WHY[REENTRY_UNTRACKED], None, message) + tracked = REENTRY_REFUSED if chain and landed else REENTRY_UNTRACKED + _stop_gate_log(root, policy, verdict, (index, tracked, changed), findings) + return (VERDICT_DENY, message + (_STOP_LAST.format(cap=REENTRY_CAP) if index == REENTRY_CAP else '')) + +UNREADABLE_STOP_LEDGER = 'unreadable-stop.jsonl' + +UNREADABLE_STOP_PHASE = 'unreadable-stop' + +UNREADABLE_STOP_WINDOW_SECONDS = 600 + +REENTRY_UNREADABLE = 'unreadable' + +_UNREADABLE_STOP_WARNING = 'chock {policy}: {cap} stops in a row had a payload chock could not read, so none of them was judged. The turn was allowed to end so the client does not loop; a commit will judge what is on disk, and a person must look at why the payload is unreadable.\n' + +_UNREADABLE_STOP_LAST = '\nThis is refusal {cap} of {cap} for stops chock cannot read: the next one ends the turn unchecked, and a commit will judge what is on disk. A person must look at why the payload is unreadable.' + +def _unreadable_stop_recent(lines, now): + """The refusals recorded inside the window; a record that does not parse, or has no time, counts as none.""" + kept = [] + for line in lines: + seen = _stop_parse(line) + stamp = seen.get('at') if isinstance(seen, dict) else None + refused = isinstance(seen, dict) and seen.get('phase') == UNREADABLE_STOP_PHASE + if refused and seen.get('verdict') == STOP_LOG_BLOCK and (type(stamp) in (int, float)) and (0 <= now - stamp < UNREADABLE_STOP_WINDOW_SECONDS): + kept.append(stamp) + return kept + +def settle_unreadable_stop(root, policy, refusal): + """(verdict, text) this unreadable Stop earns: a refusal with `refusal`, or a warning once the cap is spent. + + A ledger that is not a regular file in real directories, or cannot be written, keeps refusing: with nothing to count, only a refusal is safe. + """ + path = _chock_Path(root).joinpath(*SESSION_STATE_PARTS, UNREADABLE_STOP_LEDGER) + if not stop_ledger_safe(path): + return (VERDICT_DENY, refusal) + lines = _stop_lines(path) + now = _chock_datetime.now(_chock_timezone.utc).timestamp() + index = len(_unreadable_stop_recent(lines, now)) + 1 + capped = index > REENTRY_CAP + verdict = STOP_LOG_WARN if capped else STOP_LOG_BLOCK + _stop_append(path, lines, {'phase': UNREADABLE_STOP_PHASE, 'verdict': verdict, 'at': now, 'reentry': index}) + _stop_gate_log(root, policy, verdict, (index, REENTRY_UNREADABLE, None), []) + if capped: + text = _UNREADABLE_STOP_WARNING.format(policy=policy, cap=REENTRY_CAP) + with contextlib.suppress(Exception): + sys.stderr.write(text) + sys.stderr.flush() + return (VERDICT_WARN, text) + return (VERDICT_DENY, refusal + (_UNREADABLE_STOP_LAST.format(cap=REENTRY_CAP) if index == REENTRY_CAP else '')) + GATE_FLAG = '--gate' -_GATE_TIMEOUT_SECONDS = 30 +STOP_FLAG = '--stop' _GATE_DEPTH_TO_CHOCK = 3 _RUNNER_PARTS = ('bin', 'gate.py') +_PACKAGED_RUNNER = 'gate.py' + _GIT = 'git' -_DELETED = 'D' +_UTF8 = 'utf-8' -_RENAMED = 'R' +_PATH_ERRORS = 'surrogateescape' -GATE_BLOCKED = 'blocked' +_PARENT = '..' -GATE_CLEAN = 'clean' +_DRIVE_COLON = ':' -GATE_ERRORED = 'errored' +_DELETED = 'D' + +_RENAMED = 'R' VERDICT_DENY = 'deny' @@ -643,72 +1577,145 @@ def gate_path_from_argv(argv): return None def runner_for(gate): - """The vendored gate runner beside this compiled gate, or None when it is not there.""" + """The gate runner: beside the gate in a plugin, under .chock/bin in a repository, else None.""" + packaged = gate.resolve().parent / _PACKAGED_RUNNER + if packaged.exists(): + return packaged parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None 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.""" +def writes_from_event(event, root=None): + """The one file this tool call would write, or none when it carries no file text. + + An edit call carries only the text it inserts; judged alone, that fragment has no imports, + no class and no neighbours, so a gate that reads a whole file (a script kind) finds nothing + in it. An edit is therefore judged as the file it would leave: the file on disk with the + call's replacements applied. When that cannot be rebuilt -- the file is unreadable, or the + text to replace is not in it, so the call itself will fail -- the fragment is judged as + before and the turn's end judges what actually landed. A Codex apply_patch call names its + files only inside the patch; each one it adds or updates is judged as the patch leaves it. + """ + patched = patched_files(event, root) + if patched: + return patched path = getattr(event, 'path', None) content = getattr(event, 'content', None) + edited = edited_text(event, root) + if path and edited is not None: + return {str(path): edited} 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.""" +def _folded(pure): + """`pure` with `..` folded lexically; an absolute path never climbs above its anchor.""" + parts = [] + for part in pure.parts: + if part != _PARENT: + parts.append(part) + elif len(parts) > 1: + parts.pop() + return type(pure)(*parts) + +def repo_relative(path, root): + """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" + text = str(path) + if root is None: + return text + windows = _chock_os.name == 'nt' or str(root)[1:2] == _DRIVE_COLON + flavour = _chock_PureWindowsPath if windows else _chock_PurePosixPath 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] + return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() + except ValueError: + pass + if windows != (_chock_os.name == 'nt'): + return text + try: + return _chock_Path(root, text).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return text + +def repo_paths(path, root): + """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" + lexical = repo_relative(path, root) + if root is None or (str(root)[1:2] == _DRIVE_COLON) != (_chock_os.name == 'nt'): + return (lexical,) + try: + resolved = _chock_Path(root, str(path)).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return (lexical,) + return tuple(dict.fromkeys((lexical, resolved))) + +def changed_paths(repo_root, deadline=None, why=None): + """Every uncommitted path; None (reason appended to `why`) when git gave no answer, [] only outside a repository.""" + deadline, why = (engine_deadline() if deadline is None else deadline, [] if why is None else why) + argv = [_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'] + try: + timeout = time_left(deadline, argv) + proc = _chock_subprocess.run(argv, capture_output=True, text=True, encoding=_UTF8, errors=_PATH_ERRORS, timeout=timeout, check=False) + except _chock_subprocess.TimeoutExpired as exc: + why.append(f'git status did not finish within {allowed_seconds(exc)}') + except (OSError, _chock_subprocess.SubprocessError) as exc: + why.append(f'git status could not run ({exc})') + else: + first = ((proc.stderr or '').strip().splitlines() or [''])[0] + if proc.returncode and 'not a git repository' in first: + return [] + if proc.returncode: + why.append(f'git status failed (exit {proc.returncode}): {first}') + if why: + sys.stderr.write(f'chock: {why[-1]}, worktree not checked\n') + return None + fields = iter([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) + if status.startswith(_RENAMED): + next(fields, None) + if _DELETED not in status and path: + paths.append(path) return paths -def writes_from_worktree(repo_root): - """What this turn actually left on disk, however it was written. +def writes_from_worktree(repo_root, deadline=None, why=None): + """What this turn actually left on disk, however it was written; None when it could not be listed. 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. """ + paths = changed_paths(repo_root, deadline, why) + if paths is None: + return None writes = {} - for path in changed_paths(repo_root): + for path in paths: 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.""" +def run_gate(gate, writes, event, run_in, deadline): + """Ask the vendored runner. Returns (outcome, message) and never decides for itself. + + `run_in` is (root, extra): the working directory, and the stdin keys `extra` adds: `added` (per + edited path, only the text the edit introduces; a runner that predates it judges the whole file, + which only ever refuses more) and the script `session`. The runner gets what is left of `deadline`. + """ + root, extra = run_in runner = runner_for(gate) if runner is None: return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') + argv = [sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event] try: - proc = _chock_subprocess.run([sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event], input=json.dumps({'writes': writes}), capture_output=True, text=True, timeout=_GATE_TIMEOUT_SECONDS, check=False) + proc = _chock_subprocess.run(argv, input=json.dumps({'writes': writes, **(extra or {})}), capture_output=True, text=True, encoding=_UTF8, errors='replace', timeout=time_left(deadline, argv), check=False, cwd=str(root) if root is not None else None) + except _chock_subprocess.TimeoutExpired as exc: + return (GATE_ERRORED, f'the gate runner gave no verdict within {allowed_seconds(exc)}') 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()) + return runner_outcome(proc.returncode, proc.stderr) _EVENT_ARG = {'pre_tool': 'pre-tool-use', 'stop': 'stop'} @@ -719,33 +1726,262 @@ def root_for(gate): parents = gate.resolve().parents if len(parents) <= _GATE_DEPTH_TO_CHOCK: return None + if parents[_GATE_DEPTH_TO_CHOCK - 1].name != 'compiled' or parents[_GATE_DEPTH_TO_CHOCK].name != '.chock': + return None return parents[_GATE_DEPTH_TO_CHOCK].parent -def 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 {} +def repo_root_for(event, gate): + """The repository under judgement: the compiled layout's root, else where the agent works.""" root = root_for(gate) - return writes_from_worktree(root) if root is not None else {} + if root is not None: + return root + cwd = getattr(event, 'cwd', None) + return _chock_Path(cwd) if cwd else _chock_Path.cwd() + +def writes_for(event, gate, deadline=None, why=None): + """What this event puts under judgement: the call's own text, or what the turn left behind (None: unlisted).""" + if event.event == PRE_TOOL: + return writes_from_event(event, repo_root_for(event, gate)) + return writes_from_worktree(repo_root_for(event, gate), deadline, why) + +def _missing_gate(gate): + """A gate the hook names but that is not on disk: a broken install, so a refusal that says so.""" + return (VERDICT_DENY, f'chock gate {gate} is missing, so this write cannot be checked. Run `chock sync --repo .` to rebuild the compiled gates.') + +def _gate_says(gate, event, name, deadline): + """(decision, judged files): what the compiled gate says about this event, before a re-entered stop is weighed.""" + if not gate.exists(): + return (_missing_gate(gate), {}) + root = repo_root_for(event, gate) + outside = outside_globs(gate) + why = [] + listed = writes_for(event, gate, deadline, why) + if listed is None: + return (gate_decision(GATE_ERRORED, why[-1] if why else 'git status gave no answer', gate), {}) + writes = judged_files(listed, root, outside, lambda path: repo_paths(path, root)) + if not writes: + return (None, writes) + added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} + added = judged_files(added, root, outside, lambda path: repo_paths(path, root)) + added = {path: text for path, text in added.items() if path in writes} + extra = {**({'added': added} if added else {}), 'session': session_for(event, root)} + outcome, message = run_gate(gate, writes, name, (root, extra), deadline) + return (gate_decision(outcome, message, gate), writes) 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()): + if gate is None or name is None: 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.') + decision, judged = _gate_says(gate, event, name, engine_deadline()) + if event.event == PRE_TOOL: + return decision + return settle_stop(event, repo_root_for(event, gate), gate, decision, judged) + +TOOL_CALL_FLAG = '--tool-call' + +RECORD_FLAG = '--record' + +TOOL_CALL_EVENT = 'tool_call' + +_TOOL_CALL_PRE = 'pre_tool' + +_TOOL_CALL_POST = ('post_tool', 'tool_failure') + +_TOOL_CALL_SCRIPT_KIND = 'script' + +_TOOL_CALL_REGEX_KIND = 'content_regex' + +_TOOL_CALL_BLOCKED = 'blocked' + +_TOOL_CALL_NEEDS_SESSION = frozenset({_TOOL_CALL_SCRIPT_KIND}) + +_TOOL_CALL_EXIT_VERDICTS = {0: None, 1: 'deny', 3: 'escalate', 4: 'warn'} + +_TOOL_CALL_CEILING = {'block': 'deny', 'ask': 'escalate', 'warn': 'warn'} + +_TOOL_CALL_RANK = {'warn': 0, 'escalate': 1, 'deny': 2} + +_TOOL_CALL_UNDECIDED = ' -- refusing rather than allowing what it never judged' + +def _flag_path(argv, flag): + if flag in argv: + index = argv.index(flag) + if index + 1 < len(argv): + return _chock_Path(argv[index + 1]) return None +def tool_call_matches(globs, tool): + """Whether `tool` (a tool name, exact case) matches any glob; no globs match nothing.""" + return any((_chock_fnmatch.fnmatchcase(tool, str(glob)) for glob in globs)) + +def _tool_call_refusal(spec, spoken): + return spoken or str(spec.get('message') or '').strip() or 'blocked by a chock tool_call gate' + +def _tool_call_regex(spec, tool_input): + """`deny` verdict when the input, as JSON text, matches the gate's `content_pattern`.""" + text = json.dumps(tool_input, sort_keys=True, ensure_ascii=False) + if _chock_re.search(str((spec.get('params') or {}).get('content_pattern', '')), text): + return ('deny', _tool_call_refusal(spec, '')) + return None + +def _tool_call_script(spec, root, payload, deadline=None): + """Run the policy's script on `payload`; a missing script, crash or timeout refuses.""" + named = str((spec.get('params') or {}).get('script', '')) + script = _chock_Path(root) / named + if not script.is_file(): + return ('deny', f'script gate: {named!r} is not installed{_TOOL_CALL_UNDECIDED}') + deadline = engine_deadline() if deadline is None else deadline + argv = [sys.executable, str(script)] + try: + proc = _chock_subprocess.run(argv, input=json.dumps(payload), capture_output=True, text=True, encoding='utf-8', errors='replace', cwd=str(root), timeout=time_left(deadline, argv), check=False) + except _chock_subprocess.TimeoutExpired as exc: + budget = f'gave no verdict within {allowed_seconds(exc)}' + return ('deny', f'script gate: {script.name} {budget}{_TOOL_CALL_UNDECIDED}') + except OSError as exc: + return ('deny', f'script gate: {script.name} could not run ({exc}){_TOOL_CALL_UNDECIDED}') + spoken = ((proc.stderr or '') + (proc.stdout or '')).strip() + if proc.returncode in _TOOL_CALL_EXIT_VERDICTS: + verdict = _TOOL_CALL_EXIT_VERDICTS[proc.returncode] + return None if verdict is None else (verdict, spoken or f'blocked by {script.name}') + first = spoken.splitlines()[0] if spoken else '' + detail = f': {first}' if first else '' + return ('deny', f'script gate: {script.name} exited {proc.returncode}{detail}{_TOOL_CALL_UNDECIDED}') + +def _tool_call_verdict(spec, root, event, deadline): + """None to allow, else (verdict, message) for this call; a kind a tool call cannot answer refuses.""" + tool, tool_input = (str(event.tool or ''), tool_call_input(event)) + kind = spec.get('kind') + if kind == _TOOL_CALL_REGEX_KIND: + return _tool_call_regex(spec, tool_input) + if kind == _TOOL_CALL_SCRIPT_KIND: + payload = {'event': TOOL_CALL_EVENT, 'repo_root': str(root), 'tool': tool, 'input': tool_input, 'session': session_for(event, root)} + return _tool_call_script(spec, root, payload, deadline) + return ('deny', f'chock tool_call gate: kind {kind!r} cannot judge a tool call{_TOOL_CALL_UNDECIDED}') + +def _tool_call_capped(spec, verdict): + """`verdict` held to the gate's declared action; an unknown action keeps the verdict as spoken.""" + ceiling = _TOOL_CALL_CEILING.get(spec.get('action', 'block')) + if verdict is None or ceiling is None: + return verdict + return (min(verdict[0], ceiling, key=_TOOL_CALL_RANK.__getitem__), verdict[1]) + +def _tool_call_spec(gate): + """The compiled gate as a dict, or (None, refusal) when the install is broken.""" + if not gate.exists(): + return (None, f'chock gate {gate} is missing, so this tool call cannot be checked. Run `chock sync --repo .` to rebuild the compiled gates.') + try: + return (json.loads(gate.read_text(encoding='utf-8')), None) + except (OSError, ValueError) as exc: + return (None, f'chock could not read {gate}: {exc}. Refusing rather than reporting an allow it never established.') + +def evaluate_tool_call(argv, event): + """The decision a tool_call gate earns this call, or None; also logs the call for the session. + + `--tool-call ` judges a PreToolUse call and, for a gate whose check reads the session, + logs it after the verdict, so the script reads prior calls only. `--record ` logs what + a call came to, after it ran (a vendor's PostToolUse and failure events). + """ + recording = _flag_path(argv, RECORD_FLAG) + if recording is not None: + if event.event in _TOOL_CALL_POST: + session_record(repo_root_for(event, recording), event, 'post', session_outcome(event)) + return None + gate = _flag_path(argv, TOOL_CALL_FLAG) + if gate is None or event.event != _TOOL_CALL_PRE: + return None + spec, broken = _tool_call_spec(gate) + if spec is None: + return ('deny', broken) + root = repo_root_for(event, gate) + verdict = None + if TOOL_CALL_EVENT in spec.get('on', []) and tool_call_matches((spec.get('params') or {}).get('tools', []), str(event.tool or '')): + verdict = _tool_call_capped(spec, _tool_call_verdict(spec, root, event, engine_deadline())) + if spec.get('kind') in _TOOL_CALL_NEEDS_SESSION: + session_record(root, event, 'pre', _TOOL_CALL_BLOCKED if verdict and verdict[0] == 'deny' else None) + return verdict +_CHOCK_UNREADABLE = ( + "chock could not read this hook's payload, so it could not check the call. " + "Refusing rather than reporting an allow it never established." +) + +#: A payload naming only this vendor's own wire event, so `respond` picks that event's grammar. +_CHOCK_WIRE_RAW = {PRE_TOOL: {'hook_event_name': 'PreToolUse'}, STOP: {'hook_event_name': 'Stop'}} + +#: The words a client reads as a refusal, in any of the answer fields a vendor grammar uses. +_CHOCK_REFUSING = ("deny", "block", "ask", "continue") +_CHOCK_ANSWER_KEYS = ("decision", "permission", "permissionDecision") + + +def _chock_spec_action(path): + try: + action = json.loads(path.read_text(encoding="utf-8")).get("action") + except Exception: + return "block" + return action if action in ("ask", "warn") else "block" + + +def _chock_declared(): + """(strictest action, event, gate) of the judging flags; (None, None, None) where this run judges nothing.""" + argv = sys.argv[1:] + actions = ["block"] if guard_path_from_argv(argv) is not None else [] + event = PRE_TOOL + gate = _flag_path(argv, GATE_FLAG) + if gate is not None: + actions.append(_chock_spec_action(gate)) + event = STOP if STOP_FLAG in argv or gate.parent.name == _EVENT_ARG[STOP] else PRE_TOOL + called = _flag_path(argv, TOOL_CALL_FLAG) + if called is not None: + actions.append(_chock_spec_action(called)) + for action in ("block", "ask", "warn"): + if action in actions: + return action, event, gate + return None, None, None + + +def _chock_refuses(text, code): + if code == 2: + return True + try: + body = json.loads(text) + except Exception: + return False + if not isinstance(body, dict): + return False + nested = body.get("hookSpecificOutput") + answers = [body] + ([nested] if isinstance(nested, dict) else []) + return any(part.get(key) in _CHOCK_REFUSING for part in answers for key in _CHOCK_ANSWER_KEYS) + + +def _chock_unreadable_stop(gate): + """The bounded verdict of an unreadable Stop: a refusal, or a warning once the cap is spent.""" + root = root_for(gate) or _chock_Path.cwd() + return settle_unreadable_stop(root, gate.parent.parent.name, _CHOCK_UNREADABLE) + + +def _chock_unreadable(): + action, kind, gate = _chock_declared() + if action in (None, "warn"): + return "", 0 + event = Event(AGENT, kind, raw=dict(_CHOCK_WIRE_RAW[kind])) + reason = _CHOCK_UNREADABLE + if kind == STOP and gate is not None: + verdict, reason = _chock_unreadable_stop(gate) + if verdict == VERDICT_WARN: + return _chock_copilot_respond(degrade(Decision.warn(reason), event), event) + _report(reason + "\n") + decision = Decision.escalate(reason) if action == "ask" else Decision.deny(reason) + text, code = _chock_copilot_respond(degrade(decision, event), event) + return (text, code) if _chock_refuses(text, code) else ("", 2) + + +def _spoken(verdict): + outcome, reason = verdict + if outcome == VERDICT_WARN: + return Decision.warn(reason) + return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + def _judge(event): if event.event == "pre_tool" and event.command: @@ -755,7 +1991,10 @@ def _judge(event): 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 _spoken(gated) + called = evaluate_tool_call(sys.argv[1:], event) + if called is not None: + return _spoken(called) return None @@ -770,6 +2009,41 @@ def handle(event): "chock could not check this call (%s: %s). Refusing rather than reporting an " "allow it never established." % (type(exc).__name__, exc) ) + + +def _chock_copilot_respond(decision, event): + # Copilot's own runtime answered a top-level deny and a top-level Stop block in live probes + # (VS Code agent mode, 2026-09-28); which of the top-level and nested Stop blocks it reads was + # never isolated. The nested answer agentseam writes stays, and the witnessed top-level one + # is added beside it, so either reading refuses. + text, code = respond(decision, event) + if not text: + return _chock_stop_warning(decision, event) or text, code + body = json.loads(text) + nested = body.get("hookSpecificOutput") + if not isinstance(nested, dict): + return text, code + if event.event == PRE_TOOL and nested.get("permissionDecision") == "deny": + body["permissionDecision"] = nested["permissionDecision"] + body["permissionDecisionReason"] = nested.get("permissionDecisionReason") + elif event.event == STOP and nested.get("decision") == "block": + body["decision"] = nested["decision"] + body["reason"] = nested.get("reason") + return json.dumps(body), code + + +def _chock_stop_warning(decision, event): + # A warn at the turn's end never blocks: a Stop block would force the turn to continue. agentseam + # reduces WARN to a silent allow, which drops the reason, so it is added back as `systemMessage`, + # which Claude Code, Codex and VS Code Copilot each document as a warning shown to the user. + if event.event != STOP or degraded_from(decision) != WARN or not decision.reason: + return None + return json.dumps({"systemMessage": decision.reason}) + + +def _chock_stop_warn_respond(decision, event): + text, code = respond(decision, event) + return text or _chock_stop_warning(decision, event) or text, code # <<< agentseam handler <<< @@ -815,6 +2089,55 @@ def _report(text): return +_STDERR_FD = 2 + + +def _divert_fd1(): + """Point fd 1 at stderr (devnull if there is none); the saved fd 1, or None if it could not.""" + try: + saved = os.dup(1) + except OSError: + return None + # With fd 2 closed, dup() hands back 2 itself, and "stderr" would be stdout again. + if saved > _STDERR_FD: + with contextlib.suppress(OSError): + os.dup2(_STDERR_FD, 1) + return saved + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +def _flush(streams): + """Flush each stream that exists; a broken one is no reason to lose the verdict.""" + for stream in streams: + if stream is not None: + with contextlib.suppress(Exception): + stream.flush() + + +@contextlib.contextmanager +def _stdout_to_stderr(): + # Stdout is the verdict channel. A handler's stray print (or a child process it runs) + # ahead of the JSON makes the host fail to parse it, and Claude Code and Gemini CLI then + # treat the hook as a non-blocking error: a deny became an allow, witnessed live. + sink = sys.stderr if sys.stderr is not None else io.StringIO() + held = (sys.stdout, sys.__stdout__) + _flush(held) + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + # A stream the handler held on to (sys.__stdout__, a reference cached at import) + # buffers past redirect_stdout; flushed now it lands on stderr, not after the verdict. + _flush((sink, *held)) + os.dup2(saved, 1) + os.close(saved) + + def _decide(raw): """(stdout_text, exit_code) for one decoded payload: agentseam.dispatch.handle(), inlined.""" event = parse(raw) @@ -824,7 +2147,9 @@ def _decide(raw): # vocabulary invites a decision made on a false premise. return "", 0 try: - decision = _coerce(handle(event)) + with _stdout_to_stderr(): + result = handle(event) + decision = _coerce(result) except Exception: # A door that cannot decide refuses. Escaping here would exit 1 with a traceback, # which every host reads as a non-blocking error and allows past; instead the @@ -836,7 +2161,7 @@ def _decide(raw): "policy handler failed (%s); refusing rather than allowing what it could not judge" % sys.exc_info()[0].__name__ ) - return respond(degrade(decision, event), event) + return _chock_copilot_respond(degrade(decision, event), event) def main(stdin=None, stdout=None, exit=True): @@ -846,10 +2171,14 @@ def main(stdin=None, stdout=None, exit=True): try: raw = json.loads(_read_payload(stream)) except Exception: - # Malformed input is not the agent's fault to pay for: allow, stay silent. + raw = None + if not isinstance(raw, dict): + text, code = _chock_unreadable() + if text: + _emit(out, text) if exit: - sys.exit(0) - return 0 + sys.exit(code) + return code try: text, code = _decide(raw) except Exception: diff --git a/.chock/compiled/block-no-verify/agent-hooks/agent-hooks.json b/.chock/compiled/block-no-verify/agent-hooks/agent-hooks.json index 7ac1d33..f849c68 100644 --- a/.chock/compiled/block-no-verify/agent-hooks/agent-hooks.json +++ b/.chock/compiled/block-no-verify/agent-hooks/agent-hooks.json @@ -1,10 +1,10 @@ { "type": "command", "matcher": "bash|powershell|pwsh|sh|shell", - "timeout": 30, - "timeoutSec": 30, - "bash": "repo=\"$(git rev-parse --show-toplevel)\"; PY=\"$(command -v python3 || command -v python || command -v py)\"; [ -n \"$PY\" ] || { echo \"chock: no python interpreter found\" >&2; exit 1; }; exec \"$PY\" \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/block-no-verify/implementations/block-no-verify.sh\"", - "command": "repo=\"$(git rev-parse --show-toplevel)\"; PY=\"$(command -v python3 || command -v python || command -v py)\"; [ -n \"$PY\" ] || { echo \"chock: no python interpreter found\" >&2; exit 1; }; exec \"$PY\" \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/block-no-verify/implementations/block-no-verify.sh\"", - "powershell": "$repo = (git rev-parse --show-toplevel); $py = (Get-Command python3, python, py -ErrorAction SilentlyContinue | Where-Object { $_.Source -and $_.Source -notlike '*WindowsApps*' } | Select-Object -First 1).Source; if (-not $py) { [Console]::Error.WriteLine('chock: no python interpreter found'); exit 1 }; $input | & $py \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/block-no-verify/implementations/block-no-verify.sh\"; exit $LASTEXITCODE", - "windows": "$repo = (git rev-parse --show-toplevel); $py = (Get-Command python3, python, py -ErrorAction SilentlyContinue | Where-Object { $_.Source -and $_.Source -notlike '*WindowsApps*' } | Select-Object -First 1).Source; if (-not $py) { [Console]::Error.WriteLine('chock: no python interpreter found'); exit 1 }; $input | & $py \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/block-no-verify/implementations/block-no-verify.sh\"; exit $LASTEXITCODE" + "timeout": 45, + "timeoutSec": 45, + "bash": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "powershell": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE", + "windows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE" } \ No newline at end of file diff --git a/.chock/compiled/block-no-verify/pre-tool-use/antigravity-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/antigravity-hooks.json index 5de15fe..9cdc134 100644 --- a/.chock/compiled/block-no-verify/pre-tool-use/antigravity-hooks.json +++ b/.chock/compiled/block-no-verify/pre-tool-use/antigravity-hooks.json @@ -5,7 +5,8 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/antigravity.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/antigravity.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "timeout": 45 } ] } diff --git a/.chock/compiled/block-no-verify/pre-tool-use/codex_cli-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/codex_cli-hooks.json index ef9b11d..82d9984 100644 --- a/.chock/compiled/block-no-verify/pre-tool-use/codex_cli-hooks.json +++ b/.chock/compiled/block-no-verify/pre-tool-use/codex_cli-hooks.json @@ -5,8 +5,9 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/codex_cli.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", - "commandWindows": "& @CHOCK_PYTHON@ \".chock/bin/codex_cli.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE", + "timeout": 45 } ], "matcher": "Bash" diff --git a/.chock/compiled/block-no-verify/pre-tool-use/cursor-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/cursor-hooks.json index 96bfccf..d9a57bf 100644 --- a/.chock/compiled/block-no-verify/pre-tool-use/cursor-hooks.json +++ b/.chock/compiled/block-no-verify/pre-tool-use/cursor-hooks.json @@ -1,8 +1,9 @@ { "beforeShellExecution": [ { - "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/cursor.py\" --guard \"${CLAUDE_PROJECT_DIR}/.agents/policies/block-no-verify/implementations/block-no-verify.sh\"", - "timeout": 30 + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/cursor.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "timeout": 45, + "failClosed": true } ] } \ No newline at end of file diff --git a/.chock/compiled/block-no-verify/pre-tool-use/devin-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/devin-hooks.json index 773dc47..3e776c5 100644 --- a/.chock/compiled/block-no-verify/pre-tool-use/devin-hooks.json +++ b/.chock/compiled/block-no-verify/pre-tool-use/devin-hooks.json @@ -4,7 +4,8 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/devin.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/devin.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "timeout": 45 } ] } diff --git a/.chock/compiled/block-no-verify/pre-tool-use/gemini_cli-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/gemini_cli-hooks.json index f5e839d..1962fb4 100644 --- a/.chock/compiled/block-no-verify/pre-tool-use/gemini_cli-hooks.json +++ b/.chock/compiled/block-no-verify/pre-tool-use/gemini_cli-hooks.json @@ -5,7 +5,8 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/gemini_cli.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "timeout": 45000 } ], "matcher": "run_shell_command" diff --git a/.chock/compiled/block-no-verify/pre-tool-use/grok-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/grok-hooks.json index 2126899..54808fa 100644 --- a/.chock/compiled/block-no-verify/pre-tool-use/grok-hooks.json +++ b/.chock/compiled/block-no-verify/pre-tool-use/grok-hooks.json @@ -5,7 +5,8 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/grok.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/grok.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "timeout": 45 } ] } diff --git a/.chock/compiled/block-no-verify/pre-tool-use/pretooluse.json b/.chock/compiled/block-no-verify/pre-tool-use/pretooluse.json index c1d61be..a81314d 100644 --- a/.chock/compiled/block-no-verify/pre-tool-use/pretooluse.json +++ b/.chock/compiled/block-no-verify/pre-tool-use/pretooluse.json @@ -1,10 +1,10 @@ { - "matcher": "Bash", + "matcher": "Bash|PowerShell", "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --guard \"${CLAUDE_PROJECT_DIR}/.agents/policies/block-no-verify/implementations/block-no-verify.sh\"", - "timeout": 30 + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "timeout": 45 } ] } \ No newline at end of file diff --git a/.chock/compiled/block-no-verify/pre-tool-use/tabnine-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/tabnine-hooks.json index 6a9fd70..9286aea 100644 --- a/.chock/compiled/block-no-verify/pre-tool-use/tabnine-hooks.json +++ b/.chock/compiled/block-no-verify/pre-tool-use/tabnine-hooks.json @@ -5,8 +5,9 @@ "hooks": [ { "type": "command", - "command": "@CHOCK_PYTHON@ \".chock/bin/tabnine.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", - "name": "agentseam" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/tabnine.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "name": "agentseam", + "timeout": 45000 } ] } diff --git a/.chock/compiled/block-no-verify/pre-tool-use/windsurf-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/windsurf-hooks.json index 5e47270..39b7e64 100644 --- a/.chock/compiled/block-no-verify/pre-tool-use/windsurf-hooks.json +++ b/.chock/compiled/block-no-verify/pre-tool-use/windsurf-hooks.json @@ -2,12 +2,12 @@ "hooks": { "pre_run_command": [ { - "command": "@CHOCK_PYTHON@ \".chock/bin/windsurf.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" } ], "pre_mcp_tool_use": [ { - "command": "@CHOCK_PYTHON@ \".chock/bin/windsurf.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/windsurf.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" } ] } diff --git a/.chock/coverage.json b/.chock/coverage.json index 6167dc2..bdcfb7e 100644 --- a/.chock/coverage.json +++ b/.chock/coverage.json @@ -3,34 +3,40 @@ "claude": { "level": "best-effort", "basis": "live-run", - "witnessed": false + "witnessed": false, + "at_commit": false }, "copilot": { "level": "best-effort", "basis": "live-run-partial", - "witnessed": true + "witnessed": true, + "at_commit": false }, "gemini": { "level": "best-effort", "basis": "vendor-source", - "witnessed": false + "witnessed": false, + "at_commit": false } }, "protect-main-branch": { "claude": { "level": "enforced-at-commit", "basis": null, - "witnessed": false + "witnessed": false, + "at_commit": true }, "copilot": { "level": "enforced-at-commit", "basis": null, - "witnessed": false + "witnessed": false, + "at_commit": true }, "gemini": { "level": "enforced-at-commit", "basis": null, - "witnessed": false + "witnessed": false, + "at_commit": true } } } \ No newline at end of file From 637217edee794f23bf92a5a56c68c25764a6412c Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:38:10 -0400 Subject: [PATCH 3/3] regen: plugins, hook configs and lock under the README's engine pin Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- .../skills/block-no-verify/SKILL.md | 2 +- .../skills/protect-main-branch/SKILL.md | 2 +- .claude/settings.json | 8 ++++---- .gemini/settings.json | 3 ++- .github/hooks/chock.json | 12 +++++------ .gitignore | 20 +++++++++++++++++++ chock.lock | 6 +++--- 7 files changed, 37 insertions(+), 16 deletions(-) diff --git a/.agents/policies/block-no-verify/skills/block-no-verify/SKILL.md b/.agents/policies/block-no-verify/skills/block-no-verify/SKILL.md index dddc3b3..5871438 100644 --- a/.agents/policies/block-no-verify/skills/block-no-verify/SKILL.md +++ b/.agents/policies/block-no-verify/skills/block-no-verify/SKILL.md @@ -16,4 +16,4 @@ never(commit|push): --no-verify|-n if(hook_fails): fix_issue; never(skip_hook) ``` -This skill is advisory: the client reading it has no mechanism to enforce it, and this policy stays advisory even when compiled by `chock` -- it ships rule text, not a blocking hook. See https://github.com/open-coder-ai/chock +This skill is advisory: the client reading it has no mechanism to enforce it. The same policy compiled by `chock` can refuse an agent's shell command before it runs. See https://github.com/open-coder-ai/chock diff --git a/.agents/policies/protect-main-branch/skills/protect-main-branch/SKILL.md b/.agents/policies/protect-main-branch/skills/protect-main-branch/SKILL.md index 5a5f765..3ade30e 100644 --- a/.agents/policies/protect-main-branch/skills/protect-main-branch/SKILL.md +++ b/.agents/policies/protect-main-branch/skills/protect-main-branch/SKILL.md @@ -16,4 +16,4 @@ on(commit|push): block(forbidden_ref) refs=main|master Direct commits/pushes to a protected branch (main|master) are blocked. Create a feature branch and open a pull request. ``` -This skill is advisory: the client reading it has no mechanism to enforce it. The same policy compiled by `chock` becomes a git hook that exits non-zero. See https://github.com/open-coder-ai/chock +This skill is advisory: the client reading it has no mechanism to enforce it. The same policy compiled by `chock` blocks at commit and at push. See https://github.com/open-coder-ai/chock diff --git a/.claude/settings.json b/.claude/settings.json index cf6dcae..3912cec 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "\"/usr/local/bin/python3\" \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py", "timeout": 300 } ] @@ -13,12 +13,12 @@ ], "PreToolUse": [ { - "matcher": "Bash", + "matcher": "Bash|PowerShell", "hooks": [ { "type": "command", - "command": "\"/usr/local/bin/python3\" \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --guard \"${CLAUDE_PROJECT_DIR}/.agents/policies/block-no-verify/implementations/block-no-verify.sh\"", - "timeout": 30 + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/claude_code.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "timeout": 45 } ] } diff --git a/.gemini/settings.json b/.gemini/settings.json index d575b09..1962fb4 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -5,7 +5,8 @@ "hooks": [ { "type": "command", - "command": "\"/usr/local/bin/python3\" \".chock/bin/gemini_cli.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/gemini_cli.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "timeout": 45000 } ], "matcher": "run_shell_command" diff --git a/.github/hooks/chock.json b/.github/hooks/chock.json index c0a407b..2ae68e4 100644 --- a/.github/hooks/chock.json +++ b/.github/hooks/chock.json @@ -5,12 +5,12 @@ { "type": "command", "matcher": "bash|powershell|pwsh|sh|shell", - "timeout": 30, - "timeoutSec": 30, - "bash": "repo=\"$(git rev-parse --show-toplevel)\"; PY=\"$(command -v python3 || command -v python || command -v py)\"; [ -n \"$PY\" ] || { echo \"chock: no python interpreter found\" >&2; exit 1; }; exec \"$PY\" \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/block-no-verify/implementations/block-no-verify.sh\"", - "command": "repo=\"$(git rev-parse --show-toplevel)\"; PY=\"$(command -v python3 || command -v python || command -v py)\"; [ -n \"$PY\" ] || { echo \"chock: no python interpreter found\" >&2; exit 1; }; exec \"$PY\" \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/block-no-verify/implementations/block-no-verify.sh\"", - "powershell": "$repo = (git rev-parse --show-toplevel); $py = (Get-Command python3, python, py -ErrorAction SilentlyContinue | Where-Object { $_.Source -and $_.Source -notlike '*WindowsApps*' } | Select-Object -First 1).Source; if (-not $py) { [Console]::Error.WriteLine('chock: no python interpreter found'); exit 1 }; $input | & $py \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/block-no-verify/implementations/block-no-verify.sh\"; exit $LASTEXITCODE", - "windows": "$repo = (git rev-parse --show-toplevel); $py = (Get-Command python3, python, py -ErrorAction SilentlyContinue | Where-Object { $_.Source -and $_.Source -notlike '*WindowsApps*' } | Select-Object -First 1).Source; if (-not $py) { [Console]::Error.WriteLine('chock: no python interpreter found'); exit 1 }; $input | & $py \"$repo/.chock/bin/vscode_copilot.py\" --guard \"$repo/.agents/policies/block-no-verify/implementations/block-no-verify.sh\"; exit $LASTEXITCODE" + "timeout": 45, + "timeoutSec": 45, + "bash": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "powershell": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE", + "windows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE" } ] } diff --git a/.gitignore b/.gitignore index 96084a1..c8c09ae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,22 @@ # Gate telemetry is local evidence, never committed content. .chock/log/ +# chock: the per-session tool-call log is per machine and never shared +.chock/state/ +# chock: the gate runtime and compiled gates are what every hook runs -- tracked even where +# a global rule (a `bin/` from a Visual Studio or Java template) would ignore them +!.chock/bin/ +!.chock/bin/** +!.chock/compiled/ +!.chock/compiled/** +# chock: installed policies are tracked whole -- a policy's `build/` sub-package must reach every +# clone, whatever a `build/`, `out/` or `target/` rule elsewhere says +!.agents/policies/ +!.agents/policies/** +# chock: bytecode under the policies is per machine (must stay after the rules above) +.agents/policies/**/__pycache__/ +.agents/policies/**/*.pyc +# chock: bytecode under the runtime and compiled gates is per machine (must stay after the rules above) +.chock/bin/**/__pycache__/ +.chock/bin/**/*.pyc +.chock/compiled/**/__pycache__/ +.chock/compiled/**/*.pyc diff --git a/chock.lock b/chock.lock index 4645faf..a2b96e9 100644 --- a/chock.lock +++ b/chock.lock @@ -6,15 +6,15 @@ "id": "block-no-verify", "version": "0.0.1", "managed": false, - "sha256": "fa557dc2d82ad4c54691a23e238e5928590dcb054571b482e66f5937e789d76c", + "sha256": "f68c0d080031d7b59e73965ecfe47692784410ecfd4f351be49f5c666e3c9892", "source": "local", - "artifacts_sha256": "fc65a16e8d3d84f2735a86922e16fa21324fcb63553c17cdd32bb87ef5a6d906" + "artifacts_sha256": "3ca40596ae0bb83532989943a60cf2d26e51ae587193cf87e37a617a49eb518c" }, { "id": "protect-main-branch", "version": "0.0.1", "managed": false, - "sha256": "316bd80a2fdbeb098f8477503af8c9e0977cf11be4e32572d87014ef062e055d", + "sha256": "a0c154b0e2fc31a90f518419d29177a487702f881e7f0158e566271d5a8a61a8", "source": "local", "artifacts_sha256": "18bb4772936ece0e374fb48e1a09fdbf946fb82381ed30687c0bcc8a10d4643c" }