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/.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/.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 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/.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 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" }