diff --git a/.chock/bin/claude_code.py b/.chock/bin/claude_code.py index f39b0fd3..f8e1fa2a 100755 --- a/.chock/bin/claude_code.py +++ b/.chock/bin/claude_code.py @@ -29,6 +29,7 @@ from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings +import zlib as _zlib # ------------------------------------------------------------------------------ # contract (agentseam 0.3.5) @@ -235,11 +236,31 @@ def tool_input_of(raw): "effort", ) +_MCP_PREFIX = "mcp_" + +_CLAUDE_MCP_PREFIX = "mcp__" + def looks_like_claude_code(raw): """True when the payload carries a field only Claude Code has been seen to send.""" return isinstance(raw, dict) and any(marker in raw for marker in OBSERVED_MARKERS) -PROBES = {"looks_like_claude_code": looks_like_claude_code} +def names_a_single_underscore_mcp_tool(raw): + """True when tool_name is an MCP tool ID in a spelling Claude Code never sends.""" + name = raw.get("tool_name") if isinstance(raw, dict) else None + return isinstance(name, str) and name.startswith(_MCP_PREFIX) and not name.startswith(_CLAUDE_MCP_PREFIX) + +def event_keys_agree(raw): + """True when hookEventName names hook_event_name's event: the same text, or Grok Build's snake_case twin.""" + snake, pascal = (raw.get("hookEventName"), raw.get("hook_event_name")) if isinstance(raw, dict) else (None, None) + if not isinstance(pascal, str) or not isinstance(snake, str): + return False + return snake == pascal or (snake.islower() and snake.replace("_", "") == pascal.lower()) + +PROBES = { + "looks_like_claude_code": looks_like_claude_code, + "names_a_single_underscore_mcp_tool": names_a_single_underscore_mcp_tool, + "event_keys_agree": event_keys_agree, +} UNREADABLE_NAME = "" @@ -265,12 +286,22 @@ def _rejected_by_markers(c, raw): for marker in c.get("reject_markers", ()): if marker in raw: return True + for key, values in c.get("reject_values", {}).items(): + if key in raw and raw[key] in values: + return True for probe, markers in c.get("reject_markers_unless_probe", {}).items(): if any(marker in raw for marker in markers) and not PROBES[probe](raw): return True return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) +def _accepted_by_values(c, raw): + """True unless the entry names accepted values and the payload carries none of them.""" + values = c.get("accept_values") + return not values or any(key in raw and raw[key] in allowed for key, allowed in values.items()) + def _accepted_by_markers(c, raw, name): + if not _accepted_by_values(c, raw): + return False accept = c.get("accept_markers", ()) if not accept or any(marker in raw for marker in accept): return True @@ -309,12 +340,25 @@ def _segment(node, part): return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None return node.get(part) if isinstance(node, dict) else None +def _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + def _walk(node, path): - """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None if "[]." in path: head, sub = path.split("[].", 1) - items = _walk(node, head) - if not isinstance(items, (list, tuple)): + items = _list_at(node, head) + if items is None: return None joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) return joined or None @@ -344,7 +388,7 @@ def _tool_input_raw(cfg, raw): if not isinstance(raw, dict): return None for key in cfg["fields"].get("tool_input", ("tool_input",)): - value = raw.get(key) + value = _walk(raw, key) if value is not None: return value return None @@ -566,6 +610,8 @@ def hj_respond(cfg, decision, event, wire=None): return "", 0 if gate["grammar"] == "G2": return _g2(v, gate, decision, name) + if gate["grammar"] == "G7": + return _g7(v, decision, wire) return _g1(v, gate, decision, wire, name) _WINDOWS_KEYS = ("commandWindows", "windows") @@ -581,16 +627,25 @@ def _hook_dict(cfg, command): entry[key] = value return entry +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): rules = [] for ev in canonical_events: name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: - rule["matcher"] = matcher - rules.append(rule) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) return rules def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): @@ -619,6 +674,10 @@ def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): hooks.setdefault(extra, []).append({"command": command}) return {"hooks": hooks} +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] hooks = {} @@ -649,6 +708,8 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) if wrapper == "flat_entries": return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) return _default_wrapper(cfg, reverse, canonical_events, command, matcher) @@ -657,7 +718,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': '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'}}} +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). A value Claude Code never sends beats every marker key, looks_like_claude_code's included (value-based detection, D1): permission_mode values off, spec, auto-low, auto-medium and auto-high are Factory Droid's (docs.factory.com/harness/hooks, read 2026-10-11); tool_name values fs_read, fsRead, fs_write, fsWrite, execute_bash, execute_cmd and use_aws (kiro.dev/docs/reference/built-in-tools, updated 2026-10-10), and an MCP tool spelled mcp__ rather than Claude's mcp____ (kiro.dev/docs/hooks/types, updated 2026-10-10), are Kiro's. Kiro's V3 primary names (read, write, shell) and its tool-less events carry nothing Claude Code does not, so those still read as claude_code. factory_droid claims Droid's payloads on those same permission_mode values (accept_values, D2a); Kiro has no adapter, so its separable payloads detect as no agent rather than as claude_code.", 'reject_markers': ('turn_id', 'project_path', 'timestamp'), 'reject_markers_unless_probe': {'looks_like_claude_code': ('prompt_id',)}, 'reject_probes': ('names_a_single_underscore_mcp_tool',), 'reject_values': {'permission_mode': ('off', 'spec', 'auto-low', 'auto-medium', 'auto-high'), 'tool_name': ('fs_read', 'fsRead', 'fs_write', 'fsWrite', 'execute_bash', 'execute_cmd', 'use_aws')}}, '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): diff --git a/.chock/bin/codex_cli.py b/.chock/bin/codex_cli.py index f1e41dba..6f7a3a65 100755 --- a/.chock/bin/codex_cli.py +++ b/.chock/bin/codex_cli.py @@ -29,6 +29,7 @@ from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings +import zlib as _zlib # ------------------------------------------------------------------------------ # contract (agentseam 0.3.5) @@ -262,12 +263,22 @@ def _rejected_by_markers(c, raw): for marker in c.get("reject_markers", ()): if marker in raw: return True + for key, values in c.get("reject_values", {}).items(): + if key in raw and raw[key] in values: + return True for probe, markers in c.get("reject_markers_unless_probe", {}).items(): if any(marker in raw for marker in markers) and not PROBES[probe](raw): return True return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) +def _accepted_by_values(c, raw): + """True unless the entry names accepted values and the payload carries none of them.""" + values = c.get("accept_values") + return not values or any(key in raw and raw[key] in allowed for key, allowed in values.items()) + def _accepted_by_markers(c, raw, name): + if not _accepted_by_values(c, raw): + return False accept = c.get("accept_markers", ()) if not accept or any(marker in raw for marker in accept): return True @@ -306,12 +317,25 @@ def _segment(node, part): return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None return node.get(part) if isinstance(node, dict) else None +def _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + def _walk(node, path): - """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None if "[]." in path: head, sub = path.split("[].", 1) - items = _walk(node, head) - if not isinstance(items, (list, tuple)): + items = _list_at(node, head) + if items is None: return None joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) return joined or None @@ -341,7 +365,7 @@ def _tool_input_raw(cfg, raw): if not isinstance(raw, dict): return None for key in cfg["fields"].get("tool_input", ("tool_input",)): - value = raw.get(key) + value = _walk(raw, key) if value is not None: return value return None @@ -563,6 +587,8 @@ def hj_respond(cfg, decision, event, wire=None): return "", 0 if gate["grammar"] == "G2": return _g2(v, gate, decision, name) + if gate["grammar"] == "G7": + return _g7(v, decision, wire) return _g1(v, gate, decision, wire, name) _WINDOWS_KEYS = ("commandWindows", "windows") @@ -578,16 +604,25 @@ def _hook_dict(cfg, command): entry[key] = value return entry +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): rules = [] for ev in canonical_events: name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: - rule["matcher"] = matcher - rules.append(rule) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) return rules def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): @@ -616,6 +651,10 @@ def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): hooks.setdefault(extra, []).append({"command": command}) return {"hooks": hooks} +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] hooks = {} @@ -646,6 +685,8 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) if wrapper == "flat_entries": return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) return _default_wrapper(cfg, reverse, canonical_events, command, matcher) diff --git a/.chock/bin/cursor.py b/.chock/bin/cursor.py index af70c81c..b4757b40 100755 --- a/.chock/bin/cursor.py +++ b/.chock/bin/cursor.py @@ -29,6 +29,7 @@ from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings +import zlib as _zlib # ------------------------------------------------------------------------------ # contract (agentseam 0.3.5) @@ -253,12 +254,25 @@ def _segment(node, part): return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None return node.get(part) if isinstance(node, dict) else None +def _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + def _walk(node, path): - """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None if "[]." in path: head, sub = path.split("[].", 1) - items = _walk(node, head) - if not isinstance(items, (list, tuple)): + items = _list_at(node, head) + if items is None: return None joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) return joined or None @@ -288,7 +302,7 @@ def _tool_input_raw(cfg, raw): if not isinstance(raw, dict): return None for key in cfg["fields"].get("tool_input", ("tool_input",)): - value = raw.get(key) + value = _walk(raw, key) if value is not None: return value return None @@ -369,16 +383,25 @@ def _hook_dict(cfg, command): entry[key] = value return entry +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): rules = [] for ev in canonical_events: name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: - rule["matcher"] = matcher - rules.append(rule) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) return rules def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): @@ -407,6 +430,10 @@ def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): hooks.setdefault(extra, []).append({"command": command}) return {"hooks": hooks} +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] hooks = {} @@ -437,6 +464,8 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) if wrapper == "flat_entries": return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) return _default_wrapper(cfg, reverse, canonical_events, command, matcher) _AMBIGUOUS_NAMES = ( diff --git a/.chock/bin/devin.py b/.chock/bin/devin.py index e93a7a95..a977783b 100755 --- a/.chock/bin/devin.py +++ b/.chock/bin/devin.py @@ -29,6 +29,7 @@ from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings +import zlib as _zlib # ------------------------------------------------------------------------------ # contract (agentseam 0.3.5) @@ -235,11 +236,31 @@ def tool_input_of(raw): "effort", ) +_MCP_PREFIX = "mcp_" + +_CLAUDE_MCP_PREFIX = "mcp__" + def looks_like_claude_code(raw): """True when the payload carries a field only Claude Code has been seen to send.""" return isinstance(raw, dict) and any(marker in raw for marker in OBSERVED_MARKERS) -PROBES = {"looks_like_claude_code": looks_like_claude_code} +def names_a_single_underscore_mcp_tool(raw): + """True when tool_name is an MCP tool ID in a spelling Claude Code never sends.""" + name = raw.get("tool_name") if isinstance(raw, dict) else None + return isinstance(name, str) and name.startswith(_MCP_PREFIX) and not name.startswith(_CLAUDE_MCP_PREFIX) + +def event_keys_agree(raw): + """True when hookEventName names hook_event_name's event: the same text, or Grok Build's snake_case twin.""" + snake, pascal = (raw.get("hookEventName"), raw.get("hook_event_name")) if isinstance(raw, dict) else (None, None) + if not isinstance(pascal, str) or not isinstance(snake, str): + return False + return snake == pascal or (snake.islower() and snake.replace("_", "") == pascal.lower()) + +PROBES = { + "looks_like_claude_code": looks_like_claude_code, + "names_a_single_underscore_mcp_tool": names_a_single_underscore_mcp_tool, + "event_keys_agree": event_keys_agree, +} UNREADABLE_NAME = "" @@ -265,12 +286,22 @@ def _rejected_by_markers(c, raw): for marker in c.get("reject_markers", ()): if marker in raw: return True + for key, values in c.get("reject_values", {}).items(): + if key in raw and raw[key] in values: + return True for probe, markers in c.get("reject_markers_unless_probe", {}).items(): if any(marker in raw for marker in markers) and not PROBES[probe](raw): return True return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) +def _accepted_by_values(c, raw): + """True unless the entry names accepted values and the payload carries none of them.""" + values = c.get("accept_values") + return not values or any(key in raw and raw[key] in allowed for key, allowed in values.items()) + def _accepted_by_markers(c, raw, name): + if not _accepted_by_values(c, raw): + return False accept = c.get("accept_markers", ()) if not accept or any(marker in raw for marker in accept): return True @@ -309,12 +340,25 @@ def _segment(node, part): return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None return node.get(part) if isinstance(node, dict) else None +def _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + def _walk(node, path): - """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None if "[]." in path: head, sub = path.split("[].", 1) - items = _walk(node, head) - if not isinstance(items, (list, tuple)): + items = _list_at(node, head) + if items is None: return None joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) return joined or None @@ -344,7 +388,7 @@ def _tool_input_raw(cfg, raw): if not isinstance(raw, dict): return None for key in cfg["fields"].get("tool_input", ("tool_input",)): - value = raw.get(key) + value = _walk(raw, key) if value is not None: return value return None @@ -535,6 +579,8 @@ def hj_respond(cfg, decision, event, wire=None): return "", 0 if gate["grammar"] == "G2": return _g2(v, gate, decision, name) + if gate["grammar"] == "G7": + return _g7(v, decision, wire) return _g1(v, gate, decision, wire, name) _WINDOWS_KEYS = ("commandWindows", "windows") @@ -550,16 +596,25 @@ def _hook_dict(cfg, command): entry[key] = value return entry +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): rules = [] for ev in canonical_events: name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: - rule["matcher"] = matcher - rules.append(rule) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) return rules def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): @@ -588,6 +643,10 @@ def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): hooks.setdefault(extra, []).append({"command": command}) return {"hooks": hooks} +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] hooks = {} @@ -618,6 +677,8 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) if wrapper == "flat_entries": return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) return _default_wrapper(cfg, reverse, canonical_events, command, matcher) diff --git a/.chock/bin/gemini_cli.py b/.chock/bin/gemini_cli.py index 1afd4d41..98ecf634 100755 --- a/.chock/bin/gemini_cli.py +++ b/.chock/bin/gemini_cli.py @@ -29,6 +29,7 @@ from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings +import zlib as _zlib # ------------------------------------------------------------------------------ # contract (agentseam 0.3.5) @@ -235,11 +236,31 @@ def tool_input_of(raw): "effort", ) +_MCP_PREFIX = "mcp_" + +_CLAUDE_MCP_PREFIX = "mcp__" + def looks_like_claude_code(raw): """True when the payload carries a field only Claude Code has been seen to send.""" return isinstance(raw, dict) and any(marker in raw for marker in OBSERVED_MARKERS) -PROBES = {"looks_like_claude_code": looks_like_claude_code} +def names_a_single_underscore_mcp_tool(raw): + """True when tool_name is an MCP tool ID in a spelling Claude Code never sends.""" + name = raw.get("tool_name") if isinstance(raw, dict) else None + return isinstance(name, str) and name.startswith(_MCP_PREFIX) and not name.startswith(_CLAUDE_MCP_PREFIX) + +def event_keys_agree(raw): + """True when hookEventName names hook_event_name's event: the same text, or Grok Build's snake_case twin.""" + snake, pascal = (raw.get("hookEventName"), raw.get("hook_event_name")) if isinstance(raw, dict) else (None, None) + if not isinstance(pascal, str) or not isinstance(snake, str): + return False + return snake == pascal or (snake.islower() and snake.replace("_", "") == pascal.lower()) + +PROBES = { + "looks_like_claude_code": looks_like_claude_code, + "names_a_single_underscore_mcp_tool": names_a_single_underscore_mcp_tool, + "event_keys_agree": event_keys_agree, +} UNREADABLE_NAME = "" @@ -265,12 +286,22 @@ def _rejected_by_markers(c, raw): for marker in c.get("reject_markers", ()): if marker in raw: return True + for key, values in c.get("reject_values", {}).items(): + if key in raw and raw[key] in values: + return True for probe, markers in c.get("reject_markers_unless_probe", {}).items(): if any(marker in raw for marker in markers) and not PROBES[probe](raw): return True return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) +def _accepted_by_values(c, raw): + """True unless the entry names accepted values and the payload carries none of them.""" + values = c.get("accept_values") + return not values or any(key in raw and raw[key] in allowed for key, allowed in values.items()) + def _accepted_by_markers(c, raw, name): + if not _accepted_by_values(c, raw): + return False accept = c.get("accept_markers", ()) if not accept or any(marker in raw for marker in accept): return True @@ -309,12 +340,25 @@ def _segment(node, part): return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None return node.get(part) if isinstance(node, dict) else None +def _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + def _walk(node, path): - """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None if "[]." in path: head, sub = path.split("[].", 1) - items = _walk(node, head) - if not isinstance(items, (list, tuple)): + items = _list_at(node, head) + if items is None: return None joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) return joined or None @@ -344,7 +388,7 @@ def _tool_input_raw(cfg, raw): if not isinstance(raw, dict): return None for key in cfg["fields"].get("tool_input", ("tool_input",)): - value = raw.get(key) + value = _walk(raw, key) if value is not None: return value return None @@ -535,6 +579,8 @@ def hj_respond(cfg, decision, event, wire=None): return "", 0 if gate["grammar"] == "G2": return _g2(v, gate, decision, name) + if gate["grammar"] == "G7": + return _g7(v, decision, wire) return _g1(v, gate, decision, wire, name) _WINDOWS_KEYS = ("commandWindows", "windows") @@ -550,16 +596,25 @@ def _hook_dict(cfg, command): entry[key] = value return entry +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): rules = [] for ev in canonical_events: name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: - rule["matcher"] = matcher - rules.append(rule) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) return rules def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): @@ -588,6 +643,10 @@ def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): hooks.setdefault(extra, []).append({"command": command}) return {"hooks": hooks} +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] hooks = {} @@ -618,6 +677,8 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) if wrapper == "flat_entries": return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) return _default_wrapper(cfg, reverse, canonical_events, command, matcher) diff --git a/.chock/bin/grok.py b/.chock/bin/grok.py index 470de4f2..1e48cb6f 100755 --- a/.chock/bin/grok.py +++ b/.chock/bin/grok.py @@ -29,6 +29,7 @@ from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings +import zlib as _zlib # ------------------------------------------------------------------------------ # contract (agentseam 0.3.5) @@ -224,6 +225,43 @@ def tool_input_of(raw): # ------------------------------------------------------------------------------ # flat_decision family engine (trimmed to what this entry uses) +OBSERVED_MARKERS = ( + "transcript_path", + "permission_mode", + "stop_hook_active", + "agent_transcript_path", + "background_tasks", + "session_crons", + "custom_instructions", + "effort", +) + +_MCP_PREFIX = "mcp_" + +_CLAUDE_MCP_PREFIX = "mcp__" + +def looks_like_claude_code(raw): + """True when the payload carries a field only Claude Code has been seen to send.""" + return isinstance(raw, dict) and any(marker in raw for marker in OBSERVED_MARKERS) + +def names_a_single_underscore_mcp_tool(raw): + """True when tool_name is an MCP tool ID in a spelling Claude Code never sends.""" + name = raw.get("tool_name") if isinstance(raw, dict) else None + return isinstance(name, str) and name.startswith(_MCP_PREFIX) and not name.startswith(_CLAUDE_MCP_PREFIX) + +def event_keys_agree(raw): + """True when hookEventName names hook_event_name's event: the same text, or Grok Build's snake_case twin.""" + snake, pascal = (raw.get("hookEventName"), raw.get("hook_event_name")) if isinstance(raw, dict) else (None, None) + if not isinstance(pascal, str) or not isinstance(snake, str): + return False + return snake == pascal or (snake.islower() and snake.replace("_", "") == pascal.lower()) + +PROBES = { + "looks_like_claude_code": looks_like_claude_code, + "names_a_single_underscore_mcp_tool": names_a_single_underscore_mcp_tool, + "event_keys_agree": event_keys_agree, +} + UNREADABLE_NAME = "" def wire_name_of(raw, key): @@ -248,12 +286,22 @@ def _rejected_by_markers(c, raw): for marker in c.get("reject_markers", ()): if marker in raw: return True + for key, values in c.get("reject_values", {}).items(): + if key in raw and raw[key] in values: + return True for probe, markers in c.get("reject_markers_unless_probe", {}).items(): if any(marker in raw for marker in markers) and not PROBES[probe](raw): return True return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) +def _accepted_by_values(c, raw): + """True unless the entry names accepted values and the payload carries none of them.""" + values = c.get("accept_values") + return not values or any(key in raw and raw[key] in allowed for key, allowed in values.items()) + def _accepted_by_markers(c, raw, name): + if not _accepted_by_values(c, raw): + return False accept = c.get("accept_markers", ()) if not accept or any(marker in raw for marker in accept): return True @@ -292,12 +340,25 @@ def _segment(node, part): return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None return node.get(part) if isinstance(node, dict) else None +def _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + def _walk(node, path): - """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None if "[]." in path: head, sub = path.split("[].", 1) - items = _walk(node, head) - if not isinstance(items, (list, tuple)): + items = _list_at(node, head) + if items is None: return None joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) return joined or None @@ -327,7 +388,7 @@ def _tool_input_raw(cfg, raw): if not isinstance(raw, dict): return None for key in cfg["fields"].get("tool_input", ("tool_input",)): - value = raw.get(key) + value = _walk(raw, key) if value is not None: return value return None @@ -518,6 +579,8 @@ def hj_respond(cfg, decision, event, wire=None): return "", 0 if gate["grammar"] == "G2": return _g2(v, gate, decision, name) + if gate["grammar"] == "G7": + return _g7(v, decision, wire) return _g1(v, gate, decision, wire, name) _WINDOWS_KEYS = ("commandWindows", "windows") @@ -533,16 +596,25 @@ def _hook_dict(cfg, command): entry[key] = value return entry +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): rules = [] for ev in canonical_events: name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: - rule["matcher"] = matcher - rules.append(rule) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) return rules def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): @@ -571,6 +643,10 @@ def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): hooks.setdefault(extra, []).append({"command": command}) return {"hooks": hooks} +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] hooks = {} @@ -601,6 +677,8 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) if wrapper == "flat_entries": return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) return _default_wrapper(cfg, reverse, canonical_events, command, matcher) @@ -609,7 +687,7 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos AGENT = "grok" -VENDOR = {'agent': 'grok', 'claims': {'event_key': ('hookEventName',), 'mode': 'marker'}, 'config_format': 'json', 'config_path': '.grok/hooks/agentseam.json', 'display': 'Grok CLI', 'events': {'PermissionDenied': 'tool_failure', 'PostCompact': 'unknown', 'PostToolUse': 'post_tool', 'PostToolUseFailure': 'tool_failure', 'PreCompact': 'pre_compact', 'PreToolUse': 'pre_tool', 'SessionEnd': 'session_end', 'SessionStart': 'session_start', 'Stop': 'stop', 'StopFailure': 'stop', 'SubagentStart': 'subagent_start', 'SubagentStop': 'subagent_stop', 'UserPromptSubmit': 'prompt_submit'}, 'evidence': {'claims': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'flat_decision', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content', 'tool_input.new_string'), 'cwd': ('cwd', 'workspaceRoot'), 'output': ('toolOutput',), 'path': ('tool_input.file_path', 'tool_input.path'), 'prompt': ('prompt',), 'session_id': ('sessionId',), 'tool': ('toolName',), 'tool_input': ('toolInput',)}, 'hook_entry': {'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': True, 'tools': {}, 'verdicts': {'answer_events': ('PreToolUse',), 'bare_allow': 'silent', 'degrade_notes': {'escalate': 'Grok cannot prompt for confirmation', 'escalate_from_transform': 'Grok cannot modify a tool call', 'transform': 'Grok cannot modify a tool call'}, 'gates': {'PreToolUse': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'because', 'vocabulary': ('deny',), 'vocabulary_basis': 'verified', 'words': {'block': 'deny'}}, 'wire_events': {'pre_compact': 'PreCompact', 'stop': 'Stop', 'tool_failure': 'PostToolUseFailure'}} +VENDOR = {'agent': 'grok', 'claims': {'accept_markers': ('hookEventName',), 'event_key': ('hook_event_name', 'hookEventName'), 'mode': 'marker', 'notes': "Grok Build sends hookEventName with grok's snake_case value (pre_tool_use) beside a hook_event_name key carrying the PascalCase name (github.com/xai-org/grok-build user-guide/10-hooks.md, read 2026-10-11 against v1.0.50), so hook_event_name is read first, but only when event_keys_agree(raw) finds hookEventName naming the same event (its snake_case twin, or the same text); a payload whose two keys disagree is not grok's (Kimi Code sends both, with a camelCase hookEventName). The older camelCase-only shape falls back to hookEventName, which is required either way.", 'reject_markers_unless_probe': {'event_keys_agree': ('hook_event_name',)}}, 'config_format': 'json', 'config_path': '.grok/hooks/agentseam.json', 'display': 'Grok CLI', 'events': {'PermissionDenied': 'tool_failure', 'PostCompact': 'unknown', 'PostToolUse': 'post_tool', 'PostToolUseFailure': 'tool_failure', 'PreCompact': 'pre_compact', 'PreToolUse': 'pre_tool', 'SessionEnd': 'session_end', 'SessionStart': 'session_start', 'Stop': 'stop', 'StopFailure': 'stop', 'SubagentStart': 'subagent_start', 'SubagentStop': 'subagent_stop', 'UserPromptSubmit': 'prompt_submit'}, 'evidence': {'claims': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'flat_decision', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content', 'tool_input.new_string'), 'cwd': ('cwd', 'workspaceRoot'), 'output': ('toolOutput',), 'path': ('tool_input.file_path', 'tool_input.path'), 'prompt': ('prompt',), 'session_id': ('sessionId',), 'tool': ('toolName',), 'tool_input': ('toolInput',)}, 'hook_entry': {'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': True, 'tools': {}, 'verdicts': {'answer_events': ('PreToolUse',), 'bare_allow': 'silent', 'degrade_notes': {'escalate': 'Grok cannot prompt for confirmation', 'escalate_from_transform': 'Grok cannot modify a tool call', 'transform': 'Grok cannot modify a tool call'}, 'gates': {'PreToolUse': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'because', 'vocabulary': ('deny',), 'vocabulary_basis': 'verified', 'words': {'block': 'deny'}}, 'wire_events': {'pre_compact': 'PreCompact', 'stop': 'Stop', 'tool_failure': 'PostToolUseFailure'}} def claims(raw): @@ -2096,7 +2174,7 @@ def evaluate_tool_call(argv, event): ) #: A payload naming only this vendor's own wire event, so `respond` picks that event's grammar. -_CHOCK_WIRE_RAW = {PRE_TOOL: {'hookEventName': 'PreToolUse'}, STOP: {'hookEventName': 'Stop'}} +_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") @@ -2165,7 +2243,7 @@ def _chock_unreadable(): return (text, code) if _chock_refuses(text, code) else ("", 2) #: Per wire event mapped to pre_tool: whether the client shows a guard's ask as a prompt. _CHOCK_CAN_ASK = {'PreToolUse': False} -_CHOCK_EVENT_KEYS = ('hookEventName', 'hook_event_name', 'agent_action_name') +_CHOCK_EVENT_KEYS = ('hook_event_name', 'hookEventName', 'agent_action_name') def _chock_wire_name(raw): diff --git a/.chock/bin/tabnine.py b/.chock/bin/tabnine.py index 6d7f192c..e490c345 100755 --- a/.chock/bin/tabnine.py +++ b/.chock/bin/tabnine.py @@ -29,6 +29,7 @@ from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings +import zlib as _zlib # ------------------------------------------------------------------------------ # contract (agentseam 0.3.5) @@ -248,12 +249,22 @@ def _rejected_by_markers(c, raw): for marker in c.get("reject_markers", ()): if marker in raw: return True + for key, values in c.get("reject_values", {}).items(): + if key in raw and raw[key] in values: + return True for probe, markers in c.get("reject_markers_unless_probe", {}).items(): if any(marker in raw for marker in markers) and not PROBES[probe](raw): return True return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) +def _accepted_by_values(c, raw): + """True unless the entry names accepted values and the payload carries none of them.""" + values = c.get("accept_values") + return not values or any(key in raw and raw[key] in allowed for key, allowed in values.items()) + def _accepted_by_markers(c, raw, name): + if not _accepted_by_values(c, raw): + return False accept = c.get("accept_markers", ()) if not accept or any(marker in raw for marker in accept): return True @@ -292,12 +303,25 @@ def _segment(node, part): return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None return node.get(part) if isinstance(node, dict) else None +def _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + def _walk(node, path): - """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None if "[]." in path: head, sub = path.split("[].", 1) - items = _walk(node, head) - if not isinstance(items, (list, tuple)): + items = _list_at(node, head) + if items is None: return None joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) return joined or None @@ -327,7 +351,7 @@ def _tool_input_raw(cfg, raw): if not isinstance(raw, dict): return None for key in cfg["fields"].get("tool_input", ("tool_input",)): - value = raw.get(key) + value = _walk(raw, key) if value is not None: return value return None @@ -518,6 +542,8 @@ def hj_respond(cfg, decision, event, wire=None): return "", 0 if gate["grammar"] == "G2": return _g2(v, gate, decision, name) + if gate["grammar"] == "G7": + return _g7(v, decision, wire) return _g1(v, gate, decision, wire, name) _WINDOWS_KEYS = ("commandWindows", "windows") @@ -533,16 +559,25 @@ def _hook_dict(cfg, command): entry[key] = value return entry +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): rules = [] for ev in canonical_events: name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: - rule["matcher"] = matcher - rules.append(rule) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) return rules def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): @@ -571,6 +606,10 @@ def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): hooks.setdefault(extra, []).append({"command": command}) return {"hooks": hooks} +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] hooks = {} @@ -601,6 +640,8 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) if wrapper == "flat_entries": return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) return _default_wrapper(cfg, reverse, canonical_events, command, matcher) diff --git a/.chock/bin/vscode_copilot.py b/.chock/bin/vscode_copilot.py index c7c3a84a..82eaeddf 100755 --- a/.chock/bin/vscode_copilot.py +++ b/.chock/bin/vscode_copilot.py @@ -316,7 +316,7 @@ def _name(raw): def claims(raw): """True for a payload from either product.""" - if not isinstance(raw, dict): + if not isinstance(raw, dict) or _is_grok_build(raw): return False name = _name(raw) if name in EVENT_MAP and _VSCODE_ENVELOPE in raw and "turn_id" not in raw: @@ -334,6 +334,12 @@ def claims(raw): return raw.get("tool_name") in MEMORY_TOOLS and isinstance(ti, dict) and "command" in ti +def _is_grok_build(raw): + """Grok Build's envelope: hookEventName is hook_event_name's snake_case twin (pre_tool_use, PreToolUse).""" + snake, pascal = raw.get("hookEventName"), raw.get("hook_event_name") + return isinstance(pascal, str) and str(snake).islower() and str(snake).replace("_", "") == pascal.lower() + + 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") diff --git a/.chock/bin/windsurf.py b/.chock/bin/windsurf.py index 586e0904..4da24d18 100755 --- a/.chock/bin/windsurf.py +++ b/.chock/bin/windsurf.py @@ -29,6 +29,7 @@ from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings +import zlib as _zlib # ------------------------------------------------------------------------------ # contract (agentseam 0.3.5) @@ -253,12 +254,25 @@ def _segment(node, part): return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None return node.get(part) if isinstance(node, dict) else None +def _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + def _walk(node, path): - """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None if "[]." in path: head, sub = path.split("[].", 1) - items = _walk(node, head) - if not isinstance(items, (list, tuple)): + items = _list_at(node, head) + if items is None: return None joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) return joined or None @@ -288,7 +302,7 @@ def _tool_input_raw(cfg, raw): if not isinstance(raw, dict): return None for key in cfg["fields"].get("tool_input", ("tool_input",)): - value = raw.get(key) + value = _walk(raw, key) if value is not None: return value return None @@ -406,16 +420,25 @@ def _hook_dict(cfg, command): entry[key] = value return entry +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): rules = [] for ev in canonical_events: name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: - rule["matcher"] = matcher - rules.append(rule) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) return rules def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): @@ -444,6 +467,10 @@ def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): hooks.setdefault(extra, []).append({"command": command}) return {"hooks": hooks} +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] hooks = {} @@ -474,6 +501,8 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) if wrapper == "flat_entries": return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) return _default_wrapper(cfg, reverse, canonical_events, command, matcher) _MCP_EVENTS = ("pre_mcp_tool_use", "post_mcp_tool_use") diff --git a/.chock/compiled/block-destructive-commands/pre-tool-use/factory_droid-hooks.json b/.chock/compiled/block-destructive-commands/pre-tool-use/factory_droid-hooks.json new file mode 100644 index 00000000..b17c61d0 --- /dev/null +++ b/.chock/compiled/block-destructive-commands/pre-tool-use/factory_droid-hooks.json @@ -0,0 +1,14 @@ +{ + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/factory_droid.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive-commands.py\"", + "timeout": 45 + } + ], + "matcher": "Execute" + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/block-destructive-commands/pre-tool-use/goose-hooks.json b/.chock/compiled/block-destructive-commands/pre-tool-use/goose-hooks.json new file mode 100644 index 00000000..65ac5aa3 --- /dev/null +++ b/.chock/compiled/block-destructive-commands/pre-tool-use/goose-hooks.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/goose.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive-commands.py\"", + "on_failure": "block", + "timeout": 45 + } + ], + "matcher": "shell" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-destructive-commands/pre-tool-use/mistral_vibe-hooks.json b/.chock/compiled/block-destructive-commands/pre-tool-use/mistral_vibe-hooks.json new file mode 100644 index 00000000..272cf309 --- /dev/null +++ b/.chock/compiled/block-destructive-commands/pre-tool-use/mistral_vibe-hooks.json @@ -0,0 +1,9 @@ +[ + { + "type": "pre_tool", + "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/mistral_vibe.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive-commands.py\"", + "match": "bash|powershell|git_bash", + "name": "agentseam-pre_tool-de73a44f", + "timeout": 45 + } +] \ No newline at end of file diff --git a/.chock/compiled/block-destructive-commands/pre-tool-use/openhands-hooks.json b/.chock/compiled/block-destructive-commands/pre-tool-use/openhands-hooks.json new file mode 100644 index 00000000..ef6105d6 --- /dev/null +++ b/.chock/compiled/block-destructive-commands/pre-tool-use/openhands-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/openhands.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive-commands.py\"", + "timeout": 45 + } + ], + "matcher": "terminal" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-invisible-unicode/pre-tool-use/factory_droid-write-hooks.json b/.chock/compiled/block-invisible-unicode/pre-tool-use/factory_droid-write-hooks.json new file mode 100644 index 00000000..830b1b36 --- /dev/null +++ b/.chock/compiled/block-invisible-unicode/pre-tool-use/factory_droid-write-hooks.json @@ -0,0 +1,14 @@ +{ + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/factory_droid.py --gate \".chock/compiled/block-invisible-unicode/pre-tool-use/gate.json\"", + "timeout": 45 + } + ], + "matcher": "Create|Edit|MultiEdit|ApplyPatch" + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/block-invisible-unicode/pre-tool-use/goose-write-hooks.json b/.chock/compiled/block-invisible-unicode/pre-tool-use/goose-write-hooks.json new file mode 100644 index 00000000..0de9a259 --- /dev/null +++ b/.chock/compiled/block-invisible-unicode/pre-tool-use/goose-write-hooks.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/goose.py --gate \".chock/compiled/block-invisible-unicode/pre-tool-use/gate.json\"", + "on_failure": "block", + "timeout": 45 + } + ], + "matcher": "write|edit" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-invisible-unicode/pre-tool-use/mistral_vibe-write-hooks.json b/.chock/compiled/block-invisible-unicode/pre-tool-use/mistral_vibe-write-hooks.json new file mode 100644 index 00000000..a96be9f7 --- /dev/null +++ b/.chock/compiled/block-invisible-unicode/pre-tool-use/mistral_vibe-write-hooks.json @@ -0,0 +1,9 @@ +[ + { + "type": "pre_tool", + "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/mistral_vibe.py --gate \".chock/compiled/block-invisible-unicode/pre-tool-use/gate.json\"", + "match": "write_file|edit", + "name": "agentseam-pre_tool-d550af0e", + "timeout": 45 + } +] \ No newline at end of file diff --git a/.chock/compiled/block-invisible-unicode/pre-tool-use/openhands-write-hooks.json b/.chock/compiled/block-invisible-unicode/pre-tool-use/openhands-write-hooks.json new file mode 100644 index 00000000..dadf1032 --- /dev/null +++ b/.chock/compiled/block-invisible-unicode/pre-tool-use/openhands-write-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/openhands.py --gate \".chock/compiled/block-invisible-unicode/pre-tool-use/gate.json\"", + "timeout": 45 + } + ], + "matcher": "file_editor" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-invisible-unicode/stop/factory_droid-hooks.json b/.chock/compiled/block-invisible-unicode/stop/factory_droid-hooks.json new file mode 100644 index 00000000..3276445c --- /dev/null +++ b/.chock/compiled/block-invisible-unicode/stop/factory_droid-hooks.json @@ -0,0 +1,13 @@ +{ + "Stop": [ + { + "hooks": [ + { + "type": "command", + "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/factory_droid.py --gate \".chock/compiled/block-invisible-unicode/stop/gate.json\"", + "timeout": 45 + } + ] + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/block-invisible-unicode/stop/goose-hooks.json b/.chock/compiled/block-invisible-unicode/stop/goose-hooks.json new file mode 100644 index 00000000..b914fa22 --- /dev/null +++ b/.chock/compiled/block-invisible-unicode/stop/goose-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "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/goose.py --gate \".chock/compiled/block-invisible-unicode/stop/gate.json\"", + "on_failure": "block", + "timeout": 45 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-invisible-unicode/stop/mistral_vibe-hooks.json b/.chock/compiled/block-invisible-unicode/stop/mistral_vibe-hooks.json new file mode 100644 index 00000000..e8090569 --- /dev/null +++ b/.chock/compiled/block-invisible-unicode/stop/mistral_vibe-hooks.json @@ -0,0 +1,8 @@ +[ + { + "type": "post_agent", + "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/mistral_vibe.py --gate \".chock/compiled/block-invisible-unicode/stop/gate.json\"", + "name": "agentseam-post_agent-908f1ff7", + "timeout": 45 + } +] \ No newline at end of file diff --git a/.chock/compiled/block-invisible-unicode/stop/openhands-hooks.json b/.chock/compiled/block-invisible-unicode/stop/openhands-hooks.json new file mode 100644 index 00000000..f440aad2 --- /dev/null +++ b/.chock/compiled/block-invisible-unicode/stop/openhands-hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "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/openhands.py --gate \".chock/compiled/block-invisible-unicode/stop/gate.json\"", + "timeout": 45 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-no-verify/pre-tool-use/factory_droid-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/factory_droid-hooks.json new file mode 100644 index 00000000..b414f1ec --- /dev/null +++ b/.chock/compiled/block-no-verify/pre-tool-use/factory_droid-hooks.json @@ -0,0 +1,14 @@ +{ + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/factory_droid.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.py\"", + "timeout": 45 + } + ], + "matcher": "Execute" + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/block-no-verify/pre-tool-use/goose-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/goose-hooks.json new file mode 100644 index 00000000..54c2a0c2 --- /dev/null +++ b/.chock/compiled/block-no-verify/pre-tool-use/goose-hooks.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/goose.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.py\"", + "on_failure": "block", + "timeout": 45 + } + ], + "matcher": "shell" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-no-verify/pre-tool-use/mistral_vibe-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/mistral_vibe-hooks.json new file mode 100644 index 00000000..b04c9f6a --- /dev/null +++ b/.chock/compiled/block-no-verify/pre-tool-use/mistral_vibe-hooks.json @@ -0,0 +1,9 @@ +[ + { + "type": "pre_tool", + "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/mistral_vibe.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.py\"", + "match": "bash|powershell|git_bash", + "name": "agentseam-pre_tool-15b7ea20", + "timeout": 45 + } +] \ No newline at end of file diff --git a/.chock/compiled/block-no-verify/pre-tool-use/openhands-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/openhands-hooks.json new file mode 100644 index 00000000..c7b34eb5 --- /dev/null +++ b/.chock/compiled/block-no-verify/pre-tool-use/openhands-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/openhands.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.py\"", + "timeout": 45 + } + ], + "matcher": "terminal" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-wildcard-agent-permissions/pre-tool-use/factory_droid-write-hooks.json b/.chock/compiled/block-wildcard-agent-permissions/pre-tool-use/factory_droid-write-hooks.json new file mode 100644 index 00000000..3bb8e0c0 --- /dev/null +++ b/.chock/compiled/block-wildcard-agent-permissions/pre-tool-use/factory_droid-write-hooks.json @@ -0,0 +1,14 @@ +{ + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/factory_droid.py --gate \".chock/compiled/block-wildcard-agent-permissions/pre-tool-use/gate.json\"", + "timeout": 45 + } + ], + "matcher": "Create|Edit|MultiEdit|ApplyPatch" + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/block-wildcard-agent-permissions/pre-tool-use/goose-write-hooks.json b/.chock/compiled/block-wildcard-agent-permissions/pre-tool-use/goose-write-hooks.json new file mode 100644 index 00000000..0f7335e2 --- /dev/null +++ b/.chock/compiled/block-wildcard-agent-permissions/pre-tool-use/goose-write-hooks.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/goose.py --gate \".chock/compiled/block-wildcard-agent-permissions/pre-tool-use/gate.json\"", + "on_failure": "block", + "timeout": 45 + } + ], + "matcher": "write|edit" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-wildcard-agent-permissions/pre-tool-use/mistral_vibe-write-hooks.json b/.chock/compiled/block-wildcard-agent-permissions/pre-tool-use/mistral_vibe-write-hooks.json new file mode 100644 index 00000000..415e1032 --- /dev/null +++ b/.chock/compiled/block-wildcard-agent-permissions/pre-tool-use/mistral_vibe-write-hooks.json @@ -0,0 +1,9 @@ +[ + { + "type": "pre_tool", + "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/mistral_vibe.py --gate \".chock/compiled/block-wildcard-agent-permissions/pre-tool-use/gate.json\"", + "match": "write_file|edit", + "name": "agentseam-pre_tool-1be1eff7", + "timeout": 45 + } +] \ No newline at end of file diff --git a/.chock/compiled/block-wildcard-agent-permissions/pre-tool-use/openhands-write-hooks.json b/.chock/compiled/block-wildcard-agent-permissions/pre-tool-use/openhands-write-hooks.json new file mode 100644 index 00000000..f3a46425 --- /dev/null +++ b/.chock/compiled/block-wildcard-agent-permissions/pre-tool-use/openhands-write-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/openhands.py --gate \".chock/compiled/block-wildcard-agent-permissions/pre-tool-use/gate.json\"", + "timeout": 45 + } + ], + "matcher": "file_editor" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-wildcard-agent-permissions/stop/factory_droid-hooks.json b/.chock/compiled/block-wildcard-agent-permissions/stop/factory_droid-hooks.json new file mode 100644 index 00000000..5a9076e7 --- /dev/null +++ b/.chock/compiled/block-wildcard-agent-permissions/stop/factory_droid-hooks.json @@ -0,0 +1,13 @@ +{ + "Stop": [ + { + "hooks": [ + { + "type": "command", + "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/factory_droid.py --gate \".chock/compiled/block-wildcard-agent-permissions/stop/gate.json\"", + "timeout": 45 + } + ] + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/block-wildcard-agent-permissions/stop/goose-hooks.json b/.chock/compiled/block-wildcard-agent-permissions/stop/goose-hooks.json new file mode 100644 index 00000000..1c89c8a7 --- /dev/null +++ b/.chock/compiled/block-wildcard-agent-permissions/stop/goose-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "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/goose.py --gate \".chock/compiled/block-wildcard-agent-permissions/stop/gate.json\"", + "on_failure": "block", + "timeout": 45 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-wildcard-agent-permissions/stop/mistral_vibe-hooks.json b/.chock/compiled/block-wildcard-agent-permissions/stop/mistral_vibe-hooks.json new file mode 100644 index 00000000..27357a81 --- /dev/null +++ b/.chock/compiled/block-wildcard-agent-permissions/stop/mistral_vibe-hooks.json @@ -0,0 +1,8 @@ +[ + { + "type": "post_agent", + "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/mistral_vibe.py --gate \".chock/compiled/block-wildcard-agent-permissions/stop/gate.json\"", + "name": "agentseam-post_agent-5991567d", + "timeout": 45 + } +] \ No newline at end of file diff --git a/.chock/compiled/block-wildcard-agent-permissions/stop/openhands-hooks.json b/.chock/compiled/block-wildcard-agent-permissions/stop/openhands-hooks.json new file mode 100644 index 00000000..a806a126 --- /dev/null +++ b/.chock/compiled/block-wildcard-agent-permissions/stop/openhands-hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "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/openhands.py --gate \".chock/compiled/block-wildcard-agent-permissions/stop/gate.json\"", + "timeout": 45 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/pin-github-actions/pre-tool-use/factory_droid-write-hooks.json b/.chock/compiled/pin-github-actions/pre-tool-use/factory_droid-write-hooks.json new file mode 100644 index 00000000..e593d00d --- /dev/null +++ b/.chock/compiled/pin-github-actions/pre-tool-use/factory_droid-write-hooks.json @@ -0,0 +1,14 @@ +{ + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/factory_droid.py --gate \".chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", + "timeout": 45 + } + ], + "matcher": "Create|Edit|MultiEdit|ApplyPatch" + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/pin-github-actions/pre-tool-use/goose-write-hooks.json b/.chock/compiled/pin-github-actions/pre-tool-use/goose-write-hooks.json new file mode 100644 index 00000000..6cb3c5b1 --- /dev/null +++ b/.chock/compiled/pin-github-actions/pre-tool-use/goose-write-hooks.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/goose.py --gate \".chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", + "on_failure": "block", + "timeout": 45 + } + ], + "matcher": "write|edit" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/pin-github-actions/pre-tool-use/mistral_vibe-write-hooks.json b/.chock/compiled/pin-github-actions/pre-tool-use/mistral_vibe-write-hooks.json new file mode 100644 index 00000000..d7ccc723 --- /dev/null +++ b/.chock/compiled/pin-github-actions/pre-tool-use/mistral_vibe-write-hooks.json @@ -0,0 +1,9 @@ +[ + { + "type": "pre_tool", + "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/mistral_vibe.py --gate \".chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", + "match": "write_file|edit", + "name": "agentseam-pre_tool-2a087702", + "timeout": 45 + } +] \ No newline at end of file diff --git a/.chock/compiled/pin-github-actions/pre-tool-use/openhands-write-hooks.json b/.chock/compiled/pin-github-actions/pre-tool-use/openhands-write-hooks.json new file mode 100644 index 00000000..883a32d3 --- /dev/null +++ b/.chock/compiled/pin-github-actions/pre-tool-use/openhands-write-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/openhands.py --gate \".chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", + "timeout": 45 + } + ], + "matcher": "file_editor" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/pin-github-actions/stop/factory_droid-hooks.json b/.chock/compiled/pin-github-actions/stop/factory_droid-hooks.json new file mode 100644 index 00000000..0e15b8dc --- /dev/null +++ b/.chock/compiled/pin-github-actions/stop/factory_droid-hooks.json @@ -0,0 +1,13 @@ +{ + "Stop": [ + { + "hooks": [ + { + "type": "command", + "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/factory_droid.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", + "timeout": 45 + } + ] + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/pin-github-actions/stop/goose-hooks.json b/.chock/compiled/pin-github-actions/stop/goose-hooks.json new file mode 100644 index 00000000..5b022eef --- /dev/null +++ b/.chock/compiled/pin-github-actions/stop/goose-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "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/goose.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", + "on_failure": "block", + "timeout": 45 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/pin-github-actions/stop/mistral_vibe-hooks.json b/.chock/compiled/pin-github-actions/stop/mistral_vibe-hooks.json new file mode 100644 index 00000000..f707c1d9 --- /dev/null +++ b/.chock/compiled/pin-github-actions/stop/mistral_vibe-hooks.json @@ -0,0 +1,8 @@ +[ + { + "type": "post_agent", + "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/mistral_vibe.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", + "name": "agentseam-post_agent-b976d9db", + "timeout": 45 + } +] \ No newline at end of file diff --git a/.chock/compiled/pin-github-actions/stop/openhands-hooks.json b/.chock/compiled/pin-github-actions/stop/openhands-hooks.json new file mode 100644 index 00000000..9ce454f9 --- /dev/null +++ b/.chock/compiled/pin-github-actions/stop/openhands-hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "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/openhands.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", + "timeout": 45 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/factory_droid-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/factory_droid-hooks.json new file mode 100644 index 00000000..19a1a3ee --- /dev/null +++ b/.chock/compiled/protect-agent-config/pre-tool-use/factory_droid-hooks.json @@ -0,0 +1,14 @@ +{ + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/factory_droid.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.py\"", + "timeout": 45 + } + ], + "matcher": "Execute" + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/factory_droid-write-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/factory_droid-write-hooks.json new file mode 100644 index 00000000..edd584d1 --- /dev/null +++ b/.chock/compiled/protect-agent-config/pre-tool-use/factory_droid-write-hooks.json @@ -0,0 +1,14 @@ +{ + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/factory_droid.py --gate \".chock/compiled/protect-agent-config/pre-tool-use/gate.json\"", + "timeout": 45 + } + ], + "matcher": "Create|Edit|MultiEdit|ApplyPatch" + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/goose-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/goose-hooks.json new file mode 100644 index 00000000..d17afaac --- /dev/null +++ b/.chock/compiled/protect-agent-config/pre-tool-use/goose-hooks.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/goose.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.py\"", + "on_failure": "block", + "timeout": 45 + } + ], + "matcher": "shell" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/goose-write-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/goose-write-hooks.json new file mode 100644 index 00000000..46ae4353 --- /dev/null +++ b/.chock/compiled/protect-agent-config/pre-tool-use/goose-write-hooks.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/goose.py --gate \".chock/compiled/protect-agent-config/pre-tool-use/gate.json\"", + "on_failure": "block", + "timeout": 45 + } + ], + "matcher": "write|edit" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/mistral_vibe-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/mistral_vibe-hooks.json new file mode 100644 index 00000000..ef053a6d --- /dev/null +++ b/.chock/compiled/protect-agent-config/pre-tool-use/mistral_vibe-hooks.json @@ -0,0 +1,9 @@ +[ + { + "type": "pre_tool", + "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/mistral_vibe.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.py\"", + "match": "bash|powershell|git_bash", + "name": "agentseam-pre_tool-02fb3e0c", + "timeout": 45 + } +] \ No newline at end of file diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/mistral_vibe-write-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/mistral_vibe-write-hooks.json new file mode 100644 index 00000000..62461e0a --- /dev/null +++ b/.chock/compiled/protect-agent-config/pre-tool-use/mistral_vibe-write-hooks.json @@ -0,0 +1,9 @@ +[ + { + "type": "pre_tool", + "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/mistral_vibe.py --gate \".chock/compiled/protect-agent-config/pre-tool-use/gate.json\"", + "match": "write_file|edit", + "name": "agentseam-pre_tool-be959742", + "timeout": 45 + } +] \ No newline at end of file diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/openhands-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/openhands-hooks.json new file mode 100644 index 00000000..6b85a527 --- /dev/null +++ b/.chock/compiled/protect-agent-config/pre-tool-use/openhands-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/openhands.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.py\"", + "timeout": 45 + } + ], + "matcher": "terminal" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/openhands-write-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/openhands-write-hooks.json new file mode 100644 index 00000000..93608ceb --- /dev/null +++ b/.chock/compiled/protect-agent-config/pre-tool-use/openhands-write-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/openhands.py --gate \".chock/compiled/protect-agent-config/pre-tool-use/gate.json\"", + "timeout": 45 + } + ], + "matcher": "file_editor" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/protect-agent-config/stop/factory_droid-hooks.json b/.chock/compiled/protect-agent-config/stop/factory_droid-hooks.json new file mode 100644 index 00000000..70adcf87 --- /dev/null +++ b/.chock/compiled/protect-agent-config/stop/factory_droid-hooks.json @@ -0,0 +1,13 @@ +{ + "Stop": [ + { + "hooks": [ + { + "type": "command", + "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/factory_droid.py --gate \".chock/compiled/protect-agent-config/stop/gate.json\"", + "timeout": 45 + } + ] + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/protect-agent-config/stop/goose-hooks.json b/.chock/compiled/protect-agent-config/stop/goose-hooks.json new file mode 100644 index 00000000..2720bbcd --- /dev/null +++ b/.chock/compiled/protect-agent-config/stop/goose-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "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/goose.py --gate \".chock/compiled/protect-agent-config/stop/gate.json\"", + "on_failure": "block", + "timeout": 45 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/protect-agent-config/stop/mistral_vibe-hooks.json b/.chock/compiled/protect-agent-config/stop/mistral_vibe-hooks.json new file mode 100644 index 00000000..040a064b --- /dev/null +++ b/.chock/compiled/protect-agent-config/stop/mistral_vibe-hooks.json @@ -0,0 +1,8 @@ +[ + { + "type": "post_agent", + "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/mistral_vibe.py --gate \".chock/compiled/protect-agent-config/stop/gate.json\"", + "name": "agentseam-post_agent-8a1a761d", + "timeout": 45 + } +] \ No newline at end of file diff --git a/.chock/compiled/protect-agent-config/stop/openhands-hooks.json b/.chock/compiled/protect-agent-config/stop/openhands-hooks.json new file mode 100644 index 00000000..e982cd60 --- /dev/null +++ b/.chock/compiled/protect-agent-config/stop/openhands-hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "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/openhands.py --gate \".chock/compiled/protect-agent-config/stop/gate.json\"", + "timeout": 45 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/protect-commit-privacy/pre-tool-use/factory_droid-hooks.json b/.chock/compiled/protect-commit-privacy/pre-tool-use/factory_droid-hooks.json new file mode 100644 index 00000000..5d529717 --- /dev/null +++ b/.chock/compiled/protect-commit-privacy/pre-tool-use/factory_droid-hooks.json @@ -0,0 +1,14 @@ +{ + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/factory_droid.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.py\"", + "timeout": 45 + } + ], + "matcher": "Execute" + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/protect-commit-privacy/pre-tool-use/goose-hooks.json b/.chock/compiled/protect-commit-privacy/pre-tool-use/goose-hooks.json new file mode 100644 index 00000000..b00719c6 --- /dev/null +++ b/.chock/compiled/protect-commit-privacy/pre-tool-use/goose-hooks.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/goose.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.py\"", + "on_failure": "block", + "timeout": 45 + } + ], + "matcher": "shell" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/protect-commit-privacy/pre-tool-use/mistral_vibe-hooks.json b/.chock/compiled/protect-commit-privacy/pre-tool-use/mistral_vibe-hooks.json new file mode 100644 index 00000000..30616e14 --- /dev/null +++ b/.chock/compiled/protect-commit-privacy/pre-tool-use/mistral_vibe-hooks.json @@ -0,0 +1,9 @@ +[ + { + "type": "pre_tool", + "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/mistral_vibe.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.py\"", + "match": "bash|powershell|git_bash", + "name": "agentseam-pre_tool-5f6e48e6", + "timeout": 45 + } +] \ No newline at end of file diff --git a/.chock/compiled/protect-commit-privacy/pre-tool-use/openhands-hooks.json b/.chock/compiled/protect-commit-privacy/pre-tool-use/openhands-hooks.json new file mode 100644 index 00000000..a0ecd254 --- /dev/null +++ b/.chock/compiled/protect-commit-privacy/pre-tool-use/openhands-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/openhands.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.py\"", + "timeout": 45 + } + ], + "matcher": "terminal" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/scan-secrets/pre-tool-use/factory_droid-write-hooks.json b/.chock/compiled/scan-secrets/pre-tool-use/factory_droid-write-hooks.json new file mode 100644 index 00000000..8f15c8e5 --- /dev/null +++ b/.chock/compiled/scan-secrets/pre-tool-use/factory_droid-write-hooks.json @@ -0,0 +1,14 @@ +{ + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/factory_droid.py --gate \".chock/compiled/scan-secrets/pre-tool-use/gate.json\"", + "timeout": 45 + } + ], + "matcher": "Create|Edit|MultiEdit|ApplyPatch" + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/scan-secrets/pre-tool-use/goose-write-hooks.json b/.chock/compiled/scan-secrets/pre-tool-use/goose-write-hooks.json new file mode 100644 index 00000000..aa59e0bc --- /dev/null +++ b/.chock/compiled/scan-secrets/pre-tool-use/goose-write-hooks.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/goose.py --gate \".chock/compiled/scan-secrets/pre-tool-use/gate.json\"", + "on_failure": "block", + "timeout": 45 + } + ], + "matcher": "write|edit" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/scan-secrets/pre-tool-use/mistral_vibe-write-hooks.json b/.chock/compiled/scan-secrets/pre-tool-use/mistral_vibe-write-hooks.json new file mode 100644 index 00000000..623bc77a --- /dev/null +++ b/.chock/compiled/scan-secrets/pre-tool-use/mistral_vibe-write-hooks.json @@ -0,0 +1,9 @@ +[ + { + "type": "pre_tool", + "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/mistral_vibe.py --gate \".chock/compiled/scan-secrets/pre-tool-use/gate.json\"", + "match": "write_file|edit", + "name": "agentseam-pre_tool-29b54959", + "timeout": 45 + } +] \ No newline at end of file diff --git a/.chock/compiled/scan-secrets/pre-tool-use/openhands-write-hooks.json b/.chock/compiled/scan-secrets/pre-tool-use/openhands-write-hooks.json new file mode 100644 index 00000000..3d856f3c --- /dev/null +++ b/.chock/compiled/scan-secrets/pre-tool-use/openhands-write-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/openhands.py --gate \".chock/compiled/scan-secrets/pre-tool-use/gate.json\"", + "timeout": 45 + } + ], + "matcher": "file_editor" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/scan-secrets/stop/factory_droid-hooks.json b/.chock/compiled/scan-secrets/stop/factory_droid-hooks.json new file mode 100644 index 00000000..bf8d9420 --- /dev/null +++ b/.chock/compiled/scan-secrets/stop/factory_droid-hooks.json @@ -0,0 +1,13 @@ +{ + "Stop": [ + { + "hooks": [ + { + "type": "command", + "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/factory_droid.py --gate \".chock/compiled/scan-secrets/stop/gate.json\"", + "timeout": 45 + } + ] + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/scan-secrets/stop/goose-hooks.json b/.chock/compiled/scan-secrets/stop/goose-hooks.json new file mode 100644 index 00000000..50b5b410 --- /dev/null +++ b/.chock/compiled/scan-secrets/stop/goose-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "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/goose.py --gate \".chock/compiled/scan-secrets/stop/gate.json\"", + "on_failure": "block", + "timeout": 45 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/scan-secrets/stop/mistral_vibe-hooks.json b/.chock/compiled/scan-secrets/stop/mistral_vibe-hooks.json new file mode 100644 index 00000000..9818de43 --- /dev/null +++ b/.chock/compiled/scan-secrets/stop/mistral_vibe-hooks.json @@ -0,0 +1,8 @@ +[ + { + "type": "post_agent", + "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/mistral_vibe.py --gate \".chock/compiled/scan-secrets/stop/gate.json\"", + "name": "agentseam-post_agent-253321d2", + "timeout": 45 + } +] \ No newline at end of file diff --git a/.chock/compiled/scan-secrets/stop/openhands-hooks.json b/.chock/compiled/scan-secrets/stop/openhands-hooks.json new file mode 100644 index 00000000..f5e49dcb --- /dev/null +++ b/.chock/compiled/scan-secrets/stop/openhands-hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "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/openhands.py --gate \".chock/compiled/scan-secrets/stop/gate.json\"", + "timeout": 45 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/token-efficiency/pre-tool-use/factory_droid-tool-call-hooks.json b/.chock/compiled/token-efficiency/pre-tool-use/factory_droid-tool-call-hooks.json new file mode 100644 index 00000000..2674dc71 --- /dev/null +++ b/.chock/compiled/token-efficiency/pre-tool-use/factory_droid-tool-call-hooks.json @@ -0,0 +1,24 @@ +{ + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/factory_droid.py --tool-call \".chock/compiled/token-efficiency/pre-tool-use/tool-call-gate.json\"", + "timeout": 45 + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/factory_droid.py --record \".chock/compiled/token-efficiency/pre-tool-use/tool-call-gate.json\"", + "timeout": 45 + } + ] + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/token-efficiency/pre-tool-use/goose-tool-call-hooks.json b/.chock/compiled/token-efficiency/pre-tool-use/goose-tool-call-hooks.json new file mode 100644 index 00000000..0477af8e --- /dev/null +++ b/.chock/compiled/token-efficiency/pre-tool-use/goose-tool-call-hooks.json @@ -0,0 +1,28 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/goose.py --tool-call \".chock/compiled/token-efficiency/pre-tool-use/tool-call-gate.json\"", + "on_failure": "block", + "timeout": 45 + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/goose.py --record \".chock/compiled/token-efficiency/pre-tool-use/tool-call-gate.json\"", + "on_failure": "block", + "timeout": 45 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/token-efficiency/pre-tool-use/mistral_vibe-tool-call-hooks.json b/.chock/compiled/token-efficiency/pre-tool-use/mistral_vibe-tool-call-hooks.json new file mode 100644 index 00000000..7a661516 --- /dev/null +++ b/.chock/compiled/token-efficiency/pre-tool-use/mistral_vibe-tool-call-hooks.json @@ -0,0 +1,16 @@ +[ + { + "type": "pre_tool", + "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/mistral_vibe.py --tool-call \".chock/compiled/token-efficiency/pre-tool-use/tool-call-gate.json\"", + "match": "*", + "name": "agentseam-pre_tool-cfb34027", + "timeout": 45 + }, + { + "type": "post_tool", + "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/mistral_vibe.py --record \".chock/compiled/token-efficiency/pre-tool-use/tool-call-gate.json\"", + "match": "*", + "name": "agentseam-post_tool-0203fa14", + "timeout": 45 + } +] \ No newline at end of file diff --git a/.chock/compiled/token-efficiency/pre-tool-use/openhands-tool-call-hooks.json b/.chock/compiled/token-efficiency/pre-tool-use/openhands-tool-call-hooks.json new file mode 100644 index 00000000..d8797dad --- /dev/null +++ b/.chock/compiled/token-efficiency/pre-tool-use/openhands-tool-call-hooks.json @@ -0,0 +1,26 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/openhands.py --tool-call \".chock/compiled/token-efficiency/pre-tool-use/tool-call-gate.json\"", + "timeout": 45 + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/openhands.py --record \".chock/compiled/token-efficiency/pre-tool-use/tool-call-gate.json\"", + "timeout": 45 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.github/workflows/figures.yml b/.github/workflows/figures.yml index f3baeec9..c030cd2b 100644 --- a/.github/workflows/figures.yml +++ b/.github/workflows/figures.yml @@ -39,3 +39,14 @@ jobs: run: | cd docs/figures && for g in make_*.py; do python "$g"; done cd ../.. && git diff --exit-code -- docs/figures + # A PNG rasterizes differently off this runner (fonts), so it cannot be regenerated locally + # byte for byte. On drift, print each differing PNG so the exact runner bytes can be committed. + - name: Print drifted PNGs as base64 + if: failure() + run: | + git diff --name-only -- 'docs/figures/*.png' | while read -r png; do + echo "::group::$png" + base64 -w0 "$png" + echo + echo "::endgroup::" + done diff --git a/README.md b/README.md index cb417044..7e639876 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,11 @@ Full nine-surface matrix and per-agent caveats: | **Grok** | native pre-tool-use hook | `.grok/hooks/agentseam.json` | | **Tabnine** | native pre-tool-use hook | `.tabnine/agent/settings.json` | | **Antigravity CLI** | native pre-tool-use hook | `.agents/hooks.json` | +| **Factory Droid** | native pre-tool-use hook | `.factory/hooks.json` | +| **goose** | native pre-tool-use hook | `.agents/plugins/chock/hooks/hooks.json` | +| **OpenHands** | native pre-tool-use hook | `.openhands/hooks.json` | +| **Mistral Vibe** | native pre-tool-use hook | `.vibe/hooks.toml` | +| **Cline** | git hook + CI gate only — one hook file per event; chock's multi-policy dispatch for it is not built yet | — | | **Aider** | git hook + CI gate only — no native hook API yet | — | | **Junie** | git hook + CI gate only — home-anchored config, outside chock's repo-scoped install | `~/.junie/config.json` | | **Kimi Code** | git hook + CI gate only — home-anchored config, outside chock's repo-scoped install | `~/.kimi-code/config.toml` | diff --git a/changelog.d/copilot-write-witnessed.md b/changelog.d/copilot-write-witnessed.md new file mode 100644 index 00000000..b88dc04f --- /dev/null +++ b/changelog.d/copilot-write-witnessed.md @@ -0,0 +1,4 @@ +- **Copilot's label says the write is judged before it lands.** Copilot CLI 1.0.94 on Windows refused an + unpinned workflow `create` at `preToolUse`, and nothing landed (org-plan + `witness/results/witness-copilot-2026-10-11.md`). `chock install --client copilot` now reads "blocks on an + agent's file writes and at turn end", and a client note says VS Code agent mode's write path is unmeasured. diff --git a/changelog.d/cursor-witnessed.md b/changelog.d/cursor-witnessed.md new file mode 100644 index 00000000..941bcc7b --- /dev/null +++ b/changelog.d/cursor-witnessed.md @@ -0,0 +1,4 @@ +- **Cursor is witnessed: `chock install --selection --client cursor` no longer warns "Untested".** On Windows, + Cursor refused `block-no-verify` at `beforeShellExecution` and `pin-github-actions` at the `preToolUse` write + (org-plan `witness/results/witness-cursor-2026-10-11.md`). The `client` warning that chock has not witnessed + an install now names no client. Cursor's admin-setting warning stays. diff --git a/changelog.d/sync-every-agentseam-adapter.md b/changelog.d/sync-every-agentseam-adapter.md new file mode 100644 index 00000000..702d2fa4 --- /dev/null +++ b/changelog.d/sync-every-agentseam-adapter.md @@ -0,0 +1,10 @@ +- **`chock sync` wires Factory Droid, goose, OpenHands and Mistral Vibe.** The agent list comes from + agentseam's adapter registry: every adapter is a valid `supported_agents` name (`factory-droid`, `goose`, + `openhands`, `mistral-vibe`, `cline`), and every one whose hook config sits in the repository as JSON or TOML + gets its hook entries and a `.chock/bin/.py` runtime. Vibe's rules go in one owned block in + `.vibe/hooks.toml`; goose's go in `.agents/plugins/chock/hooks/hooks.json`. Each answers in its own + grammar; none can prompt, so an ask is a refusal there and `CHOCK_CAN_ASK` is 0. Cline (one executable per + event) and Junie and Kimi Code (home-directory config) are selectable but not yet wired. +- **Sync prints each wired agent's pre-tool row from agentseam's tier table**, with its basis and date, and + says "Untested" for an agent chock has no live witness of. +- **agentseam is pinned to main commit bebbfa6** as a direct reference until its next release. diff --git a/chock.lock b/chock.lock index 76260650..515d987c 100644 --- a/chock.lock +++ b/chock.lock @@ -16,7 +16,7 @@ "managed": false, "sha256": "ba1108ffdfce230bf66ee9d83a71c46cddb7d7c995759d4daee987439107afc6", "source": "local", - "artifacts_sha256": "4a6e2049781696c7111ac99333efc71b85308b71b329ecd05a2b2f8b5ffdcc9c" + "artifacts_sha256": "d8681dfc8b57f3c78799d509886b45bbfdd4477b170ff358e113ae8dacc1a310" }, { "id": "block-invisible-unicode", @@ -24,7 +24,7 @@ "managed": false, "sha256": "bc91b8805d38ab92649b5eb69cf7a40c311ad86263ba343935ba4a5a4c3a8d3e", "source": "local", - "artifacts_sha256": "9b92f04913f3aaa206f48ec3832fc5ba6669d797dfc8a8fb3c6a3d4c2bc27189" + "artifacts_sha256": "e0403ed9ac6246cd613740910d819e9fcfaa9185d74074919301557bb73efe2a" }, { "id": "block-no-verify", @@ -32,7 +32,7 @@ "managed": false, "sha256": "67c7a06a6676eb0ecc8f929dbdf8fc181ead721c8aa7b6e0f29e1757fdfbb3ff", "source": "local", - "artifacts_sha256": "37f633ac5859acf1394ef78203ac7ebc4efa64b3f8387b791ff0648894d1f1a7" + "artifacts_sha256": "435d6552977fbf856ac3ee29ea2902e4292072b6e35bf09d3635b4915dbc5cde" }, { "id": "block-wildcard-agent-permissions", @@ -40,7 +40,7 @@ "managed": false, "sha256": "123c2f3a5c286c2677f00e0ac93cef75c4604e84a1af2464fb89c91b0575aed4", "source": "local", - "artifacts_sha256": "31cb6ecf602628ee4419f4ee5ef6eb298a3bc070ba94935f823e705a452716d4" + "artifacts_sha256": "ce7a8b83daecf2c9479ac233068216be5d5ec086caf8f2c6f6a18dc619b8a830" }, { "id": "code-safety", @@ -96,7 +96,7 @@ "managed": false, "sha256": "53677765d84916220ac717424bbdf9dce4089257bfc873a3ed354cdb38f7865c", "source": "local", - "artifacts_sha256": "5a555c646bcb5ec19e9f607b2227c512fcd5a2f399915e2d6f72754fb5491e12" + "artifacts_sha256": "8b40e464285d8637314e1105012f7a969ddd96fef94338000281f588fbba30d2" }, { "id": "pre-generated-scripts", @@ -112,7 +112,7 @@ "managed": false, "sha256": "29c1a18fa1bd3ee2ab34a0ea1d1c07617a5841b8afbd65ad8704d31093c4fcc9", "source": "local", - "artifacts_sha256": "0f26eedc043ac84f1ce6ddee47d257b8f9c283d105d116d4532ee3cf8d0d0f2d" + "artifacts_sha256": "07a281e17de3e63a0eedd4fb32914f2c3c3cc7c59468213edba63e698d2d7fcc" }, { "id": "protect-commit-privacy", @@ -120,7 +120,7 @@ "managed": false, "sha256": "22045da796ba466d8043b7d4322b6dbfdcc46e3fd9709221c505acdf24d08119", "source": "local", - "artifacts_sha256": "bc816a321567a172180472a8e3d0b90461a22f5bde2a36892b257735ceaa6fa6" + "artifacts_sha256": "c752bb2b3f20c6d75c9b2c90bd176fbf2c7c54b7e480147d875442150c103294" }, { "id": "protect-main-branch", @@ -144,7 +144,7 @@ "managed": false, "sha256": "ac98dc12772df80b1133d262996d7e52b279281c1f1df2095693aceba58952ec", "source": "local", - "artifacts_sha256": "592c913e0528b81cd8796bf03e23c0712e963d2c834e582f435cf60b0df6bfb7" + "artifacts_sha256": "44a9631994c1beb8fe05e7de973de7a68312a9f6d63b9a0c5868515777c9dc70" }, { "id": "test-integrity", @@ -160,7 +160,7 @@ "managed": false, "sha256": "cea845541e17c74ce0ffe8fd269c1b00d649d96c61a0b504a185b7ed6e247b4e", "source": "local", - "artifacts_sha256": "600d16af2a46cfa721808ab8c16bf2b5965e4f69c04a671e2a13084a9cf7f1c5" + "artifacts_sha256": "dbc26807e0d6ab796e909fd55a874546e6777bbdfb288edc7c3e7e92bd7cad5b" }, { "id": "verify-dependency-exists", diff --git a/docs/assets/social-preview.png b/docs/assets/social-preview.png index 6e7c1cbb..d6336ce6 100644 Binary files a/docs/assets/social-preview.png and b/docs/assets/social-preview.png differ diff --git a/docs/assets/social-preview.svg b/docs/assets/social-preview.svg index 1cfff643..8906638b 100644 --- a/docs/assets/social-preview.svg +++ b/docs/assets/social-preview.svg @@ -1,5 +1,5 @@ + role="img" aria-label="chock — governance-as-code for AI coding agents: write a rule once and every agent obeys it. A policy compiles to whatever control each agent actually supports. 20 agents (claude, aider, antigravity, cline, codex, copilot, cursor, devin, factory-droid, gemini, goose, grok, junie, kimi-code, mistral-vibe, openhands, replit, tabnine, vscode, windsurf); 9 enforcement surfaces (ambient-rule, git-hook, ci-gate, pre-tool-use, stop, managed-setting, gateway, mcp-gateway, agent-hooks), of which 4 are installed by chock sync (ambient-rule, ci-gate, git-hook, stop); 10 everyday commands (init, add, install, remove, sync, check, status, enable, disable, bundle). Python 3.11–3.13, Apache-2.0, on PyPI as chock, version 0.15.2."> @@ -26,7 +26,7 @@ AGENTS - 15 + 20 claude @@ -35,22 +35,22 @@ antigravity - codex + cline - copilot + codex - cursor + copilot - devin + cursor - gemini + devin - grok + factory-droid - junie + gemini - kimi-code - + 4 more + goose + + 9 more SURFACES @@ -100,7 +100,7 @@ bundle - 15 + 20 AGENTS 9 SURFACES diff --git a/docs/enforcement-surfaces.md b/docs/enforcement-surfaces.md index 6d7a9f92..83cf4d26 100644 --- a/docs/enforcement-surfaces.md +++ b/docs/enforcement-surfaces.md @@ -166,21 +166,25 @@ Which surfaces each agent supports today (from `src/chock/compile/surfaces.py`): | Tabnine | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | | VS Code | ✅ | ✅ | ✅ | — | ✅ | — | ✅ | | Antigravity CLI | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | - -In-agent membership derives from agentseam's matrix: every adapted vendor whose row can block -a pre-tool call from a repo-level JSON hook config gets `pre-tool-use` (Copilot CLI and VS Code: -`agent-hooks`, chock's owned file). `stop` is derived the same way from the **turn-end** row, which -is a different question with a different answer: Grok and Windsurf can observe a finished turn -but not refuse one, so they are `detect` and get no column mark. Cursor cannot hold a turn either, -but its `stop` hook hands a refusal back to the agent as a `followup_message` (witnessed live on -3.21.18), which is `best-effort` and worth a mark: the agent is sent back to what the turn left -behind rather than the user being told after the fact. Copilot and VS Code refuse one too: their -`Stop` key in chock's own file was witnessed firing in VS Code agent mode (2026-09-28), and a block -answer made the agent carry on with the turn. It is registered under `Stop` alone, since `agentStop` fires -beside it and would run the gate twice; which of the top-level or nested block answers Copilot reads is -not isolated, so chock writes both. Junie and Kimi Code block only via home-level configs (Kimi -Code's in TOML), outside what `chock sync --repo` may write; Aider and Replit cannot block. A -checkmark is a wiring claim: new-vendor cells stay `witnessed: false` until a real client run is recorded. +| Factory Droid | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | +| goose | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | +| OpenHands | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | +| Mistral Vibe | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | +| Cline | ✅ | ✅ | ✅ | — | — | — | — | + +In-agent membership derives from agentseam's matrix: every adapted vendor whose row can block a pre-tool call +from a repo-level JSON or TOML hook config gets `pre-tool-use` (Copilot CLI and VS Code: `agent-hooks`, chock's +owned file). `stop` is derived the same way from the **turn-end** row, which is a different question with a +different answer: Grok and Windsurf can observe a finished turn but not refuse one, so they are `detect` and get +no column mark. Cursor cannot hold a turn either, but its `stop` hook hands a refusal back to the agent as a +`followup_message` (witnessed live on 3.21.18), which is `best-effort` and worth a mark: the agent is sent back +to what the turn left behind rather than the user being told after the fact. Copilot and VS Code refuse one too: +their `Stop` key in chock's own file was witnessed firing in VS Code agent mode (2026-09-28), and a block answer +made the agent carry on with the turn. It is registered under `Stop` alone, since `agentStop` fires beside it +and would run the gate twice; which of the top-level or nested block answers Copilot reads is not isolated, so +chock writes both. Junie and Kimi Code block only via home configs, outside what `chock sync --repo` may write; +Cline's per-event hooks directory is not wired yet; Aider and Replit cannot block. A checkmark is a wiring +claim: new-vendor cells stay `witnessed: false` until a real client run is recorded; sync calls them "Untested". ## Coverage levels @@ -190,22 +194,20 @@ evidence cap that bounds every grade -- is on its own page: **[Coverage Levels]( ## How a policy picks its surfaces -A hook that must stop a command targets `git-hook` + `ci-gate` (the universal floor) and, where -available, `pre-tool-use` + `managed-setting`. A hook whose `on:` includes `tool_use` is compiled to -the `pre-tool-use` surface on agents that support it (nine of them, Claude Code and Cursor -among them; Copilot -CLI and VS Code get the same guard via `agent-hooks`, and a content gate's write check as a `PreToolUse` entry over `Edit|Write` there). A hook whose `on:` includes -`tool_call` (a `content_regex` or `script` gate with `params.tools`, globs over tool names such as -`mcp__Firecrawl__*` or `WebFetch`) gates PreToolUse for any tool by name, on the five agents whose -vendor facts record a tool vocabulary (Claude Code, Codex CLI, Cursor, Gemini CLI, and Copilot -via `agent-hooks`); the others get no `tool_call` entry and are credited nothing for it. A gate's -`outside_repo` globs let its PreToolUse write check see declared files outside the repository (the -turn-end check cannot). A `script` gate on `tool_call` also keeps a local per-session tool-call log -under `.chock/state/` that its script can read; see -[`spec/session-log.md`](../spec/session-log.md). A best-practice rule with -no deterministic check compiles only to `ambient-rule`. The compiler always pairs a control with the -**strongest available backstop** — e.g. a git hook plus a CI gate, because a git hook alone can be -skipped with `--no-verify`. +A hook that must stop a command targets `git-hook` + `ci-gate` (the universal floor) and, where available, +`pre-tool-use` + `managed-setting`. A hook whose `on:` includes `tool_use` is compiled to the `pre-tool-use` +surface on agents that support it (thirteen of them, Claude Code and Cursor among them; Copilot CLI and VS Code +get the same guard via `agent-hooks`, and a content gate's write check as a `PreToolUse` entry over `Edit|Write` +there). A hook whose `on:` includes `tool_call` (a `content_regex` or `script` gate with `params.tools`, globs +over tool names such as `mcp__Firecrawl__*` or `WebFetch`) gates PreToolUse for any tool by name, on the nine +agents whose vendor facts record a tool vocabulary (Claude Code, Codex CLI, Cursor, Gemini CLI, Factory Droid, +goose, Mistral Vibe, OpenHands, and Copilot via `agent-hooks`); the others get no `tool_call` entry and are +credited nothing for it. A gate's `outside_repo` globs let its PreToolUse write check see declared files outside +the repository (the turn-end check cannot). A `script` gate on `tool_call` also keeps a local per-session +tool-call log under `.chock/state/` that its script can read; see +[`spec/session-log.md`](../spec/session-log.md). A best-practice rule with no deterministic check compiles only +to `ambient-rule`. The compiler always pairs a control with the **strongest available backstop** — e.g. a git +hook plus a CI gate, because a git hook alone can be skipped with `--no-verify`. ## What happens when the guard cannot decide diff --git a/docs/figures/social-card.png b/docs/figures/social-card.png index 3980c634..f7e46004 100644 Binary files a/docs/figures/social-card.png and b/docs/figures/social-card.png differ diff --git a/docs/figures/social-card.svg b/docs/figures/social-card.svg index 98828efa..81e5229f 100644 --- a/docs/figures/social-card.svg +++ b/docs/figures/social-card.svg @@ -1,9 +1,9 @@ chock - chock: one policy, compiled to every supported coding agent. 15 agents supported. + chock: one policy, compiled to every supported coding agent. 20 agents supported. chock one policy, compiled to every coding agent - 15 + 20 agents supported diff --git a/docs/figures/surfaces-dark.svg b/docs/figures/surfaces-dark.svg index 48836e01..0cc3fdf4 100644 --- a/docs/figures/surfaces-dark.svg +++ b/docs/figures/surfaces-dark.svg @@ -1,6 +1,6 @@ One policy, 9 surfaces - A fan-out diagram: one chock policy compiles into 9 enforcement surfaces. All 15 supported agent names get the advisory ambient rule and the two commit-time gates, git hook and CI gate. 9 also get a native pre-tool-use hook, enforced live in the agent, and 2 (vscode, copilot -- one underlying vendor) get chock's own agent-hooks file, also enforced in-agent. 9 get an end-of-turn hook that reads what the turn wrote: a backstop for what a pre-tool hook cannot see, carrying no coverage grade of its own. Three surfaces are named for honesty though no agent reaches them yet: managed-setting is compiled for Claude but not installed, gateway is modelled but not emitted, and mcp-gateway emits but is not yet credited to any agent. + A fan-out diagram: one chock policy compiles into 9 enforcement surfaces. All 20 supported agent names get the advisory ambient rule and the two commit-time gates, git hook and CI gate. 13 also get a native pre-tool-use hook, enforced live in the agent, and 2 (vscode, copilot -- one underlying vendor) get chock's own agent-hooks file, also enforced in-agent. 13 get an end-of-turn hook that reads what the turn wrote: a backstop for what a pre-tool hook cannot see, carrying no coverage grade of its own. Three surfaces are named for honesty though no agent reaches them yet: managed-setting is compiled for Claude but not installed, gateway is modelled but not emitted, and mcp-gateway emits but is not yet credited to any agent. one policy @@ -10,21 +10,21 @@ ambient-rule - 15 agents + 20 agents advisory only git-hook - 15 agents + 20 agents enforced at commit ci-gate - 15 agents + 20 agents enforced at commit @@ -33,14 +33,14 @@ pre-tool-use - 9 agents + 13 agents enforced in-agent stop - 9 agents + 13 agents backstop, no grade @@ -83,5 +83,5 @@ enforced in-agent absent (reaches no agent yet) - 15 chock agent names map to 14 agentseam vendors (vscode and copilot share vscode_copilot). + 20 chock agent names map to 19 agentseam vendors (vscode and copilot share vscode_copilot). diff --git a/docs/figures/surfaces-light.svg b/docs/figures/surfaces-light.svg index ad367c9d..d9498565 100644 --- a/docs/figures/surfaces-light.svg +++ b/docs/figures/surfaces-light.svg @@ -1,6 +1,6 @@ One policy, 9 surfaces - A fan-out diagram: one chock policy compiles into 9 enforcement surfaces. All 15 supported agent names get the advisory ambient rule and the two commit-time gates, git hook and CI gate. 9 also get a native pre-tool-use hook, enforced live in the agent, and 2 (vscode, copilot -- one underlying vendor) get chock's own agent-hooks file, also enforced in-agent. 9 get an end-of-turn hook that reads what the turn wrote: a backstop for what a pre-tool hook cannot see, carrying no coverage grade of its own. Three surfaces are named for honesty though no agent reaches them yet: managed-setting is compiled for Claude but not installed, gateway is modelled but not emitted, and mcp-gateway emits but is not yet credited to any agent. + A fan-out diagram: one chock policy compiles into 9 enforcement surfaces. All 20 supported agent names get the advisory ambient rule and the two commit-time gates, git hook and CI gate. 13 also get a native pre-tool-use hook, enforced live in the agent, and 2 (vscode, copilot -- one underlying vendor) get chock's own agent-hooks file, also enforced in-agent. 13 get an end-of-turn hook that reads what the turn wrote: a backstop for what a pre-tool hook cannot see, carrying no coverage grade of its own. Three surfaces are named for honesty though no agent reaches them yet: managed-setting is compiled for Claude but not installed, gateway is modelled but not emitted, and mcp-gateway emits but is not yet credited to any agent. one policy @@ -10,21 +10,21 @@ ambient-rule - 15 agents + 20 agents advisory only git-hook - 15 agents + 20 agents enforced at commit ci-gate - 15 agents + 20 agents enforced at commit @@ -33,14 +33,14 @@ pre-tool-use - 9 agents + 13 agents enforced in-agent stop - 9 agents + 13 agents backstop, no grade @@ -83,5 +83,5 @@ enforced in-agent absent (reaches no agent yet) - 15 chock agent names map to 14 agentseam vendors (vscode and copilot share vscode_copilot). + 20 chock agent names map to 19 agentseam vendors (vscode and copilot share vscode_copilot). diff --git a/pyproject.toml b/pyproject.toml index 4f68c723..e0f3750f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,9 +58,10 @@ dependencies = [ # Build-time only: chock's plugin packaging consumes agentseam's per-agent layout # data (packaging.plan/supports/plugin_root/executable_ref) to render plugin bundles. # It never ships to an adopter repo -- the vendored PreToolUse/SessionStart runners - # stay self-contained stdlib-only files, unaffected by this dependency. Pinned exact - # per plan/spine-a/contract.md: the wave boundary is the published PyPI artifact. - "agentseam==0.3.5", + # stay self-contained stdlib-only files, unaffected by this dependency. Pre-1.0 it is + # pinned to an agentseam main commit (its newest adapters are unreleased); release week + # swaps this for the released version, since PyPI refuses a direct reference. + "agentseam @ git+https://github.com/open-coder-ai/agentseam@bebbfa6f85cc6512238ecad5114c00d99952f664", ] [project.optional-dependencies] diff --git a/requirements/brand-assets.in b/requirements/brand-assets.in index a2cff98f..4e376693 100644 --- a/requirements/brand-assets.in +++ b/requirements/brand-assets.in @@ -1,5 +1,5 @@ pyyaml>=6.0 jsonschema>=4.18,<5 referencing>=0.35,<0.38 -agentseam==0.3.5 +agentseam @ https://github.com/open-coder-ai/agentseam/archive/bebbfa6f85cc6512238ecad5114c00d99952f664.tar.gz cairosvg==2.9.0 diff --git a/requirements/brand-assets.txt b/requirements/brand-assets.txt index bee68487..c5b6b7e2 100644 --- a/requirements/brand-assets.txt +++ b/requirements/brand-assets.txt @@ -4,9 +4,8 @@ # # pip-compile --generate-hashes --no-index --output-file=requirements/brand-assets.txt --strip-extras requirements/brand-assets.in # -agentseam==0.3.5 \ - --hash=sha256:429bc5d55930fdad9a6c102a19f516cab9e84a1ed43fdecb1ac02b52060ac51d \ - --hash=sha256:7a34880b81fc2b6f125874f7b05da35a754d03ed8ecc35947df7c22108d9f87e +agentseam @ https://github.com/open-coder-ai/agentseam/archive/bebbfa6f85cc6512238ecad5114c00d99952f664.tar.gz \ + --hash=sha256:76fd44a41a826da73f6838fb9eb1fdc4e356c69973ad9fc0cff02b5a82abf196 # via -r requirements/brand-assets.in attrs==26.1.0 \ --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ diff --git a/src/chock/compile/emitters/in_agent_hooks.py b/src/chock/compile/emitters/in_agent_hooks.py index c9c3bc0a..ec4d14a8 100644 --- a/src/chock/compile/emitters/in_agent_hooks.py +++ b/src/chock/compile/emitters/in_agent_hooks.py @@ -14,6 +14,9 @@ #: A client hook timeout fails OPEN, so it must fire after the engine's own (ask-a-person) timers. TIMEOUT_SECONDS = ENGINE_BUDGET_SECONDS + STARTUP_MARGIN_SECONDS +#: A vendor's hook-config document: a JSON object, or a flat list of rules (mistral_vibe's TOML `[[hooks]]`). +HookDoc = dict[str, Any] | list[dict[str, Any]] + _MS_PER_SECOND = 1000 #: The hooks-schema timeout field and unit of each generic vendor, with the doc it was read from. VENDOR_TIMEOUTS: dict[str, dict[str, Any]] = json.loads( @@ -63,7 +66,7 @@ def vendor_timeout(vendor: str) -> tuple[str, int] | None: return facts["field"], TIMEOUT_SECONDS * (_MS_PER_SECOND if facts["unit"] == "ms" else 1) -def with_timeout(vendor: str, doc: dict[str, Any]) -> dict[str, Any]: +def with_timeout(vendor: str, doc: HookDoc) -> HookDoc: """`doc` (agentseam's rendering) with `vendor`'s timeout on every hook entry; unchanged where it has no key.""" timeout = vendor_timeout(vendor) if timeout is not None: @@ -84,7 +87,7 @@ def _stamp(node: Any, field: str, value: int) -> None: _stamp(child, field, value) -def generic_hooks_file(vendor: str, command: str) -> dict[str, Any]: +def generic_hooks_file(vendor: str, command: str) -> HookDoc: """`vendor`'s full hook-config document for one guard command, agentseam's rendering plus its timeout. Paths inside `command` are repo-relative and resolve wherever the session started: the @@ -93,17 +96,17 @@ def generic_hooks_file(vendor: str, command: str) -> dict[str, Any]: return generic_pre_tool_config(vendor, command, vendors.shell_matcher(vendor)) -def generic_pre_tool_config(vendor: str, command: str, matcher: str | None) -> dict[str, Any]: +def generic_pre_tool_config(vendor: str, command: str, matcher: str | None) -> HookDoc: """The vendor's pre-tool hook-config document running `command`, with its timeout.""" return with_timeout(vendor, vendors.pre_tool_hook_config(vendor, command, matcher=matcher)) -def generic_stop_config(vendor: str, command: str) -> dict[str, Any]: +def generic_stop_config(vendor: str, command: str) -> HookDoc: """The vendor's turn-end hook-config document running `command`, with its timeout.""" return with_timeout(vendor, vendors.stop_hook_config(vendor, command)) -def generic_post_tool_config(vendor: str, command: str) -> dict[str, Any]: +def generic_post_tool_config(vendor: str, command: str) -> HookDoc: """The vendor's after-tool hook-config document running `command`, with its timeout.""" return with_timeout(vendor, vendors.post_tool_hook_config(vendor, command)) diff --git a/src/chock/compile/emitters/in_agent_tool_call.py b/src/chock/compile/emitters/in_agent_tool_call.py index c87639ec..0e47fdbb 100644 --- a/src/chock/compile/emitters/in_agent_tool_call.py +++ b/src/chock/compile/emitters/in_agent_tool_call.py @@ -111,7 +111,13 @@ def _generic(vendor: str, output_dir: Path, policy_id: str, spec: dict[str, Any] pre, record = _commands(vendor, policy_id, PRE_SURFACE) doc = generic_pre_tool_config(vendor, pre, _matcher(spec)) if records_session(spec): - doc["hooks"] = {**doc["hooks"], **generic_post_tool_config(vendor, record)["hooks"]} + post = generic_post_tool_config(vendor, record) + if isinstance(doc, list): # mistral_vibe: a flat list of rules + doc = [*doc, *post] + elif vendors.hook_entry_bare(vendor): # factory_droid: the document is the event map itself + doc = {**doc, **post} + else: + doc["hooks"] = {**doc["hooks"], **post["hooks"]} _write(output_dir, f"{vendor}{GENERIC_SUFFIX}", doc, written) diff --git a/src/chock/data/claims.json b/src/chock/data/claims.json index 91a95532..1e8c2247 100644 --- a/src/chock/data/claims.json +++ b/src/chock/data/claims.json @@ -78,5 +78,37 @@ "honours": false, "evidence": "tested", "test": "tests/test_guard_fail_to_ask.py::test_a_crashed_guard_asks_rather_than_allowing" + }, + { + "agent": "factory_droid", + "claim": "honours_ask", + "verdict": "deny", + "honours": false, + "evidence": "tested", + "test": "tests/test_guard_fail_to_ask.py::test_a_crashed_guard_asks_rather_than_allowing" + }, + { + "agent": "goose", + "claim": "honours_ask", + "verdict": "block", + "honours": false, + "evidence": "tested", + "test": "tests/test_guard_fail_to_ask.py::test_a_crashed_guard_asks_rather_than_allowing" + }, + { + "agent": "openhands", + "claim": "honours_ask", + "verdict": "deny", + "honours": false, + "evidence": "tested", + "test": "tests/test_guard_fail_to_ask.py::test_a_crashed_guard_asks_rather_than_allowing" + }, + { + "agent": "mistral_vibe", + "claim": "honours_ask", + "verdict": "deny", + "honours": false, + "evidence": "tested", + "test": "tests/test_guard_fail_to_ask.py::test_a_crashed_guard_asks_rather_than_allowing" } ] diff --git a/src/chock/data/hook_timeouts.json b/src/chock/data/hook_timeouts.json index a99caf9b..9c93074b 100644 --- a/src/chock/data/hook_timeouts.json +++ b/src/chock/data/hook_timeouts.json @@ -17,18 +17,42 @@ "default_seconds": 600, "source": "https://docs.devin.ai/cli/extensibility/hooks/overview" }, + "factory_droid": { + "field": "timeout", + "unit": "s", + "default_seconds": 60, + "source": "https://docs.factory.ai/reference/hooks-reference" + }, "gemini_cli": { "field": "timeout", "unit": "ms", "default_seconds": 60, "source": "https://github.com/google-gemini/gemini-cli/blob/main/docs/hooks/reference.md" }, + "goose": { + "field": "timeout", + "unit": "s", + "default_seconds": 30, + "source": "https://goose-docs.ai/docs/guides/context-engineering/hooks/" + }, "grok": { "field": "timeout", "unit": "s", "default_seconds": 5, "source": "https://docs.x.ai/build/features/hooks" }, + "mistral_vibe": { + "field": "timeout", + "unit": "s", + "default_seconds": 60, + "source": "https://docs.mistral.ai/vibe/code/cli/hooks" + }, + "openhands": { + "field": "timeout", + "unit": "s", + "default_seconds": 60, + "source": "https://docs.openhands.dev/openhands/usage/customization/hooks" + }, "tabnine": { "field": "timeout", "unit": "ms", diff --git a/src/chock/hooks/in_agent_generic.py b/src/chock/hooks/in_agent_generic.py index 98122eb8..5eb10d39 100644 --- a/src/chock/hooks/in_agent_generic.py +++ b/src/chock/hooks/in_agent_generic.py @@ -8,8 +8,11 @@ from chock import vendors from chock.emit import write_generated_json +from chock.hooks.in_agent_toml import install_toml, installed_toml_ids from chock.hooks.runtime_vendor import runtime_rel, vendor_runtime +_TOML = "toml" + def load_config(path: Path) -> dict: """The vendor's config file as a dict; a file that is not readable JSON is refused.""" @@ -81,21 +84,31 @@ def _merge(settings: dict, fragment: dict) -> None: FRAGMENT_SURFACES = ("pre-tool-use", "stop") -def _fragments(repo_root: Path, vendor: str) -> list[tuple[str, dict]]: +def _fragments(repo_root: Path, vendor: str) -> list[tuple[str, Any]]: compiled = repo_root / ".chock" / "compiled" globs = [f"*/{surface}/{vendor}-hooks.json" for surface in FRAGMENT_SURFACES] globs += [f"*/pre-tool-use/{vendor}-write-hooks.json", f"*/pre-tool-use/{vendor}-tool-call-hooks.json"] - found: list[tuple[str, dict]] = [] + found: list[tuple[str, Any]] = [] for path in sorted(path for glob in globs for path in compiled.glob(glob)): try: fragment = json.loads(path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): continue - if isinstance(fragment, dict): + if isinstance(fragment, dict) or (isinstance(fragment, list) and vendors.config_format(vendor) == _TOML): found.append((path.parent.parent.name, fragment)) return found +def _sync_runtime(repo_root: Path, vendor: str, *, wanted: bool) -> None: + """Vendor `vendor`'s runtime while a hook entry runs it; otherwise drop it.""" + if wanted: + vendor_runtime(repo_root, vendor) + return + vendored = repo_root / runtime_rel(vendor) + if vendored.exists(): + vendored.unlink() + + def install_generic(repo_root: Path, vendor: str, *, uninstall: bool = False) -> list[str]: """Merge `vendor`'s compiled fragments into its recorded config file, keeping entries not ours. @@ -106,6 +119,10 @@ def install_generic(repo_root: Path, vendor: str, *, uninstall: bool = False) -> repo_root = Path(repo_root) marker = _marker(vendor) fragments = [] if uninstall else _fragments(repo_root, vendor) + if vendors.config_format(vendor) == _TOML: + installed = install_toml(repo_root, vendor, fragments) + _sync_runtime(repo_root, vendor, wanted=bool(installed)) + return installed config_path = repo_root / vendors.config_path(vendor) settings = load_config(config_path) prior: dict[str, dict] = {} @@ -113,9 +130,7 @@ def install_generic(repo_root: Path, vendor: str, *, uninstall: bool = False) -> _strip_ours(settings, marker) if not fragments: - vendored = repo_root / runtime_rel(vendor) - if vendored.exists(): - vendored.unlink() + _sync_runtime(repo_root, vendor, wanted=False) if config_path.exists() and prior: if settings: write_generated_json(config_path, settings) @@ -134,6 +149,8 @@ def install_generic(repo_root: Path, vendor: str, *, uninstall: bool = False) -> def installed_generic_ids(repo_root: Path, vendor: str) -> set[str]: """Policy ids whose fragment entries are all present in `vendor`'s config file.""" repo_root = Path(repo_root) + if vendors.config_format(vendor) == _TOML: + return installed_toml_ids(repo_root, vendor, _fragments(repo_root, vendor)) marker = _marker(vendor) config_path = repo_root / vendors.config_path(vendor) if not config_path.exists(): diff --git a/src/chock/hooks/in_agent_toml.py b/src/chock/hooks/in_agent_toml.py new file mode 100644 index 00000000..4e316eed --- /dev/null +++ b/src/chock/hooks/in_agent_toml.py @@ -0,0 +1,70 @@ +"""TOML in-agent install (mistral_vibe): chock's rules as one owned block in the vendor's own file.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from agentseam import adapters +from agentseam.install_config import ConfigUnreadableError, block_bounds, read_text, remove_block, write_block + +from chock import vendors + +#: The block owner: `# >>> agentseam >>> chock` ... `# <<< agentseam <<< chock`, beside any other owner's. +OWNER = "chock" + + +def _read(path: Path) -> str: + """The file's text; one that is not readable text is refused, never overwritten.""" + try: + return str(read_text(str(path))) + except (OSError, ConfigUnreadableError): + msg = f"{path} is not readable text; leaving it untouched" + raise ValueError(msg) from None + + +def _rules(fragment: Any) -> list[dict[str, Any]]: + return [rule for rule in fragment if isinstance(rule, dict)] if isinstance(fragment, list) else [] + + +def _render(vendor: str, rules: list[dict[str, Any]]) -> str: + return str(adapters.get(vendor).render_config(rules)) + + +def _owned_block(path: Path) -> str: + """Our block's text in `path`, or "" where the file or the block is absent.""" + if not path.is_file(): + return "" + text = _read(path) + bounds = block_bounds(text, OWNER) + return text[bounds[0] : bounds[1]] if bounds else "" + + +def install_toml(repo_root: Path, vendor: str, fragments: list[tuple[str, Any]]) -> list[str]: + """Write every fragment's rules as our one block; with none, remove the block (and a file left empty).""" + path = Path(repo_root) / vendors.config_path(vendor) + rules = [rule for _policy_id, fragment in fragments for rule in _rules(fragment)] + if not rules: + if _owned_block(path): + remove_block(str(path), OWNER) + if not _read(path).strip(): + path.unlink() + return [] + if path.exists(): + _read(path) + path.parent.mkdir(parents=True, exist_ok=True) + write_block(str(path), _render(vendor, rules), OWNER) + return [policy_id for policy_id, fragment in fragments if _rules(fragment)] + + +def installed_toml_ids(repo_root: Path, vendor: str, fragments: list[tuple[str, Any]]) -> set[str]: + """Policy ids whose every rule is rendered inside our block.""" + try: + block = _owned_block(Path(repo_root) / vendors.config_path(vendor)) + except ValueError: + return set() + return { + policy_id + for policy_id, fragment in fragments + if _rules(fragment) and all(_render(vendor, [rule]).strip() in block for rule in _rules(fragment)) + } diff --git a/src/chock/hooks/sync_notes.py b/src/chock/hooks/sync_notes.py new file mode 100644 index 00000000..dc9a59d1 --- /dev/null +++ b/src/chock/hooks/sync_notes.py @@ -0,0 +1,39 @@ +"""What `chock sync` says about each wired agent: agentseam's pre-tool row, and whether chock has seen it run.""" + +from __future__ import annotations + +from agentseam import tier_table + +from chock import evidence + +#: Ledger surfaces that witness chock's own hooks deciding in a client; git-hook-env witnesses a marker only. +_HOOK_SURFACES = ("pre-tool-use", "agent-hooks", evidence.PLUGIN_HOOK) + +UNTESTED = ( + "Untested: chock has not witnessed its hooks run in this client; the wiring follows agentseam's record. " + "What you see there is wanted as evidence: open-coder-ai/agentseam issues labelled 'evidence wanted'." +) + + +def tier_line(vendor: str) -> str | None: + """agentseam's pre-tool row for `vendor`, its basis carried through; None where it has no row.""" + row = next((r for r in tier_table.rows() if r["agent"] == vendor), None) + if row is None: + return None + return ( + f"pre-tool: {row['tier']}, {row['grade']}, fail mode {row['fail_mode']}" + f" (agentseam basis {row['basis']}, recorded {row['recorded']})" + ) + + +def witnessed(vendor: str) -> bool: + """Whether the ledger holds a live observation of chock's hooks deciding in `vendor`'s client.""" + return any(evidence.witness(vendor, surface) for surface in _HOOK_SURFACES) + + +def notes(vendor: str) -> list[str]: + """The lines sync prints under a vendor it registered hooks for.""" + lines = [line for line in (tier_line(vendor),) if line] + if not witnessed(vendor): + lines.append(UNTESTED) + return [f" {vendor}: {line}" for line in lines] diff --git a/src/chock/install/data/install.json b/src/chock/install/data/install.json index 1786cb48..10fca7d8 100644 --- a/src/chock/install/data/install.json +++ b/src/chock/install/data/install.json @@ -39,7 +39,7 @@ "binary": "copilot", "index": [{"path": ".claude-plugin/marketplace.json", "style": "claude"}], "label_prefix": "", - "gate_turn_end_only": true, + "gate_turn_end_only": false, "steps": [ {"command": ["copilot", "plugin", "marketplace", "add", "__DEST__"]}, {"command": ["copilot", "plugin", "install", "__PLUGIN__@__MARKETPLACE__"]}, diff --git a/src/chock/install/data/warnings.json b/src/chock/install/data/warnings.json index c7ef47b0..9d678c85 100644 --- a/src/chock/install/data/warnings.json +++ b/src/chock/install/data/warnings.json @@ -63,7 +63,6 @@ { "kind": "client", "clients": [ - "copilot", "devin" ], "says": "This client names no file-write tool a hook can match, so a gate judges what a turn wrote only at turn end; the write itself is not held." @@ -71,15 +70,20 @@ { "kind": "client", "clients": [ - "cursor" + "copilot" ], - "says": "On Cursor Teams and Enterprise an admin setting (Allow Local Plugin Imports) can turn local plugins off, and an installed marketplace plugin of the same name takes precedence over this one." + "says": "A gate judges the write before it lands in Copilot CLI (witnessed 2026-10-11, CLI 1.0.94 on Windows); in VS Code agent mode that is unmeasured." }, { "kind": "client", "clients": [ "cursor" ], + "says": "On Cursor Teams and Enterprise an admin setting (Allow Local Plugin Imports) can turn local plugins off, and an installed marketplace plugin of the same name takes precedence over this one." + }, + { + "kind": "client", + "clients": [], "says": "Untested: chock has not witnessed an install in this client. Its steps, its hooks and the plugin version string (+) are unmeasured there." } ] diff --git a/src/chock/scaffold/recompile.py b/src/chock/scaffold/recompile.py index e6d87c50..e9de0e2a 100644 --- a/src/chock/scaffold/recompile.py +++ b/src/chock/scaffold/recompile.py @@ -13,6 +13,7 @@ from chock.compile.levels import DISABLED, Grade from chock.config import agents_from_config, guidance_mcp_enabled, load_config, policy_status from chock.emit import write_generated_json +from chock.hooks import sync_notes from chock.hooks.in_agent_install import ( WIRED_VENDORS, agent_hooks_rel, @@ -144,6 +145,8 @@ def _install_vendors(repo_root: Path, wired: tuple[str, ...]) -> list[str]: else: if installed: print(f"Registered {len(installed)} {install_label(vendor)}") + for line in sync_notes.notes(vendor): + print(line) if hint := vendors.trust_hint(vendor): # Codex skips an untrusted project hook without a word: wired is not live. print(f" ACTION NEEDED ({vendor}): hooks are not live until trusted -- {hint}") diff --git a/src/chock/vendors.py b/src/chock/vendors.py index b92bb522..ef058ad1 100644 --- a/src/chock/vendors.py +++ b/src/chock/vendors.py @@ -9,7 +9,9 @@ from agentseam import matrix as _matrix from agentseam.vendor_config import VENDOR_CONFIG -CHOCK_AGENT: dict[str, str] = { +#: chock's short agent names where they differ from agentseam's vendor id, plus agents agentseam has +#: no adapter for (aider, replit: instruction files only). +_ALIASES: dict[str, str] = { "claude": "claude_code", "cursor": "cursor", "windsurf": "windsurf", @@ -27,6 +29,21 @@ "antigravity": "antigravity", } +#: Every agent name `supported_agents` accepts: the aliases, then each agentseam adapter not yet +#: named, under its own id with `-` for `_`. A new upstream adapter needs no change here. +CHOCK_AGENT: dict[str, str] = { + **_ALIASES, + **{v.replace("_", "-"): v for v in sorted(VENDOR_CONFIG) if v not in _ALIASES.values()}, +} + +#: Config formats `chock sync` merges into: a JSON document, or an owned block in a TOML file. +#: `hook_dir` (Cline: one executable per event) is not one: it runs one command per event, and +#: chock's per-policy entries would need a single multi-policy dispatcher there. +SYNC_FORMATS = ("json", "toml") +#: agentseam names its own plugin directory in a path whose owner is the installer (goose). +_UPSTREAM_OWNER_SLOT = "/agentseam/" +_OWNER_SLOT = "/chock/" + def entry(vendor: str) -> dict[str, Any]: """agentseam's vendor-config entry for `vendor`; chock reads it, never re-records it.""" @@ -34,14 +51,19 @@ def entry(vendor: str) -> dict[str, Any]: def repo_wirable(vendor: str) -> bool: - """Whether `chock sync --repo` can reach the vendor's hook config: a repo-relative JSON file. + """Whether `chock sync --repo` can reach the vendor's hook config: a repo-relative JSON or TOML file. - A home-anchored config path (junie, kimi_code) or a non-JSON format is outside the + A home-anchored config path (junie, kimi_code) or a hooks directory (cline) is outside the repo-scoped install model, not outside the vendor's capability; the matrix row still says what the vendor could do, this says what chock's installer may touch. """ facts = entry(vendor) - return facts["config_format"] == "json" and not str(facts["config_path"]).startswith("~") + return facts["config_format"] in SYNC_FORMATS and not str(facts["config_path"]).startswith("~") + + +def config_format(vendor: str) -> str: + """The vendor's hook-config format as agentseam records it (`json`, `toml`, `hook_dir`).""" + return str(entry(vendor)["config_format"]) #: Vendors chock wires through its OWN hooks file (.github/hooks/chock.json) instead of the @@ -76,8 +98,8 @@ def stop_vendors() -> tuple[str, ...]: def config_path(vendor: str) -> str: - """Repo-relative path of the file the vendor reads hook wiring from.""" - return str(entry(vendor)["config_path"]) + """Repo-relative path of the file the vendor reads hook wiring from; chock's own plugin slot for goose.""" + return str(entry(vendor)["config_path"]).replace(_UPSTREAM_OWNER_SLOT, _OWNER_SLOT, 1) def repo_root_token(vendor: str) -> str | None: @@ -155,7 +177,7 @@ def tool_call_vendors() -> tuple[str, ...]: ) -def post_tool_hook_config(vendor: str, command: str) -> dict[str, Any]: +def post_tool_hook_config(vendor: str, command: str) -> Any: """The vendor's hook-config document running `command` after a tool call, with no matcher.""" return _adapters.get(vendor).hook_config((_contract.POST_TOOL,), command, None) @@ -193,12 +215,12 @@ def hook_entry_flat(vendor: str) -> bool: return (entry or {}).get("wrapper") == "cursor" -def pre_tool_hook_config(vendor: str, command: str, matcher: str | None = None) -> dict[str, Any]: +def pre_tool_hook_config(vendor: str, command: str, matcher: str | None = None) -> Any: """The vendor's complete hook-config document gating pre-tool with `command`.""" return _adapters.get(vendor).hook_config((_contract.PRE_TOOL,), command, matcher) -def stop_hook_config(vendor: str, command: str) -> dict[str, Any]: +def stop_hook_config(vendor: str, command: str) -> Any: """The vendor's complete hook-config document running `command` when a turn ends. No matcher: the turn-end event carries no tool to match on, which is why this surface diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/factory_droid-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/factory_droid-hooks.json new file mode 100644 index 00000000..b1fd105e --- /dev/null +++ b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/factory_droid-hooks.json @@ -0,0 +1,14 @@ +{ + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/factory_droid.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", + "timeout": 45 + } + ], + "matcher": "Execute" + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/goose-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/goose-hooks.json new file mode 100644 index 00000000..f98aeada --- /dev/null +++ b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/goose-hooks.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/goose.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", + "on_failure": "block", + "timeout": 45 + } + ], + "matcher": "shell" + } + ] + } +} \ No newline at end of file diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/mistral_vibe-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/mistral_vibe-hooks.json new file mode 100644 index 00000000..ebc8a77d --- /dev/null +++ b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/mistral_vibe-hooks.json @@ -0,0 +1,9 @@ +[ + { + "type": "pre_tool", + "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/mistral_vibe.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", + "match": "bash|powershell|git_bash", + "name": "agentseam-pre_tool-2cdbf332", + "timeout": 45 + } +] \ No newline at end of file diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/openhands-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/openhands-hooks.json new file mode 100644 index 00000000..48781090 --- /dev/null +++ b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/openhands-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/openhands.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", + "timeout": 45 + } + ], + "matcher": "terminal" + } + ] + } +} \ No newline at end of file diff --git a/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/factory_droid-write-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/factory_droid-write-hooks.json new file mode 100644 index 00000000..5b8516d9 --- /dev/null +++ b/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/factory_droid-write-hooks.json @@ -0,0 +1,14 @@ +{ + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/factory_droid.py --gate \".chock/compiled/stability-script/pre-tool-use/gate.json\"", + "timeout": 45 + } + ], + "matcher": "Create|Edit|MultiEdit|ApplyPatch" + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/goose-write-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/goose-write-hooks.json new file mode 100644 index 00000000..3b36d858 --- /dev/null +++ b/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/goose-write-hooks.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/goose.py --gate \".chock/compiled/stability-script/pre-tool-use/gate.json\"", + "on_failure": "block", + "timeout": 45 + } + ], + "matcher": "write|edit" + } + ] + } +} \ No newline at end of file diff --git a/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/mistral_vibe-write-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/mistral_vibe-write-hooks.json new file mode 100644 index 00000000..35e96c8d --- /dev/null +++ b/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/mistral_vibe-write-hooks.json @@ -0,0 +1,9 @@ +[ + { + "type": "pre_tool", + "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/mistral_vibe.py --gate \".chock/compiled/stability-script/pre-tool-use/gate.json\"", + "match": "write_file|edit", + "name": "agentseam-pre_tool-ab03e5ae", + "timeout": 45 + } +] \ No newline at end of file diff --git a/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/openhands-write-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/openhands-write-hooks.json new file mode 100644 index 00000000..e85a8aae --- /dev/null +++ b/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/openhands-write-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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/openhands.py --gate \".chock/compiled/stability-script/pre-tool-use/gate.json\"", + "timeout": 45 + } + ], + "matcher": "file_editor" + } + ] + } +} \ No newline at end of file diff --git a/tests/fixtures/emitter_stability/golden/stability-script/stop/factory_droid-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/stop/factory_droid-hooks.json new file mode 100644 index 00000000..6a59bec4 --- /dev/null +++ b/tests/fixtures/emitter_stability/golden/stability-script/stop/factory_droid-hooks.json @@ -0,0 +1,13 @@ +{ + "Stop": [ + { + "hooks": [ + { + "type": "command", + "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/factory_droid.py --gate \".chock/compiled/stability-script/stop/gate.json\"", + "timeout": 45 + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/emitter_stability/golden/stability-script/stop/goose-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/stop/goose-hooks.json new file mode 100644 index 00000000..9bf01e21 --- /dev/null +++ b/tests/fixtures/emitter_stability/golden/stability-script/stop/goose-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "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/goose.py --gate \".chock/compiled/stability-script/stop/gate.json\"", + "on_failure": "block", + "timeout": 45 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/tests/fixtures/emitter_stability/golden/stability-script/stop/mistral_vibe-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/stop/mistral_vibe-hooks.json new file mode 100644 index 00000000..f0201222 --- /dev/null +++ b/tests/fixtures/emitter_stability/golden/stability-script/stop/mistral_vibe-hooks.json @@ -0,0 +1,8 @@ +[ + { + "type": "post_agent", + "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/mistral_vibe.py --gate \".chock/compiled/stability-script/stop/gate.json\"", + "name": "agentseam-post_agent-ba650ba3", + "timeout": 45 + } +] \ No newline at end of file diff --git a/tests/fixtures/emitter_stability/golden/stability-script/stop/openhands-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/stop/openhands-hooks.json new file mode 100644 index 00000000..cb993f6b --- /dev/null +++ b/tests/fixtures/emitter_stability/golden/stability-script/stop/openhands-hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "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/openhands.py --gate \".chock/compiled/stability-script/stop/gate.json\"", + "timeout": 45 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/tests/fixtures/runtime_goldens/antigravity.py b/tests/fixtures/runtime_goldens/antigravity.py index 72ca2602..1a4b6164 100644 --- a/tests/fixtures/runtime_goldens/antigravity.py +++ b/tests/fixtures/runtime_goldens/antigravity.py @@ -29,6 +29,7 @@ from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings +import zlib as _zlib # ------------------------------------------------------------------------------ # contract (agentseam 0.3.5) @@ -253,12 +254,25 @@ def _segment(node, part): return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None return node.get(part) if isinstance(node, dict) else None +def _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + def _walk(node, path): - """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None if "[]." in path: head, sub = path.split("[].", 1) - items = _walk(node, head) - if not isinstance(items, (list, tuple)): + items = _list_at(node, head) + if items is None: return None joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) return joined or None @@ -288,7 +302,7 @@ def _tool_input_raw(cfg, raw): if not isinstance(raw, dict): return None for key in cfg["fields"].get("tool_input", ("tool_input",)): - value = raw.get(key) + value = _walk(raw, key) if value is not None: return value return None @@ -479,6 +493,8 @@ def hj_respond(cfg, decision, event, wire=None): return "", 0 if gate["grammar"] == "G2": return _g2(v, gate, decision, name) + if gate["grammar"] == "G7": + return _g7(v, decision, wire) return _g1(v, gate, decision, wire, name) _WINDOWS_KEYS = ("commandWindows", "windows") @@ -494,16 +510,25 @@ def _hook_dict(cfg, command): entry[key] = value return entry +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): rules = [] for ev in canonical_events: name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: - rule["matcher"] = matcher - rules.append(rule) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) return rules def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): @@ -532,6 +557,10 @@ def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): hooks.setdefault(extra, []).append({"command": command}) return {"hooks": hooks} +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] hooks = {} @@ -562,6 +591,8 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) if wrapper == "flat_entries": return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) return _default_wrapper(cfg, reverse, canonical_events, command, matcher) def antigravity_wire(raw): diff --git a/tests/fixtures/runtime_goldens/claude_code.py b/tests/fixtures/runtime_goldens/claude_code.py index f39b0fd3..f8e1fa2a 100644 --- a/tests/fixtures/runtime_goldens/claude_code.py +++ b/tests/fixtures/runtime_goldens/claude_code.py @@ -29,6 +29,7 @@ from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings +import zlib as _zlib # ------------------------------------------------------------------------------ # contract (agentseam 0.3.5) @@ -235,11 +236,31 @@ def tool_input_of(raw): "effort", ) +_MCP_PREFIX = "mcp_" + +_CLAUDE_MCP_PREFIX = "mcp__" + def looks_like_claude_code(raw): """True when the payload carries a field only Claude Code has been seen to send.""" return isinstance(raw, dict) and any(marker in raw for marker in OBSERVED_MARKERS) -PROBES = {"looks_like_claude_code": looks_like_claude_code} +def names_a_single_underscore_mcp_tool(raw): + """True when tool_name is an MCP tool ID in a spelling Claude Code never sends.""" + name = raw.get("tool_name") if isinstance(raw, dict) else None + return isinstance(name, str) and name.startswith(_MCP_PREFIX) and not name.startswith(_CLAUDE_MCP_PREFIX) + +def event_keys_agree(raw): + """True when hookEventName names hook_event_name's event: the same text, or Grok Build's snake_case twin.""" + snake, pascal = (raw.get("hookEventName"), raw.get("hook_event_name")) if isinstance(raw, dict) else (None, None) + if not isinstance(pascal, str) or not isinstance(snake, str): + return False + return snake == pascal or (snake.islower() and snake.replace("_", "") == pascal.lower()) + +PROBES = { + "looks_like_claude_code": looks_like_claude_code, + "names_a_single_underscore_mcp_tool": names_a_single_underscore_mcp_tool, + "event_keys_agree": event_keys_agree, +} UNREADABLE_NAME = "" @@ -265,12 +286,22 @@ def _rejected_by_markers(c, raw): for marker in c.get("reject_markers", ()): if marker in raw: return True + for key, values in c.get("reject_values", {}).items(): + if key in raw and raw[key] in values: + return True for probe, markers in c.get("reject_markers_unless_probe", {}).items(): if any(marker in raw for marker in markers) and not PROBES[probe](raw): return True return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) +def _accepted_by_values(c, raw): + """True unless the entry names accepted values and the payload carries none of them.""" + values = c.get("accept_values") + return not values or any(key in raw and raw[key] in allowed for key, allowed in values.items()) + def _accepted_by_markers(c, raw, name): + if not _accepted_by_values(c, raw): + return False accept = c.get("accept_markers", ()) if not accept or any(marker in raw for marker in accept): return True @@ -309,12 +340,25 @@ def _segment(node, part): return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None return node.get(part) if isinstance(node, dict) else None +def _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + def _walk(node, path): - """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None if "[]." in path: head, sub = path.split("[].", 1) - items = _walk(node, head) - if not isinstance(items, (list, tuple)): + items = _list_at(node, head) + if items is None: return None joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) return joined or None @@ -344,7 +388,7 @@ def _tool_input_raw(cfg, raw): if not isinstance(raw, dict): return None for key in cfg["fields"].get("tool_input", ("tool_input",)): - value = raw.get(key) + value = _walk(raw, key) if value is not None: return value return None @@ -566,6 +610,8 @@ def hj_respond(cfg, decision, event, wire=None): return "", 0 if gate["grammar"] == "G2": return _g2(v, gate, decision, name) + if gate["grammar"] == "G7": + return _g7(v, decision, wire) return _g1(v, gate, decision, wire, name) _WINDOWS_KEYS = ("commandWindows", "windows") @@ -581,16 +627,25 @@ def _hook_dict(cfg, command): entry[key] = value return entry +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): rules = [] for ev in canonical_events: name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: - rule["matcher"] = matcher - rules.append(rule) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) return rules def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): @@ -619,6 +674,10 @@ def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): hooks.setdefault(extra, []).append({"command": command}) return {"hooks": hooks} +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] hooks = {} @@ -649,6 +708,8 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) if wrapper == "flat_entries": return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) return _default_wrapper(cfg, reverse, canonical_events, command, matcher) @@ -657,7 +718,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': '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'}}} +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). A value Claude Code never sends beats every marker key, looks_like_claude_code's included (value-based detection, D1): permission_mode values off, spec, auto-low, auto-medium and auto-high are Factory Droid's (docs.factory.com/harness/hooks, read 2026-10-11); tool_name values fs_read, fsRead, fs_write, fsWrite, execute_bash, execute_cmd and use_aws (kiro.dev/docs/reference/built-in-tools, updated 2026-10-10), and an MCP tool spelled mcp__ rather than Claude's mcp____ (kiro.dev/docs/hooks/types, updated 2026-10-10), are Kiro's. Kiro's V3 primary names (read, write, shell) and its tool-less events carry nothing Claude Code does not, so those still read as claude_code. factory_droid claims Droid's payloads on those same permission_mode values (accept_values, D2a); Kiro has no adapter, so its separable payloads detect as no agent rather than as claude_code.", 'reject_markers': ('turn_id', 'project_path', 'timestamp'), 'reject_markers_unless_probe': {'looks_like_claude_code': ('prompt_id',)}, 'reject_probes': ('names_a_single_underscore_mcp_tool',), 'reject_values': {'permission_mode': ('off', 'spec', 'auto-low', 'auto-medium', 'auto-high'), 'tool_name': ('fs_read', 'fsRead', 'fs_write', 'fsWrite', 'execute_bash', 'execute_cmd', 'use_aws')}}, '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): diff --git a/tests/fixtures/runtime_goldens/codex_cli.py b/tests/fixtures/runtime_goldens/codex_cli.py index f1e41dba..6f7a3a65 100644 --- a/tests/fixtures/runtime_goldens/codex_cli.py +++ b/tests/fixtures/runtime_goldens/codex_cli.py @@ -29,6 +29,7 @@ from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings +import zlib as _zlib # ------------------------------------------------------------------------------ # contract (agentseam 0.3.5) @@ -262,12 +263,22 @@ def _rejected_by_markers(c, raw): for marker in c.get("reject_markers", ()): if marker in raw: return True + for key, values in c.get("reject_values", {}).items(): + if key in raw and raw[key] in values: + return True for probe, markers in c.get("reject_markers_unless_probe", {}).items(): if any(marker in raw for marker in markers) and not PROBES[probe](raw): return True return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) +def _accepted_by_values(c, raw): + """True unless the entry names accepted values and the payload carries none of them.""" + values = c.get("accept_values") + return not values or any(key in raw and raw[key] in allowed for key, allowed in values.items()) + def _accepted_by_markers(c, raw, name): + if not _accepted_by_values(c, raw): + return False accept = c.get("accept_markers", ()) if not accept or any(marker in raw for marker in accept): return True @@ -306,12 +317,25 @@ def _segment(node, part): return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None return node.get(part) if isinstance(node, dict) else None +def _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + def _walk(node, path): - """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None if "[]." in path: head, sub = path.split("[].", 1) - items = _walk(node, head) - if not isinstance(items, (list, tuple)): + items = _list_at(node, head) + if items is None: return None joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) return joined or None @@ -341,7 +365,7 @@ def _tool_input_raw(cfg, raw): if not isinstance(raw, dict): return None for key in cfg["fields"].get("tool_input", ("tool_input",)): - value = raw.get(key) + value = _walk(raw, key) if value is not None: return value return None @@ -563,6 +587,8 @@ def hj_respond(cfg, decision, event, wire=None): return "", 0 if gate["grammar"] == "G2": return _g2(v, gate, decision, name) + if gate["grammar"] == "G7": + return _g7(v, decision, wire) return _g1(v, gate, decision, wire, name) _WINDOWS_KEYS = ("commandWindows", "windows") @@ -578,16 +604,25 @@ def _hook_dict(cfg, command): entry[key] = value return entry +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): rules = [] for ev in canonical_events: name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: - rule["matcher"] = matcher - rules.append(rule) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) return rules def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): @@ -616,6 +651,10 @@ def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): hooks.setdefault(extra, []).append({"command": command}) return {"hooks": hooks} +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] hooks = {} @@ -646,6 +685,8 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) if wrapper == "flat_entries": return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) return _default_wrapper(cfg, reverse, canonical_events, command, matcher) diff --git a/tests/fixtures/runtime_goldens/cursor.py b/tests/fixtures/runtime_goldens/cursor.py index af70c81c..b4757b40 100644 --- a/tests/fixtures/runtime_goldens/cursor.py +++ b/tests/fixtures/runtime_goldens/cursor.py @@ -29,6 +29,7 @@ from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings +import zlib as _zlib # ------------------------------------------------------------------------------ # contract (agentseam 0.3.5) @@ -253,12 +254,25 @@ def _segment(node, part): return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None return node.get(part) if isinstance(node, dict) else None +def _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + def _walk(node, path): - """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None if "[]." in path: head, sub = path.split("[].", 1) - items = _walk(node, head) - if not isinstance(items, (list, tuple)): + items = _list_at(node, head) + if items is None: return None joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) return joined or None @@ -288,7 +302,7 @@ def _tool_input_raw(cfg, raw): if not isinstance(raw, dict): return None for key in cfg["fields"].get("tool_input", ("tool_input",)): - value = raw.get(key) + value = _walk(raw, key) if value is not None: return value return None @@ -369,16 +383,25 @@ def _hook_dict(cfg, command): entry[key] = value return entry +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): rules = [] for ev in canonical_events: name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: - rule["matcher"] = matcher - rules.append(rule) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) return rules def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): @@ -407,6 +430,10 @@ def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): hooks.setdefault(extra, []).append({"command": command}) return {"hooks": hooks} +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] hooks = {} @@ -437,6 +464,8 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) if wrapper == "flat_entries": return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) return _default_wrapper(cfg, reverse, canonical_events, command, matcher) _AMBIGUOUS_NAMES = ( diff --git a/tests/fixtures/runtime_goldens/devin.py b/tests/fixtures/runtime_goldens/devin.py index e93a7a95..a977783b 100644 --- a/tests/fixtures/runtime_goldens/devin.py +++ b/tests/fixtures/runtime_goldens/devin.py @@ -29,6 +29,7 @@ from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings +import zlib as _zlib # ------------------------------------------------------------------------------ # contract (agentseam 0.3.5) @@ -235,11 +236,31 @@ def tool_input_of(raw): "effort", ) +_MCP_PREFIX = "mcp_" + +_CLAUDE_MCP_PREFIX = "mcp__" + def looks_like_claude_code(raw): """True when the payload carries a field only Claude Code has been seen to send.""" return isinstance(raw, dict) and any(marker in raw for marker in OBSERVED_MARKERS) -PROBES = {"looks_like_claude_code": looks_like_claude_code} +def names_a_single_underscore_mcp_tool(raw): + """True when tool_name is an MCP tool ID in a spelling Claude Code never sends.""" + name = raw.get("tool_name") if isinstance(raw, dict) else None + return isinstance(name, str) and name.startswith(_MCP_PREFIX) and not name.startswith(_CLAUDE_MCP_PREFIX) + +def event_keys_agree(raw): + """True when hookEventName names hook_event_name's event: the same text, or Grok Build's snake_case twin.""" + snake, pascal = (raw.get("hookEventName"), raw.get("hook_event_name")) if isinstance(raw, dict) else (None, None) + if not isinstance(pascal, str) or not isinstance(snake, str): + return False + return snake == pascal or (snake.islower() and snake.replace("_", "") == pascal.lower()) + +PROBES = { + "looks_like_claude_code": looks_like_claude_code, + "names_a_single_underscore_mcp_tool": names_a_single_underscore_mcp_tool, + "event_keys_agree": event_keys_agree, +} UNREADABLE_NAME = "" @@ -265,12 +286,22 @@ def _rejected_by_markers(c, raw): for marker in c.get("reject_markers", ()): if marker in raw: return True + for key, values in c.get("reject_values", {}).items(): + if key in raw and raw[key] in values: + return True for probe, markers in c.get("reject_markers_unless_probe", {}).items(): if any(marker in raw for marker in markers) and not PROBES[probe](raw): return True return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) +def _accepted_by_values(c, raw): + """True unless the entry names accepted values and the payload carries none of them.""" + values = c.get("accept_values") + return not values or any(key in raw and raw[key] in allowed for key, allowed in values.items()) + def _accepted_by_markers(c, raw, name): + if not _accepted_by_values(c, raw): + return False accept = c.get("accept_markers", ()) if not accept or any(marker in raw for marker in accept): return True @@ -309,12 +340,25 @@ def _segment(node, part): return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None return node.get(part) if isinstance(node, dict) else None +def _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + def _walk(node, path): - """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None if "[]." in path: head, sub = path.split("[].", 1) - items = _walk(node, head) - if not isinstance(items, (list, tuple)): + items = _list_at(node, head) + if items is None: return None joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) return joined or None @@ -344,7 +388,7 @@ def _tool_input_raw(cfg, raw): if not isinstance(raw, dict): return None for key in cfg["fields"].get("tool_input", ("tool_input",)): - value = raw.get(key) + value = _walk(raw, key) if value is not None: return value return None @@ -535,6 +579,8 @@ def hj_respond(cfg, decision, event, wire=None): return "", 0 if gate["grammar"] == "G2": return _g2(v, gate, decision, name) + if gate["grammar"] == "G7": + return _g7(v, decision, wire) return _g1(v, gate, decision, wire, name) _WINDOWS_KEYS = ("commandWindows", "windows") @@ -550,16 +596,25 @@ def _hook_dict(cfg, command): entry[key] = value return entry +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): rules = [] for ev in canonical_events: name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: - rule["matcher"] = matcher - rules.append(rule) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) return rules def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): @@ -588,6 +643,10 @@ def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): hooks.setdefault(extra, []).append({"command": command}) return {"hooks": hooks} +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] hooks = {} @@ -618,6 +677,8 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) if wrapper == "flat_entries": return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) return _default_wrapper(cfg, reverse, canonical_events, command, matcher) diff --git a/tests/fixtures/runtime_goldens/factory_droid.py b/tests/fixtures/runtime_goldens/factory_droid.py new file mode 100644 index 00000000..1d4ae174 --- /dev/null +++ b/tests/fixtures/runtime_goldens/factory_droid.py @@ -0,0 +1,2438 @@ +# Generated by agentseam 0.3.5 -- bundle("factory_droid"). 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. +# +# Self-contained: stdlib only, no "import agentseam" anywhere in this file. Regenerate +# with `agentseam.bundler.bundle("factory_droid")` (same agentseam version -> identical bytes, +# except your own edits inside the handler block) rather than patching this by hand. +# https://github.com/open-coder-ai/agentseam + +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 +import zlib as _zlib + +# ------------------------------------------------------------------------------ +# contract (agentseam 0.3.5) + +"""Canonical event vocabulary, normalized envelope, and decision type.""" + + + +SESSION_START = "session_start" +SESSION_END = "session_end" +PROMPT_SUBMIT = "prompt_submit" +PRE_TOOL = "pre_tool" +POST_TOOL = "post_tool" +TOOL_FAILURE = "tool_failure" +PRE_COMPACT = "pre_compact" +STOP = "stop" +SUBAGENT_START = "subagent_start" +SUBAGENT_STOP = "subagent_stop" +INSTRUCTIONS_LOADED = "instructions_loaded" +FILE_CHANGED = "file_changed" + +UNKNOWN = "unknown" + +EVENTS = ( + SESSION_START, + SESSION_END, + PROMPT_SUBMIT, + PRE_TOOL, + POST_TOOL, + TOOL_FAILURE, + PRE_COMPACT, + STOP, + SUBAGENT_START, + SUBAGENT_STOP, + INSTRUCTIONS_LOADED, + FILE_CHANGED, +) + + +class Event: + """One agent lifecycle event, normalized.""" + + __slots__ = ( + "agent", + "command", + "content", + "cwd", + "event", + "output", + "path", + "prompt", + "raw", + "session_id", + "tool", + "tool_use_id", + ) + + def __init__( + self, + agent, + event, + *, + tool=None, + command=None, + path=None, + content=None, + output=None, + prompt=None, + session_id=None, + tool_use_id=None, + cwd=None, + raw=None, + ): + self.agent = agent + self.event = event + self.tool = tool + self.command = command + self.path = path + self.content = content + self.output = output + self.prompt = prompt + self.session_id = session_id + self.tool_use_id = tool_use_id + self.cwd = cwd + self.raw = raw if raw is not None else {} + + def __repr__(self): # pragma: no cover - debugging aid + return "Event(%s/%s tool=%r path=%r)" % (self.agent, self.event, self.tool, self.path) + + +ALLOW = "allow" +DENY = "deny" +ESCALATE = "escalate" +TRANSFORM = "transform" +WARN = "warn" +VOUCH = "vouch" + +# Pre-ACS-alignment names. Same strings as their ACS-named counterparts, so every existing +# `is`/`==` comparison against the old constant keeps working untouched. +ASK = ESCALATE +REWRITE = TRANSFORM + +_CANONICAL_OUTCOMES = (ALLOW, DENY, ESCALATE, TRANSFORM, WARN, VOUCH) + +# The literal spellings a caller might still pass to Decision(outcome, ...) directly, mapped +# to the value that now backs them. Only needed for the raw-string constructor path -- +# Decision.ask()/.rewrite() below build the canonical outcome themselves. +_LEGACY_SPELLING = {"ask": ESCALATE, "rewrite": TRANSFORM} + + +class Decision: + """What a handler wants to happen. Adapters translate this to vendor dialect.""" + + __slots__ = ("context", "evidence", "outcome", "reason", "updated_input") + + #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). + DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) + + def __init__(self, outcome, reason=None, updated_input=None, evidence=None, context=None): + outcome = _LEGACY_SPELLING.get(outcome, outcome) + if outcome not in _CANONICAL_OUTCOMES: + raise ValueError("unknown outcome: %r" % (outcome,)) + self.outcome = outcome + self.reason = reason + self.updated_input = updated_input + self.evidence = evidence or {} + self.context = context + + @classmethod + def allow(cls, reason=None, evidence=None, context=None): + return cls(ALLOW, reason, evidence=evidence, context=context) + + @classmethod + def deny(cls, reason, evidence=None, context=None): + return cls(DENY, reason, evidence=evidence, context=context) + + @classmethod + def escalate(cls, reason, evidence=None, context=None): + """Defer the action to the host's own approval path (ACS `escalate`).""" + return cls(ESCALATE, reason, evidence=evidence, context=context) + + @classmethod + def ask(cls, reason, evidence=None, context=None): + """Deprecated alias of escalate() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.ask() is deprecated; use Decision.escalate()", DeprecationWarning, stacklevel=2) + return cls.escalate(reason, evidence=evidence, context=context) + + @classmethod + def transform(cls, updated_input, reason=None, evidence=None, context=None): + """Replace the tool input wholesale (ACS `transform`, at whole-value granularity).""" + return cls(TRANSFORM, reason, updated_input=updated_input, evidence=evidence, context=context) + + @classmethod + def rewrite(cls, updated_input, reason=None, evidence=None, context=None): + """Deprecated alias of transform() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.rewrite() is deprecated; use Decision.transform()", DeprecationWarning, stacklevel=2) + return cls.transform(updated_input, reason, evidence=evidence, context=context) + + @classmethod + def warn(cls, reason=None, evidence=None, context=None): + """Permit the action with no change, recording a warning (ACS `warn`).""" + return cls(WARN, reason, evidence=evidence, context=context) + + @classmethod + def vouch(cls, reason=None, evidence=None, context=None): + return cls(VOUCH, reason, evidence=evidence, context=context) + + def __repr__(self): # pragma: no cover - debugging aid + return "Decision(%s, %r)" % (self.outcome, self.reason) + + +def degraded_from(decision): + """What this decision was before the dispatcher reduced it, or None.""" + return (decision.evidence or {}).get("degraded_from") + + +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.lstrip()[:1] == "{": + try: + parsed = _json.loads(raw) + except (ValueError, RecursionError): + # JSONDecodeError is a ValueError; a string nested past the interpreter's + # limit raises RecursionError instead, and neither is an input to crash on. + return {} + if isinstance(parsed, dict): + return parsed + return {} + + +# ------------------------------------------------------------------------------ +# hook_json family engine (trimmed to what this entry uses) + +UNREADABLE_NAME = "" + +def wire_name_of(raw, key): + """The event name under `key`: text, None when absent, UNREADABLE_NAME when not text. + + Total over anything `json.loads` can return: a payload that is not an object names no + event, and a name that is not a string is not one any adapter can map. + """ + name = raw.get(key) if isinstance(raw, dict) else None + if name is None or isinstance(name, str): + return name + return UNREADABLE_NAME + +def _wire_name(cfg, raw): + for key in cfg["claims"].get("event_key", ()): + name = wire_name_of(raw, key) + if name is not None: + return name + return None + +def _rejected_by_markers(c, raw): + for marker in c.get("reject_markers", ()): + if marker in raw: + return True + for key, values in c.get("reject_values", {}).items(): + if key in raw and raw[key] in values: + return True + for probe, markers in c.get("reject_markers_unless_probe", {}).items(): + if any(marker in raw for marker in markers) and not PROBES[probe](raw): + return True + return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) + +def _accepted_by_values(c, raw): + """True unless the entry names accepted values and the payload carries none of them.""" + values = c.get("accept_values") + return not values or any(key in raw and raw[key] in allowed for key, allowed in values.items()) + +def _accepted_by_markers(c, raw, name): + if not _accepted_by_values(c, raw): + return False + accept = c.get("accept_markers", ()) + if not accept or any(marker in raw for marker in accept): + return True + return any( + name == event_name and all(key in raw for key in required) + for event_name, required in c.get("accept_when_all", {}).items() + ) + +def _disqualified(cfg, c, raw, name): + """Every reason an entry declines a payload once its wire event name is known.""" + if name not in cfg["events"] and not (c.get("accept_any_name") and isinstance(name, str)): + return True + if "client_types" in c and raw.get("client_type") not in c["client_types"]: + return True + return _rejected_by_markers(c, raw) + +def hj_claims(cfg, raw): + """True when this payload matches the entry's marker discipline.""" + if not isinstance(raw, dict): + return False + c = cfg["claims"] + name = _wire_name(cfg, raw) + # A positive self-identification beats a shared event name, accept_names included. + if raw.get("client_type") in c.get("reject_client_types", ()): + return False + if name in c.get("accept_names", ()): + return True + return not _disqualified(cfg, c, raw, name) and _accepted_by_markers(c, raw, name) + +def _segment(node, part): + """One path segment: a dict key, or `key[N]` indexing the list under it.""" + if part.endswith("]") and "[" in part: + key, _, index = part[:-1].partition("[") + items = node.get(key) if isinstance(node, dict) else None + i = int(index) + return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None + return node.get(part) if isinstance(node, dict) else None + +def _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + +def _walk(node, path): + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None + if "[]." in path: + head, sub = path.split("[].", 1) + items = _list_at(node, head) + if items is None: + return None + joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) + return joined or None + for part in path.split("."): + node = _segment(node, part) + if node is None: + return None + return node + +def _lookup(raw, ti, key): + """One config key: a `tool_input.` path walks the decoded tool input, else the payload.""" + if key.startswith("tool_input."): + return _walk(ti, key[len("tool_input.") :]) + return _walk(raw, key) + +def _field(raw, ti, chain): + value = None + for key in chain: + if value: + break + value = value or _lookup(raw, ti, key) + return value + +_FIELD_META = ("tool_input", "content_only_for_write_tools", "stringify") + +def _tool_input_raw(cfg, raw): + if not isinstance(raw, dict): + return None + for key in cfg["fields"].get("tool_input", ("tool_input",)): + value = _walk(raw, key) + if value is not None: + return value + return None + +def _canonical_of(cfg, name): + """The canonical event for one wire name; an entry with no `events` at all (antigravity, + whose payloads never carry one) maps the shape-inferred name back through `wire_events`.""" + events = cfg["events"] + if not events: + return {wire: canonical for canonical, wire in cfg.get("wire_events", {}).items()}.get(name, UNKNOWN) + return events.get(name, UNKNOWN) + +def hj_parse(cfg, raw, wire=None): + """Normalise one payload along the entry's ordered field-fallback chains. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + ti = tool_input_of(_tool_input_raw(cfg, raw)) + fields = {name: _field(raw, ti, chain) for name, chain in cfg["fields"].items() if name not in _FIELD_META} + if cfg["fields"].get("content_only_for_write_tools") and fields.get("tool") not in cfg["tools"].get("write", ()): + fields["content"] = None + if isinstance(fields.get("output"), (dict, list)): + fields["output"] = _json.dumps(fields["output"]) + for name in cfg["fields"].get("stringify", ()): + if fields.get(name) is not None: + fields[name] = str(fields[name]) + return Event( + cfg["agent"], + _canonical_of(cfg, wire if wire is not None else _wire_name(cfg, raw)), + tool=fields.get("tool"), + command=fields.get("command"), + path=fields.get("path"), + content=fields.get("content"), + output=fields.get("output"), + prompt=fields.get("prompt"), + session_id=fields.get("session_id"), + tool_use_id=fields.get("tool_use_id"), + cwd=fields.get("cwd"), + raw=raw, + ) + +_ESCALATE_FROM_TRANSFORM = "escalate_from_transform" + +_TRANSFORM_MISSING_INPUT = "transform_missing_input" + +def hj_reverse(cfg): + """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides. + + One wire name per canonical event, which is a real limit and not an oversight (R3, gap 4). + Cursor is where it bites: `pre_tool` pins to `preToolUse`, and Cursor honours `ask` only at + `beforeShellExecution` / `beforeMCPExecution`. So installing at `pre_tool` forecloses `ask` + before dispatch is ever reached -- the runtime degrade to `deny` is honest about it, but the + install already chose. Deny-style policies, which is all that ships today, are unaffected. + + The day an ask-style Cursor policy exists, this map has to select by decision dialect + (deny -> `preToolUse`, ask -> `beforeShellExecution`) and become one-to-many. Recorded here + rather than built, so the resolution is not re-litigated from scratch; the three facts it + rests on are pinned in tests/test_cursor_ask_dialect.py. + """ + reverse = {} + for name, canonical in cfg["events"].items(): + if canonical != UNKNOWN: + reverse[canonical] = name + reverse.update(cfg.get("wire_events", {})) + return reverse + +def _context_value(v, decision): + if v.get("context_source") == "context": + return decision.context + if v.get("context_source") == "reason": + return decision.reason + return None + +def _context_body(name, value): + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "additionalContext": value}}), 0 + +def _note_for(v, decision, at_gate, missing_input): + notes = v.get("degrade_notes", {}) + if decision.outcome == ESCALATE: + if degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in notes: + return notes[_ESCALATE_FROM_TRANSFORM] + if at_gate and "escalate_gate" in notes: + return notes["escalate_gate"] + return notes.get("escalate") + if decision.outcome == TRANSFORM: + if missing_input and _TRANSFORM_MISSING_INPUT in notes: + return notes[_TRANSFORM_MISSING_INPUT] + return notes.get("transform") + return None + +def _default_for(v, decision, at_gate, wire=None): + gate_defaults = v.get("gate_reason_defaults", {}) + if wire in gate_defaults: + return gate_defaults[wire] + defaults = v.get("reason_defaults", {}) + key = {DENY: "deny", ESCALATE: "escalate", TRANSFORM: "transform"}.get(decision.outcome, "deny") + if at_gate and key + "_gate" in defaults: + return defaults[key + "_gate"] + return defaults.get(key, "blocked by policy") + +def _refusal_text(v, decision, at_gate, wire=None): + note = _note_for(v, decision, at_gate, decision.updated_input is None) + default = _default_for(v, decision, at_gate, wire) + if note and "%s" in note: + # A template note fills (the reason or its default, the wire event name) itself. + return note % (decision.reason or default, wire) + reason = decision.reason + if v.get("note_style") == "suffix": + reason = reason or default + return "%s (%s)" % (reason, note) if note else reason + text = "%s (%s)" % (reason, note) if reason and note else (note or reason) + return text or default + +def _g1_allow(v, decision, wire, name, words): + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + if wire in v.get("allow_silent_events", ()): + return "", 0 + if "allow" in words: + out = {"decision": words["allow"]} + if v.get("allow_context_key") and decision.outcome == ALLOW and value: + out[v["allow_context_key"]] = value + return _json.dumps(out), 0 + return "", 0 + +def _g1_transform(v, decision, name, words): + """None when the transform isn't representable here; `_g1` falls through to the block path.""" + if v.get("transform_grammar") == "hook_specific_tool_input": + return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0 + if decision.updated_input is None: + return None + if v.get("transform_grammar") == "top_level_updated_input": + out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input} + if decision.reason: + out["reason"] = decision.reason + return _json.dumps(out), 0 + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}}), 0 + +def _g1(v, gate, decision, wire, name): + """Block dialect: a top-level decision word, or silence/context where nothing is read.""" + words = dict(v.get("words", {})) + words.update(v.get("words_at", {}).get(wire, {})) + at_context_event = wire in v.get("context_events", ()) + if decision.outcome in (ALLOW, VOUCH, WARN): + return _g1_allow(v, decision, wire, name, words) + if decision.outcome == TRANSFORM and gate["honours_transform"]: + transformed = _g1_transform(v, decision, name, words) + if transformed is not None: + return transformed + if ( + decision.outcome == ESCALATE + and gate["honours_escalate"] + and "escalate" in words + # An escalate the dispatcher degraded a transform into is a block where the entry + # names that degradation (antigravity): prompting would offer the unmodified call. + and not (degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in v.get("degrade_notes", {})) + ): + reason = decision.reason or _default_for(v, decision, at_gate=True, wire=wire) + return _json.dumps({"decision": words["escalate"], "reason": reason}), 0 + out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, at_gate=False, wire=wire)} + if at_context_event and v.get("context_source") == "context" and decision.context: + out["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": decision.context} + return _json.dumps(out), 0 + +_PERMISSION_DECISION_REASON = "permissionDecisionReason" + +def _g2(v, gate, decision, name): + """Permission gate: `hookSpecificOutput.permissionDecision`, filled from the word table.""" + if decision.outcome in (ALLOW, WARN): + return "", 0 + words = v.get("words", {}) + out = {"hookEventName": name} + if decision.outcome == VOUCH: + if "vouch" not in words: + return "", 0 + out["permissionDecision"] = words["vouch"] + if decision.reason: + out[_PERMISSION_DECISION_REASON] = decision.reason + elif ( + decision.outcome == TRANSFORM + and gate["honours_transform"] + and not (decision.updated_input is None and _TRANSFORM_MISSING_INPUT in v.get("degrade_notes", {})) + ): + out["permissionDecision"] = words.get("transform", "allow") + out["updatedInput"] = decision.updated_input + if decision.reason: + out[_PERMISSION_DECISION_REASON] = decision.reason + elif decision.outcome == ESCALATE and gate["honours_escalate"] and "escalate" in words: + out["permissionDecision"] = words["escalate"] + out[_PERMISSION_DECISION_REASON] = decision.reason or _default_for(v, decision, at_gate=True) + else: + out["permissionDecision"] = words.get("deny", "deny") + out[_PERMISSION_DECISION_REASON] = _refusal_text(v, decision, at_gate=True) + return _json.dumps({"hookSpecificOutput": out}), 0 + +def hj_respond(cfg, decision, event, wire=None): + """(stdout_text, exit_code) in this entry's dialect for the gate the payload names. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + v = cfg["verdicts"] + if wire is None: + wire = _wire_name(cfg, event.raw or {}) + if wire in v.get("empty_object_events", ()): + return _json.dumps({}), 0 + if wire is None: + wire = v.get("default_wire_event") + if wire is None and v.get("missing_wire") == "reverse_map": + wire = hj_reverse(cfg).get(event.event) + name = wire if v.get("echo") == "payload" else hj_reverse(cfg).get(event.event, "PreToolUse") + gate = v["gates"].get(wire) + if gate is None: + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + return "", 0 + if gate["grammar"] == "G2": + return _g2(v, gate, decision, name) + if gate["grammar"] == "G7": + return _g7(v, decision, wire) + return _g1(v, gate, decision, wire, name) + +_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(): + if key in _WINDOWS_KEYS: + entry[key] = powershell_command(command) + else: + entry[key] = value + return entry + +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + +def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): + rules = [] + for ev in canonical_events: + name = reverse.get(ev) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) + 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 and name not in allow_silent: + entry["failClosed"] = True + hooks.setdefault(name, []).append(entry) + return {"version": 1, "hooks": hooks} + +def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + hooks.setdefault(name, []).append({"command": command}) + extra = hook_entry.get("also_wires", {}).get(ev) + if extra: + hooks.setdefault(extra, []).append({"command": command}) + return {"hooks": hooks} + +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + +def _default_wrapper(cfg, reverse, canonical_events, command, matcher): + hook_entry = cfg["hook_entry"] + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + entry = {"hooks": [_hook_dict(cfg, command)]} + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: + entry["matcher"] = matcher + hooks.setdefault(name, []).append(entry) + if hook_entry.get("group"): + return {hook_entry["group"]: hooks} + return hooks if hook_entry.get("bare") else {"hooks": hooks} + +def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_closed=True): + """The vendor's hooks-config fragment wiring `command` for these canonical events. + + `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the + entry says otherwise; a False installs an observer, not a gate. + """ + hook_entry = cfg["hook_entry"] + reverse = hj_reverse(cfg) + wrapper = hook_entry["wrapper"] + if wrapper == "flat_list": + return _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher) + if wrapper == "cursor": + return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) + if wrapper == "flat_entries": + return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) + return _default_wrapper(cfg, reverse, canonical_events, command, matcher) + + +# ------------------------------------------------------------------------------ +# factory_droid vendor config + engine binding + +AGENT = "factory_droid" + +VENDOR = {'agent': 'factory_droid', 'claims': {'accept_values': {'permission_mode': ('off', 'spec', 'auto-low', 'auto-medium', 'auto-high')}, 'event_key': ('hook_event_name',), 'mode': 'marker', 'notes': "Droid's payload carries Claude Code's keys, so only a value separates it: every Droid event carries permission_mode, and Droid sends only off, spec, auto-low, auto-medium or auto-high there (docs.factory.com/harness/hooks, read 2026-10-11; droid 0.238.0's own hook-input builder maps every autonomy mode to one of those five, defaulting to off). claude_code rejects exactly those values (reject_values), so a payload is claimed by one of the two and never by both. A Droid build that sent a Claude Code permission_mode value would detect as claude_code again, whose deny Droid also reads."}, 'config_format': 'json', 'config_path': '.factory/hooks.json', 'display': 'Factory Droid', 'events': {'PostToolUse': 'post_tool', 'PreCompact': 'pre_compact', 'PreToolUse': 'pre_tool', 'SessionEnd': 'session_end', 'SessionStart': 'session_start', 'Stop': 'stop', 'SubagentStop': 'subagent_stop', 'UserPromptSubmit': 'prompt_submit'}, 'evidence': {'claims': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'vendor-docs', 'date': '2026-10-11', '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_str', 'tool_input.changes[].new_str', 'tool_input.input'), 'cwd': ('cwd',), 'output': ('tool_response',), 'path': ('tool_input.file_path',), 'prompt': ('prompt',), 'session_id': ('session_id',), 'tool': ('tool_name',)}, 'hook_entry': {'bare': True, 'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'tools': {'shell': ('Execute',), 'write': ('Create', 'Edit', 'MultiEdit', 'ApplyPatch')}, 'verdicts': {'answer_events': ('PreToolUse', 'Stop', 'UserPromptSubmit'), 'bare_allow': 'silent', 'context_events': ('SessionStart', 'UserPromptSubmit'), 'context_source': 'context', 'degrade_notes': {'escalate': "Droid's ask does not force a prompt, so this is a block", 'escalate_from_transform': 'Droid merges updatedInput into the original input rather than replacing it, so this is a block', 'transform': 'Droid merges updatedInput into the original input rather than replacing it, so this is a block'}, 'echo': 'reverse_map', 'gates': {'PreToolUse': {'grammar': 'G2', 'honours_escalate': False, 'honours_transform': False}, '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'}, 'vocabulary': ('allow', 'ask', 'block', 'deny'), 'vocabulary_basis': 'verified', 'words': {'block': 'block', 'deny': 'deny'}}} + + +def claims(raw): + return hj_claims(VENDOR, raw) + + +def parse(raw): + return hj_parse(VENDOR, raw) + + +def respond(decision, event): + return hj_respond(VENDOR, decision, event) + + +def hook_config(canonical_events, command, matcher=None): + return hook_entry_config(VENDOR, canonical_events, command, matcher) + + +# ------------------------------------------------------------------------------ +# runtime (agentseam dispatch, specialized to factory_droid) + +_VOUCH_SPEAKS = False +_WARN_SPEAKS = False +_TRANSFORM_EVENTS = frozenset(()) + + +def degrade(decision, event): + """Reduce a decision to what 'factory_droid' can actually honor, honestly. + + This is agentseam.dispatch.degrade(), specialized to one fixed agent so this file needs + no "import agentseam" -- no evidence establishes that an explicit approval word means anything beyond a plain allow here, so vouch degrades to one; see agentseam.allow_semantics. + """ + if decision.outcome == TRANSFORM and event.event not in _TRANSFORM_EVENTS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = TRANSFORM + return Decision.escalate( + decision.reason or "input requires modification before it can run", evidence=evidence + ) + if decision.outcome == VOUCH and not _VOUCH_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = VOUCH + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + if decision.outcome == WARN and not _WARN_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = WARN + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + return decision + + +# >>> 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' + +_CRASH_MARKERS = ('traceback (most recent call last)', 'syntax error', 'syntaxerror', 'unexpected eof') + +TOOL_UNKNOWN = 'unknown' + +ARGV_FALLBACK_ENV = 'CHOCK_ARGV_FALLBACK' + +CAN_ASK_ENV = 'CHOCK_CAN_ASK' + +_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 + +_BASH_PROBE_SECONDS = 10 + +GUARD_BLOCKED = 'blocked' + +GUARD_CLEAN = 'clean' + +GUARD_ASKED = 'asked' + +GUARD_UNCHECKED = 'unchecked' + +GUARD_ERRORED = 'errored' + +VERDICT_DENY = 'deny' + +VERDICT_ESCALATE = 'escalate' + +def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: + """The `--guard ` argument a vendored runtime was invoked with, or None.""" + if '--guard' in argv: + i = argv.index('--guard') + if i + 1 < len(argv): + return _chock_Path(argv[i + 1]) + return None + +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) + env.pop(CAN_ASK_ENV, None) + 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: + 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, 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, 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, tool: str='') -> str: + """`GUARD_BLOCKED` / `GUARD_ASKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + 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, tool: str='', *, can_ask: bool | None=None) -> tuple[str, str]: + """`run_guard`'s verdict plus the guard's own first line, which an ask carries to the user.""" + args, fallback = split_command(command) + if not args: + return (GUARD_UNCHECKED, '') + deadline = engine_deadline() + interpreter = find_interpreter(guard, deadline) + if interpreter is None: + return _no_interpreter(guard, deadline) + try: + env = {**interpreter_env(interpreter), 'CHOCK_RAW_COMMAND': command, 'CHOCK_TOOL': normalize_tool(tool)} + if fallback: + env[ARGV_FALLBACK_ENV] = '1' + env.update({} if can_ask is None else {CAN_ASK_ENV: '1' if can_ask else '0'}) + 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 {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) + return (GUARD_ERRORED, '') + output = ((proc.stderr or '') + (proc.stdout or '')).strip() + first_line = output.splitlines()[0].strip() if output else '' + if proc.returncode == GUARD_VIOLATION: + 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 '') + return (GUARD_BLOCKED, first_line) + if proc.returncode == GUARD_ASK_EXIT: + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + return (GUARD_ASKED, first_line) + if proc.returncode != 0: + print(f'chock: guard exited {proc.returncode}, not checked' + (f': {first_line[:120]}' if first_line else ''), file=sys.stderr) + return (GUARD_ERRORED, '') + return (GUARD_CLEAN, '') + +def 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: + if _chock_os.environ.get(GATE_LOG_ENV) == '0': + return + guard = guard.resolve() + if guard.parent.name != 'implementations': + return + artifact_root = None + for parent in guard.parents: + if (parent / '.chock').is_dir(): + artifact_root = parent / '.chock' + break + if artifact_root is None: + return + 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='', *, can_ask: bool | None=None) -> tuple[str, str] | None: + """Run the guard named on `argv` (`--guard `) against `command`.""" + guard = guard_path_from_argv(argv) + if guard is None: + return None + 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, can_ask=can_ask) + logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} + if verdict in logged: + log_outcome(guard, tool, verdict=logged[verdict]) + if verdict == GUARD_BLOCKED: + return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + if verdict == GUARD_ASKED: + return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') + if verdict == GUARD_ERRORED: + 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' + +_CREATE_TEXT = 'file_text' + +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 created_text(event): + """The whole file a write call carries: the adapter's content, else `tool_input.file_text`; None otherwise.""" + content = getattr(event, 'content', None) + if isinstance(content, str): + return content + text = _edit_call_input(event).get(_CREATE_TEXT) + return text if isinstance(text, str) else None + +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)) + +_PATCH_KEYS = ('command', 'input', 'patch') + +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 if isinstance(tool_input, str) else None + for key in _PATCH_KEYS if isinstance(tool_input, dict) else (): + if tool_input.get(key) is not None: + command = tool_input[key] + break + 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 '')) + +_PARENT = '..' + +_DRIVE_COLON = ':' + +_ROOTS_KEY = 'workspace_roots' + +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 _workspace_root(text): + """A workspace root as a path: `/C:/x` is `C:/x`; anything else as given.""" + if text[:1] == '/' and text[2:3] == _DRIVE_COLON and text[1:2].isalpha() and (text[3:4] in ('', '/', '\\')): + return text[1:] + return text + +def _holds(root, path): + """Whether `path`, absolute or relative, lies under `root`, compared the way `root` is spelled.""" + flavour = _chock_PureWindowsPath if root[1:2] == _DRIVE_COLON else _chock_PurePosixPath + try: + _folded(flavour(root) / str(path)).relative_to(flavour(root)) + except ValueError: + return False + return True + +def workspace_roots(event): + """The folders the client says the agent works in, normalised; [] when it names none.""" + raw = getattr(event, 'raw', None) + roots = raw.get(_ROOTS_KEY) if isinstance(raw, dict) else None + if not isinstance(roots, list): + return [] + return [_workspace_root(root) for root in roots if isinstance(root, str) and root] + +def workspace_root(event): + """The workspace root holding the event's path, else the first one; None when the client names none.""" + roots = workspace_roots(event) + path = getattr(event, 'path', None) + return next((root for root in roots if path and _holds(root, path)), roots[0] if roots else None) + +_SEVERITY = (VERDICT_DENY, VERDICT_ESCALATE, VERDICT_WARN) + +_GIT_DIR = '.git' + +def _repository(folder): + """The repository `folder` is in: the nearest folder holding `.git`, or None outside one.""" + here = _chock_Path(folder).absolute() + return next((f for f in (here, *here.parents) if (f / _GIT_DIR).exists()), None) + +def stop_roots(event, fallback): + """The event's `cwd` and every workspace root inside a repository, the first per repository; else [fallback].""" + by_repository = {} + for named in filter(None, (getattr(event, 'cwd', None), *workspace_roots(event))): + repository = _repository(named) + if repository is not None: + by_repository.setdefault(repository, _chock_Path(named)) + return list(by_repository.values()) or [fallback] + +def per_root(said): + """One (decision, judged files) for each root's own: the most severe verdict, findings under each root's name.""" + if len(said) == 1: + return said[0][1:] + spoken = [(root, decision) for root, decision, _judged in said if decision] + judged = {path: text for _root, _decision, files in said for path, text in files.items()} + if not spoken: + return (None, judged) + verdict = min((decision[0] for _root, decision in spoken), key=_SEVERITY.index) + return ((verdict, '\n'.join((f'In {root}:\n{decision[1]}' for root, decision in spoken))), judged) + +GATE_FLAG = '--gate' + +STOP_FLAG = '--stop' + +_GATE_DEPTH_TO_CHOCK = 3 + +_RUNNER_PARTS = ('bin', 'gate.py') + +_PACKAGED_RUNNER = 'gate.py' + +_GIT = 'git' + +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + +_DELETED = 'D' + +_RENAMED = 'R' + +VERDICT_DENY = 'deny' + +def gate_path_from_argv(argv): + """The `--gate ` argument a vendored runtime was invoked with, or None.""" + if GATE_FLAG in argv: + index = argv.index(GATE_FLAG) + if index + 1 < len(argv): + return _chock_Path(argv[index + 1]) + return None + +def runner_for(gate): + """The 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, 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 = created_text(event) + 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 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: + 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 = [] + for field in fields: + status, path = (field[:2], field[3:]) + 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, 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 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, 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(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)) + return runner_outcome(proc.returncode, proc.stderr) + +_EVENT_ARG = {'pre_tool': 'pre-tool-use', 'stop': 'stop'} + +PRE_TOOL = 'pre_tool' + +def root_for(gate): + """The repository this compiled gate belongs to, or None when the layout is not that.""" + parents = gate.resolve().parents + if len(parents) <= _GATE_DEPTH_TO_CHOCK: + return None + if parents[_GATE_DEPTH_TO_CHOCK - 1].name != 'compiled' or parents[_GATE_DEPTH_TO_CHOCK].name != '.chock': + return None + return parents[_GATE_DEPTH_TO_CHOCK].parent + +def repo_root_for(event, gate): + """The repository under judgement: the compiled layout's root, else the event's cwd, else its workspace root. + + Of several workspace roots, the one holding the written path; this process's cwd only when there are none. + """ + root = root_for(gate) + if root is not None: + return root + cwd = getattr(event, 'cwd', None) or workspace_root(event) + return _chock_Path(cwd) if cwd else _chock_Path.cwd() + +def writes_for(event, gate, deadline=None, why=None, root=None): + """What this event puts under judgement: the call's own text, or what the turn left behind (None: unlisted).""" + root = repo_root_for(event, gate) if root is None else root + if event.event == PRE_TOOL: + return writes_from_event(event, root) + return writes_from_worktree(root, 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, root): + """(decision, judged files): what the compiled gate says about this event in `root`, before a re-entered stop.""" + if not gate.exists(): + return (_missing_gate(gate), {}) + outside = outside_globs(gate) + why = [] + listed = writes_for(event, gate, deadline, why, root) + 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: + return None + deadline = engine_deadline() + if event.event == PRE_TOOL: + return _gate_says(gate, event, name, deadline, repo_root_for(event, gate))[0] + roots = [repo_root_for(event, gate)] if root_for(gate) else stop_roots(event, repo_root_for(event, gate)) + decision, judged = per_root([(root, *_gate_says(gate, event, name, deadline, root)) for root in roots]) + return settle_stop(event, roots[0], 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 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) +#: Per wire event mapped to pre_tool: whether the client shows a guard's ask as a prompt. +_CHOCK_CAN_ASK = {'PreToolUse': False} +_CHOCK_EVENT_KEYS = ('hook_event_name', 'hookEventName', 'agent_action_name') + + +def _chock_wire_name(raw): + names = (raw.get(key) for key in _CHOCK_EVENT_KEYS) if isinstance(raw, dict) else () + return next((name for name in names if isinstance(name, str)), None) + + +def _chock_can_ask(raw): + # An event this bundle cannot name, or one the vendor data does not list, cannot prompt. + return _CHOCK_CAN_ASK.get(_chock_wire_name(raw), False) + + +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: + verdict = evaluate(sys.argv[1:], event.command, event.tool or "", can_ask=_chock_can_ask(event.raw)) + if verdict is not None: + outcome, reason = verdict + return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + gated = evaluate_gate(sys.argv[1:], event) + if gated is not None: + return _spoken(gated) + called = evaluate_tool_call(sys.argv[1:], event) + if called is not None: + return _spoken(called) + return None + + +def handle(event): + # A door that cannot decide refuses. An exception escaping here would exit the hook + # with a traceback, which every client reads as a non-blocking error: fail-open, with + # the reason on a stderr nobody watches. The refusal carries the reason instead. + try: + return _judge(event) + except Exception as exc: # noqa: BLE001 -- any failure here must refuse, never fall through + return Decision.deny( + "chock could not check this call (%s: %s). Refusing rather than reporting an " + "allow it never established." % (type(exc).__name__, exc) + ) +# <<< agentseam handler <<< + + +def _coerce(result): + if result is None: + return Decision.allow() + if isinstance(result, Decision): + return result + raise TypeError("handle() must return a Decision or None, got %r" % (type(result),)) + + +def _read_payload(stream): + # Decode bytes ourselves rather than trust the platform locale -- a BOM'd or non-UTF-8 + # stdin must not silently disable the gate. See agentseam.dispatch for the incident + # this guards: a Windows console's cp1252 layer turning a UTF-8 BOM into three bytes + # json cannot parse, with the hook allowing everything while claiming enforcement. + buffer = getattr(stream, "buffer", None) + if buffer is not None: + return buffer.read().decode("utf-8-sig", errors="replace") + return stream.read().lstrip("\ufeff") + + +def _emit(out, text): + buffer = getattr(out, "buffer", None) + if buffer is not None: + buffer.write(text.encode("utf-8")) + buffer.flush() + else: + out.write(text) + out.flush() + + +def _report(text): + # Best effort, never raising: a diagnostic must not pre-empt the refusal it accompanies. + # A hook's stderr is whatever the host gave it -- closed (then sys.stderr is None, and + # the traceback module's default print would land on STDOUT, inside the verdict), a + # console code page that cannot hold the payload text quoted in the exception, a pipe + # nobody reads. + try: + sys.stderr.write(text) + sys.stderr.flush() + except Exception: + 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) + if event.event == UNKNOWN: + # A vendor event this adapter has no mapping for. handle() is not called: it + # reasons about the canonical vocabulary, and handing it something outside that + # vocabulary invites a decision made on a false premise. + return "", 0 + try: + 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 + # failure is printed for the operator and the gate answers deny in 'factory_droid''s + # own dialect. Only the failure's class is named to the host: its message may quote + # the very payload content the policy was inspecting. + _report(traceback.format_exc()) + decision = Decision.deny( + "policy handler failed (%s); refusing rather than allowing what it could not judge" + % sys.exc_info()[0].__name__ + ) + return respond(degrade(decision, event), event) + + +def main(stdin=None, stdout=None, exit=True): + """Read one payload from stdin, dispatch, emit the 'factory_droid' response, exit.""" + stream = stdin if stdin is not None else sys.stdin + out = stdout if stdout is not None else sys.stdout + try: + raw = json.loads(_read_payload(stream)) + except Exception: + raw = None + if not isinstance(raw, dict): + text, code = _chock_unreadable() + if text: + _emit(out, text) + if exit: + sys.exit(code) + return code + try: + text, code = _decide(raw) + except Exception: + # Past the handler, which _decide() already answers for: a fault in this file on a + # payload it did decode. With no Event to answer in dialect, the one refusal left is + # the host's blocking exit code, and nothing on stdout to be misread as a verdict. + _report(traceback.format_exc()) + text, code = "", 2 + if text: + _emit(out, text) + if exit: + sys.exit(code) + return code + + +if __name__ == "__main__": # pragma: no cover + main() + diff --git a/tests/fixtures/runtime_goldens/gemini_cli.py b/tests/fixtures/runtime_goldens/gemini_cli.py index 1afd4d41..98ecf634 100644 --- a/tests/fixtures/runtime_goldens/gemini_cli.py +++ b/tests/fixtures/runtime_goldens/gemini_cli.py @@ -29,6 +29,7 @@ from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings +import zlib as _zlib # ------------------------------------------------------------------------------ # contract (agentseam 0.3.5) @@ -235,11 +236,31 @@ def tool_input_of(raw): "effort", ) +_MCP_PREFIX = "mcp_" + +_CLAUDE_MCP_PREFIX = "mcp__" + def looks_like_claude_code(raw): """True when the payload carries a field only Claude Code has been seen to send.""" return isinstance(raw, dict) and any(marker in raw for marker in OBSERVED_MARKERS) -PROBES = {"looks_like_claude_code": looks_like_claude_code} +def names_a_single_underscore_mcp_tool(raw): + """True when tool_name is an MCP tool ID in a spelling Claude Code never sends.""" + name = raw.get("tool_name") if isinstance(raw, dict) else None + return isinstance(name, str) and name.startswith(_MCP_PREFIX) and not name.startswith(_CLAUDE_MCP_PREFIX) + +def event_keys_agree(raw): + """True when hookEventName names hook_event_name's event: the same text, or Grok Build's snake_case twin.""" + snake, pascal = (raw.get("hookEventName"), raw.get("hook_event_name")) if isinstance(raw, dict) else (None, None) + if not isinstance(pascal, str) or not isinstance(snake, str): + return False + return snake == pascal or (snake.islower() and snake.replace("_", "") == pascal.lower()) + +PROBES = { + "looks_like_claude_code": looks_like_claude_code, + "names_a_single_underscore_mcp_tool": names_a_single_underscore_mcp_tool, + "event_keys_agree": event_keys_agree, +} UNREADABLE_NAME = "" @@ -265,12 +286,22 @@ def _rejected_by_markers(c, raw): for marker in c.get("reject_markers", ()): if marker in raw: return True + for key, values in c.get("reject_values", {}).items(): + if key in raw and raw[key] in values: + return True for probe, markers in c.get("reject_markers_unless_probe", {}).items(): if any(marker in raw for marker in markers) and not PROBES[probe](raw): return True return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) +def _accepted_by_values(c, raw): + """True unless the entry names accepted values and the payload carries none of them.""" + values = c.get("accept_values") + return not values or any(key in raw and raw[key] in allowed for key, allowed in values.items()) + def _accepted_by_markers(c, raw, name): + if not _accepted_by_values(c, raw): + return False accept = c.get("accept_markers", ()) if not accept or any(marker in raw for marker in accept): return True @@ -309,12 +340,25 @@ def _segment(node, part): return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None return node.get(part) if isinstance(node, dict) else None +def _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + def _walk(node, path): - """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None if "[]." in path: head, sub = path.split("[].", 1) - items = _walk(node, head) - if not isinstance(items, (list, tuple)): + items = _list_at(node, head) + if items is None: return None joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) return joined or None @@ -344,7 +388,7 @@ def _tool_input_raw(cfg, raw): if not isinstance(raw, dict): return None for key in cfg["fields"].get("tool_input", ("tool_input",)): - value = raw.get(key) + value = _walk(raw, key) if value is not None: return value return None @@ -535,6 +579,8 @@ def hj_respond(cfg, decision, event, wire=None): return "", 0 if gate["grammar"] == "G2": return _g2(v, gate, decision, name) + if gate["grammar"] == "G7": + return _g7(v, decision, wire) return _g1(v, gate, decision, wire, name) _WINDOWS_KEYS = ("commandWindows", "windows") @@ -550,16 +596,25 @@ def _hook_dict(cfg, command): entry[key] = value return entry +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): rules = [] for ev in canonical_events: name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: - rule["matcher"] = matcher - rules.append(rule) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) return rules def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): @@ -588,6 +643,10 @@ def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): hooks.setdefault(extra, []).append({"command": command}) return {"hooks": hooks} +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] hooks = {} @@ -618,6 +677,8 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) if wrapper == "flat_entries": return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) return _default_wrapper(cfg, reverse, canonical_events, command, matcher) diff --git a/tests/fixtures/runtime_goldens/goose.py b/tests/fixtures/runtime_goldens/goose.py new file mode 100644 index 00000000..4903a7c3 --- /dev/null +++ b/tests/fixtures/runtime_goldens/goose.py @@ -0,0 +1,2407 @@ +# Generated by agentseam 0.3.5 -- bundle("goose"). 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. +# +# Self-contained: stdlib only, no "import agentseam" anywhere in this file. Regenerate +# with `agentseam.bundler.bundle("goose")` (same agentseam version -> identical bytes, +# except your own edits inside the handler block) rather than patching this by hand. +# https://github.com/open-coder-ai/agentseam + +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 +import zlib as _zlib + +# ------------------------------------------------------------------------------ +# contract (agentseam 0.3.5) + +"""Canonical event vocabulary, normalized envelope, and decision type.""" + + + +SESSION_START = "session_start" +SESSION_END = "session_end" +PROMPT_SUBMIT = "prompt_submit" +PRE_TOOL = "pre_tool" +POST_TOOL = "post_tool" +TOOL_FAILURE = "tool_failure" +PRE_COMPACT = "pre_compact" +STOP = "stop" +SUBAGENT_START = "subagent_start" +SUBAGENT_STOP = "subagent_stop" +INSTRUCTIONS_LOADED = "instructions_loaded" +FILE_CHANGED = "file_changed" + +UNKNOWN = "unknown" + +EVENTS = ( + SESSION_START, + SESSION_END, + PROMPT_SUBMIT, + PRE_TOOL, + POST_TOOL, + TOOL_FAILURE, + PRE_COMPACT, + STOP, + SUBAGENT_START, + SUBAGENT_STOP, + INSTRUCTIONS_LOADED, + FILE_CHANGED, +) + + +class Event: + """One agent lifecycle event, normalized.""" + + __slots__ = ( + "agent", + "command", + "content", + "cwd", + "event", + "output", + "path", + "prompt", + "raw", + "session_id", + "tool", + "tool_use_id", + ) + + def __init__( + self, + agent, + event, + *, + tool=None, + command=None, + path=None, + content=None, + output=None, + prompt=None, + session_id=None, + tool_use_id=None, + cwd=None, + raw=None, + ): + self.agent = agent + self.event = event + self.tool = tool + self.command = command + self.path = path + self.content = content + self.output = output + self.prompt = prompt + self.session_id = session_id + self.tool_use_id = tool_use_id + self.cwd = cwd + self.raw = raw if raw is not None else {} + + def __repr__(self): # pragma: no cover - debugging aid + return "Event(%s/%s tool=%r path=%r)" % (self.agent, self.event, self.tool, self.path) + + +ALLOW = "allow" +DENY = "deny" +ESCALATE = "escalate" +TRANSFORM = "transform" +WARN = "warn" +VOUCH = "vouch" + +# Pre-ACS-alignment names. Same strings as their ACS-named counterparts, so every existing +# `is`/`==` comparison against the old constant keeps working untouched. +ASK = ESCALATE +REWRITE = TRANSFORM + +_CANONICAL_OUTCOMES = (ALLOW, DENY, ESCALATE, TRANSFORM, WARN, VOUCH) + +# The literal spellings a caller might still pass to Decision(outcome, ...) directly, mapped +# to the value that now backs them. Only needed for the raw-string constructor path -- +# Decision.ask()/.rewrite() below build the canonical outcome themselves. +_LEGACY_SPELLING = {"ask": ESCALATE, "rewrite": TRANSFORM} + + +class Decision: + """What a handler wants to happen. Adapters translate this to vendor dialect.""" + + __slots__ = ("context", "evidence", "outcome", "reason", "updated_input") + + #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). + DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) + + def __init__(self, outcome, reason=None, updated_input=None, evidence=None, context=None): + outcome = _LEGACY_SPELLING.get(outcome, outcome) + if outcome not in _CANONICAL_OUTCOMES: + raise ValueError("unknown outcome: %r" % (outcome,)) + self.outcome = outcome + self.reason = reason + self.updated_input = updated_input + self.evidence = evidence or {} + self.context = context + + @classmethod + def allow(cls, reason=None, evidence=None, context=None): + return cls(ALLOW, reason, evidence=evidence, context=context) + + @classmethod + def deny(cls, reason, evidence=None, context=None): + return cls(DENY, reason, evidence=evidence, context=context) + + @classmethod + def escalate(cls, reason, evidence=None, context=None): + """Defer the action to the host's own approval path (ACS `escalate`).""" + return cls(ESCALATE, reason, evidence=evidence, context=context) + + @classmethod + def ask(cls, reason, evidence=None, context=None): + """Deprecated alias of escalate() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.ask() is deprecated; use Decision.escalate()", DeprecationWarning, stacklevel=2) + return cls.escalate(reason, evidence=evidence, context=context) + + @classmethod + def transform(cls, updated_input, reason=None, evidence=None, context=None): + """Replace the tool input wholesale (ACS `transform`, at whole-value granularity).""" + return cls(TRANSFORM, reason, updated_input=updated_input, evidence=evidence, context=context) + + @classmethod + def rewrite(cls, updated_input, reason=None, evidence=None, context=None): + """Deprecated alias of transform() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.rewrite() is deprecated; use Decision.transform()", DeprecationWarning, stacklevel=2) + return cls.transform(updated_input, reason, evidence=evidence, context=context) + + @classmethod + def warn(cls, reason=None, evidence=None, context=None): + """Permit the action with no change, recording a warning (ACS `warn`).""" + return cls(WARN, reason, evidence=evidence, context=context) + + @classmethod + def vouch(cls, reason=None, evidence=None, context=None): + return cls(VOUCH, reason, evidence=evidence, context=context) + + def __repr__(self): # pragma: no cover - debugging aid + return "Decision(%s, %r)" % (self.outcome, self.reason) + + +def degraded_from(decision): + """What this decision was before the dispatcher reduced it, or None.""" + return (decision.evidence or {}).get("degraded_from") + + +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.lstrip()[:1] == "{": + try: + parsed = _json.loads(raw) + except (ValueError, RecursionError): + # JSONDecodeError is a ValueError; a string nested past the interpreter's + # limit raises RecursionError instead, and neither is an input to crash on. + return {} + if isinstance(parsed, dict): + return parsed + return {} + + +# ------------------------------------------------------------------------------ +# flat_decision family engine (trimmed to what this entry uses) + +UNREADABLE_NAME = "" + +def wire_name_of(raw, key): + """The event name under `key`: text, None when absent, UNREADABLE_NAME when not text. + + Total over anything `json.loads` can return: a payload that is not an object names no + event, and a name that is not a string is not one any adapter can map. + """ + name = raw.get(key) if isinstance(raw, dict) else None + if name is None or isinstance(name, str): + return name + return UNREADABLE_NAME + +def _wire_name(cfg, raw): + for key in cfg["claims"].get("event_key", ()): + name = wire_name_of(raw, key) + if name is not None: + return name + return None + +def _rejected_by_markers(c, raw): + for marker in c.get("reject_markers", ()): + if marker in raw: + return True + for key, values in c.get("reject_values", {}).items(): + if key in raw and raw[key] in values: + return True + for probe, markers in c.get("reject_markers_unless_probe", {}).items(): + if any(marker in raw for marker in markers) and not PROBES[probe](raw): + return True + return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) + +def _accepted_by_values(c, raw): + """True unless the entry names accepted values and the payload carries none of them.""" + values = c.get("accept_values") + return not values or any(key in raw and raw[key] in allowed for key, allowed in values.items()) + +def _accepted_by_markers(c, raw, name): + if not _accepted_by_values(c, raw): + return False + accept = c.get("accept_markers", ()) + if not accept or any(marker in raw for marker in accept): + return True + return any( + name == event_name and all(key in raw for key in required) + for event_name, required in c.get("accept_when_all", {}).items() + ) + +def _disqualified(cfg, c, raw, name): + """Every reason an entry declines a payload once its wire event name is known.""" + if name not in cfg["events"] and not (c.get("accept_any_name") and isinstance(name, str)): + return True + if "client_types" in c and raw.get("client_type") not in c["client_types"]: + return True + return _rejected_by_markers(c, raw) + +def hj_claims(cfg, raw): + """True when this payload matches the entry's marker discipline.""" + if not isinstance(raw, dict): + return False + c = cfg["claims"] + name = _wire_name(cfg, raw) + # A positive self-identification beats a shared event name, accept_names included. + if raw.get("client_type") in c.get("reject_client_types", ()): + return False + if name in c.get("accept_names", ()): + return True + return not _disqualified(cfg, c, raw, name) and _accepted_by_markers(c, raw, name) + +def _segment(node, part): + """One path segment: a dict key, or `key[N]` indexing the list under it.""" + if part.endswith("]") and "[" in part: + key, _, index = part[:-1].partition("[") + items = node.get(key) if isinstance(node, dict) else None + i = int(index) + return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None + return node.get(part) if isinstance(node, dict) else None + +def _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + +def _walk(node, path): + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None + if "[]." in path: + head, sub = path.split("[].", 1) + items = _list_at(node, head) + if items is None: + return None + joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) + return joined or None + for part in path.split("."): + node = _segment(node, part) + if node is None: + return None + return node + +def _lookup(raw, ti, key): + """One config key: a `tool_input.` path walks the decoded tool input, else the payload.""" + if key.startswith("tool_input."): + return _walk(ti, key[len("tool_input.") :]) + return _walk(raw, key) + +def _field(raw, ti, chain): + value = None + for key in chain: + if value: + break + value = value or _lookup(raw, ti, key) + return value + +_FIELD_META = ("tool_input", "content_only_for_write_tools", "stringify") + +def _tool_input_raw(cfg, raw): + if not isinstance(raw, dict): + return None + for key in cfg["fields"].get("tool_input", ("tool_input",)): + value = _walk(raw, key) + if value is not None: + return value + return None + +def _canonical_of(cfg, name): + """The canonical event for one wire name; an entry with no `events` at all (antigravity, + whose payloads never carry one) maps the shape-inferred name back through `wire_events`.""" + events = cfg["events"] + if not events: + return {wire: canonical for canonical, wire in cfg.get("wire_events", {}).items()}.get(name, UNKNOWN) + return events.get(name, UNKNOWN) + +def hj_parse(cfg, raw, wire=None): + """Normalise one payload along the entry's ordered field-fallback chains. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + ti = tool_input_of(_tool_input_raw(cfg, raw)) + fields = {name: _field(raw, ti, chain) for name, chain in cfg["fields"].items() if name not in _FIELD_META} + if cfg["fields"].get("content_only_for_write_tools") and fields.get("tool") not in cfg["tools"].get("write", ()): + fields["content"] = None + if isinstance(fields.get("output"), (dict, list)): + fields["output"] = _json.dumps(fields["output"]) + for name in cfg["fields"].get("stringify", ()): + if fields.get(name) is not None: + fields[name] = str(fields[name]) + return Event( + cfg["agent"], + _canonical_of(cfg, wire if wire is not None else _wire_name(cfg, raw)), + tool=fields.get("tool"), + command=fields.get("command"), + path=fields.get("path"), + content=fields.get("content"), + output=fields.get("output"), + prompt=fields.get("prompt"), + session_id=fields.get("session_id"), + tool_use_id=fields.get("tool_use_id"), + cwd=fields.get("cwd"), + raw=raw, + ) + +_ESCALATE_FROM_TRANSFORM = "escalate_from_transform" + +_TRANSFORM_MISSING_INPUT = "transform_missing_input" + +def hj_reverse(cfg): + """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides. + + One wire name per canonical event, which is a real limit and not an oversight (R3, gap 4). + Cursor is where it bites: `pre_tool` pins to `preToolUse`, and Cursor honours `ask` only at + `beforeShellExecution` / `beforeMCPExecution`. So installing at `pre_tool` forecloses `ask` + before dispatch is ever reached -- the runtime degrade to `deny` is honest about it, but the + install already chose. Deny-style policies, which is all that ships today, are unaffected. + + The day an ask-style Cursor policy exists, this map has to select by decision dialect + (deny -> `preToolUse`, ask -> `beforeShellExecution`) and become one-to-many. Recorded here + rather than built, so the resolution is not re-litigated from scratch; the three facts it + rests on are pinned in tests/test_cursor_ask_dialect.py. + """ + reverse = {} + for name, canonical in cfg["events"].items(): + if canonical != UNKNOWN: + reverse[canonical] = name + reverse.update(cfg.get("wire_events", {})) + return reverse + +def _context_value(v, decision): + if v.get("context_source") == "context": + return decision.context + if v.get("context_source") == "reason": + return decision.reason + return None + +def _context_body(name, value): + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "additionalContext": value}}), 0 + +def _note_for(v, decision, at_gate, missing_input): + notes = v.get("degrade_notes", {}) + if decision.outcome == ESCALATE: + if degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in notes: + return notes[_ESCALATE_FROM_TRANSFORM] + if at_gate and "escalate_gate" in notes: + return notes["escalate_gate"] + return notes.get("escalate") + if decision.outcome == TRANSFORM: + if missing_input and _TRANSFORM_MISSING_INPUT in notes: + return notes[_TRANSFORM_MISSING_INPUT] + return notes.get("transform") + return None + +def _default_for(v, decision, at_gate, wire=None): + gate_defaults = v.get("gate_reason_defaults", {}) + if wire in gate_defaults: + return gate_defaults[wire] + defaults = v.get("reason_defaults", {}) + key = {DENY: "deny", ESCALATE: "escalate", TRANSFORM: "transform"}.get(decision.outcome, "deny") + if at_gate and key + "_gate" in defaults: + return defaults[key + "_gate"] + return defaults.get(key, "blocked by policy") + +def _refusal_text(v, decision, at_gate, wire=None): + note = _note_for(v, decision, at_gate, decision.updated_input is None) + default = _default_for(v, decision, at_gate, wire) + if note and "%s" in note: + # A template note fills (the reason or its default, the wire event name) itself. + return note % (decision.reason or default, wire) + reason = decision.reason + if v.get("note_style") == "suffix": + reason = reason or default + return "%s (%s)" % (reason, note) if note else reason + text = "%s (%s)" % (reason, note) if reason and note else (note or reason) + return text or default + +def _g1_allow(v, decision, wire, name, words): + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + if wire in v.get("allow_silent_events", ()): + return "", 0 + if "allow" in words: + out = {"decision": words["allow"]} + if v.get("allow_context_key") and decision.outcome == ALLOW and value: + out[v["allow_context_key"]] = value + return _json.dumps(out), 0 + return "", 0 + +def _g1_transform(v, decision, name, words): + """None when the transform isn't representable here; `_g1` falls through to the block path.""" + if v.get("transform_grammar") == "hook_specific_tool_input": + return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0 + if decision.updated_input is None: + return None + if v.get("transform_grammar") == "top_level_updated_input": + out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input} + if decision.reason: + out["reason"] = decision.reason + return _json.dumps(out), 0 + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}}), 0 + +def _g1(v, gate, decision, wire, name): + """Block dialect: a top-level decision word, or silence/context where nothing is read.""" + words = dict(v.get("words", {})) + words.update(v.get("words_at", {}).get(wire, {})) + at_context_event = wire in v.get("context_events", ()) + if decision.outcome in (ALLOW, VOUCH, WARN): + return _g1_allow(v, decision, wire, name, words) + if decision.outcome == TRANSFORM and gate["honours_transform"]: + transformed = _g1_transform(v, decision, name, words) + if transformed is not None: + return transformed + if ( + decision.outcome == ESCALATE + and gate["honours_escalate"] + and "escalate" in words + # An escalate the dispatcher degraded a transform into is a block where the entry + # names that degradation (antigravity): prompting would offer the unmodified call. + and not (degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in v.get("degrade_notes", {})) + ): + reason = decision.reason or _default_for(v, decision, at_gate=True, wire=wire) + return _json.dumps({"decision": words["escalate"], "reason": reason}), 0 + out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, at_gate=False, wire=wire)} + if at_context_event and v.get("context_source") == "context" and decision.context: + out["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": decision.context} + return _json.dumps(out), 0 + +def hj_respond(cfg, decision, event, wire=None): + """(stdout_text, exit_code) in this entry's dialect for the gate the payload names. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + v = cfg["verdicts"] + if wire is None: + wire = _wire_name(cfg, event.raw or {}) + if wire in v.get("empty_object_events", ()): + return _json.dumps({}), 0 + if wire is None: + wire = v.get("default_wire_event") + if wire is None and v.get("missing_wire") == "reverse_map": + wire = hj_reverse(cfg).get(event.event) + name = wire if v.get("echo") == "payload" else hj_reverse(cfg).get(event.event, "PreToolUse") + gate = v["gates"].get(wire) + if gate is None: + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + return "", 0 + if gate["grammar"] == "G2": + return _g2(v, gate, decision, name) + if gate["grammar"] == "G7": + return _g7(v, decision, wire) + return _g1(v, gate, decision, wire, name) + +_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(): + if key in _WINDOWS_KEYS: + entry[key] = powershell_command(command) + else: + entry[key] = value + return entry + +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + +def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): + rules = [] + for ev in canonical_events: + name = reverse.get(ev) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) + 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 and name not in allow_silent: + entry["failClosed"] = True + hooks.setdefault(name, []).append(entry) + return {"version": 1, "hooks": hooks} + +def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + hooks.setdefault(name, []).append({"command": command}) + extra = hook_entry.get("also_wires", {}).get(ev) + if extra: + hooks.setdefault(extra, []).append({"command": command}) + return {"hooks": hooks} + +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + +def _default_wrapper(cfg, reverse, canonical_events, command, matcher): + hook_entry = cfg["hook_entry"] + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + entry = {"hooks": [_hook_dict(cfg, command)]} + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: + entry["matcher"] = matcher + hooks.setdefault(name, []).append(entry) + if hook_entry.get("group"): + return {hook_entry["group"]: hooks} + return hooks if hook_entry.get("bare") else {"hooks": hooks} + +def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_closed=True): + """The vendor's hooks-config fragment wiring `command` for these canonical events. + + `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the + entry says otherwise; a False installs an observer, not a gate. + """ + hook_entry = cfg["hook_entry"] + reverse = hj_reverse(cfg) + wrapper = hook_entry["wrapper"] + if wrapper == "flat_list": + return _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher) + if wrapper == "cursor": + return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) + if wrapper == "flat_entries": + return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) + return _default_wrapper(cfg, reverse, canonical_events, command, matcher) + + +# ------------------------------------------------------------------------------ +# goose vendor config + engine binding + +AGENT = "goose" + +VENDOR = {'agent': 'goose', 'claims': {'event_key': ('event',), 'mode': 'marker', 'notes': "goose names the event under `event`, a key no other adapter's payload uses (every other marker family reads hook_event_name or hookEventName), so the event key alone separates it."}, 'config_format': 'json', 'config_path': '.agents/plugins/agentseam/hooks/hooks.json', 'display': 'goose', 'events': {'AfterFileEdit': 'file_changed', 'AfterShellExecution': 'unknown', 'BeforeReadFile': 'unknown', 'BeforeShellExecution': 'unknown', 'PostToolUse': 'post_tool', 'PostToolUseFailure': 'tool_failure', 'PreToolUse': 'pre_tool', 'PreToolUseResult': 'unknown', 'SessionEnd': 'session_end', 'SessionStart': 'session_start', 'Stop': 'stop', 'UserPromptSubmit': 'prompt_submit'}, 'evidence': {'claims': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'vendor-docs', 'date': '2026-10-11', '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.after'), 'cwd': ('working_dir',), 'path': ('tool_input.path',), 'prompt': ('message',), 'session_id': ('session_id',), 'tool': ('tool_name',), 'tool_use_id': ('tool_call_id',)}, 'hook_entry': {'entry_extra': {'on_failure': 'block'}, 'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'tools': {'shell': ('shell',), 'write': ('write', 'edit')}, 'verdicts': {'answer_events': ('PreToolUse', 'Stop'), 'bare_allow': 'silent', 'degrade_notes': {'escalate': 'goose cannot prompt for confirmation', 'escalate_from_transform': 'goose cannot modify a tool call', 'transform': 'goose cannot modify a tool call'}, 'gates': {'PreToolUse': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'Stop': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'because', 'vocabulary': ('allow', 'block'), 'vocabulary_basis': 'verified', 'words': {'block': 'block'}}} + + +def claims(raw): + return hj_claims(VENDOR, raw) + + +def parse(raw): + return hj_parse(VENDOR, raw) + + +def respond(decision, event): + return hj_respond(VENDOR, decision, event) + + +def hook_config(canonical_events, command, matcher=None): + return hook_entry_config(VENDOR, canonical_events, command, matcher) + + +# ------------------------------------------------------------------------------ +# runtime (agentseam dispatch, specialized to goose) + +_VOUCH_SPEAKS = False +_WARN_SPEAKS = False +_TRANSFORM_EVENTS = frozenset(()) + + +def degrade(decision, event): + """Reduce a decision to what 'goose' can actually honor, honestly. + + This is agentseam.dispatch.degrade(), specialized to one fixed agent so this file needs + no "import agentseam" -- no evidence establishes that an explicit approval word means anything beyond a plain allow here, so vouch degrades to one; see agentseam.allow_semantics. + """ + if decision.outcome == TRANSFORM and event.event not in _TRANSFORM_EVENTS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = TRANSFORM + return Decision.escalate( + decision.reason or "input requires modification before it can run", evidence=evidence + ) + if decision.outcome == VOUCH and not _VOUCH_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = VOUCH + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + if decision.outcome == WARN and not _WARN_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = WARN + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + return decision + + +# >>> 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' + +_CRASH_MARKERS = ('traceback (most recent call last)', 'syntax error', 'syntaxerror', 'unexpected eof') + +TOOL_UNKNOWN = 'unknown' + +ARGV_FALLBACK_ENV = 'CHOCK_ARGV_FALLBACK' + +CAN_ASK_ENV = 'CHOCK_CAN_ASK' + +_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 + +_BASH_PROBE_SECONDS = 10 + +GUARD_BLOCKED = 'blocked' + +GUARD_CLEAN = 'clean' + +GUARD_ASKED = 'asked' + +GUARD_UNCHECKED = 'unchecked' + +GUARD_ERRORED = 'errored' + +VERDICT_DENY = 'deny' + +VERDICT_ESCALATE = 'escalate' + +def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: + """The `--guard ` argument a vendored runtime was invoked with, or None.""" + if '--guard' in argv: + i = argv.index('--guard') + if i + 1 < len(argv): + return _chock_Path(argv[i + 1]) + return None + +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) + env.pop(CAN_ASK_ENV, None) + 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: + 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, 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, 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, tool: str='') -> str: + """`GUARD_BLOCKED` / `GUARD_ASKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + 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, tool: str='', *, can_ask: bool | None=None) -> tuple[str, str]: + """`run_guard`'s verdict plus the guard's own first line, which an ask carries to the user.""" + args, fallback = split_command(command) + if not args: + return (GUARD_UNCHECKED, '') + deadline = engine_deadline() + interpreter = find_interpreter(guard, deadline) + if interpreter is None: + return _no_interpreter(guard, deadline) + try: + env = {**interpreter_env(interpreter), 'CHOCK_RAW_COMMAND': command, 'CHOCK_TOOL': normalize_tool(tool)} + if fallback: + env[ARGV_FALLBACK_ENV] = '1' + env.update({} if can_ask is None else {CAN_ASK_ENV: '1' if can_ask else '0'}) + 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 {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) + return (GUARD_ERRORED, '') + output = ((proc.stderr or '') + (proc.stdout or '')).strip() + first_line = output.splitlines()[0].strip() if output else '' + if proc.returncode == GUARD_VIOLATION: + 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 '') + return (GUARD_BLOCKED, first_line) + if proc.returncode == GUARD_ASK_EXIT: + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + return (GUARD_ASKED, first_line) + if proc.returncode != 0: + print(f'chock: guard exited {proc.returncode}, not checked' + (f': {first_line[:120]}' if first_line else ''), file=sys.stderr) + return (GUARD_ERRORED, '') + return (GUARD_CLEAN, '') + +def 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: + if _chock_os.environ.get(GATE_LOG_ENV) == '0': + return + guard = guard.resolve() + if guard.parent.name != 'implementations': + return + artifact_root = None + for parent in guard.parents: + if (parent / '.chock').is_dir(): + artifact_root = parent / '.chock' + break + if artifact_root is None: + return + 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='', *, can_ask: bool | None=None) -> tuple[str, str] | None: + """Run the guard named on `argv` (`--guard `) against `command`.""" + guard = guard_path_from_argv(argv) + if guard is None: + return None + 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, can_ask=can_ask) + logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} + if verdict in logged: + log_outcome(guard, tool, verdict=logged[verdict]) + if verdict == GUARD_BLOCKED: + return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + if verdict == GUARD_ASKED: + return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') + if verdict == GUARD_ERRORED: + 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' + +_CREATE_TEXT = 'file_text' + +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 created_text(event): + """The whole file a write call carries: the adapter's content, else `tool_input.file_text`; None otherwise.""" + content = getattr(event, 'content', None) + if isinstance(content, str): + return content + text = _edit_call_input(event).get(_CREATE_TEXT) + return text if isinstance(text, str) else None + +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)) + +_PATCH_KEYS = ('command', 'input', 'patch') + +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 if isinstance(tool_input, str) else None + for key in _PATCH_KEYS if isinstance(tool_input, dict) else (): + if tool_input.get(key) is not None: + command = tool_input[key] + break + 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 '')) + +_PARENT = '..' + +_DRIVE_COLON = ':' + +_ROOTS_KEY = 'workspace_roots' + +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 _workspace_root(text): + """A workspace root as a path: `/C:/x` is `C:/x`; anything else as given.""" + if text[:1] == '/' and text[2:3] == _DRIVE_COLON and text[1:2].isalpha() and (text[3:4] in ('', '/', '\\')): + return text[1:] + return text + +def _holds(root, path): + """Whether `path`, absolute or relative, lies under `root`, compared the way `root` is spelled.""" + flavour = _chock_PureWindowsPath if root[1:2] == _DRIVE_COLON else _chock_PurePosixPath + try: + _folded(flavour(root) / str(path)).relative_to(flavour(root)) + except ValueError: + return False + return True + +def workspace_roots(event): + """The folders the client says the agent works in, normalised; [] when it names none.""" + raw = getattr(event, 'raw', None) + roots = raw.get(_ROOTS_KEY) if isinstance(raw, dict) else None + if not isinstance(roots, list): + return [] + return [_workspace_root(root) for root in roots if isinstance(root, str) and root] + +def workspace_root(event): + """The workspace root holding the event's path, else the first one; None when the client names none.""" + roots = workspace_roots(event) + path = getattr(event, 'path', None) + return next((root for root in roots if path and _holds(root, path)), roots[0] if roots else None) + +_SEVERITY = (VERDICT_DENY, VERDICT_ESCALATE, VERDICT_WARN) + +_GIT_DIR = '.git' + +def _repository(folder): + """The repository `folder` is in: the nearest folder holding `.git`, or None outside one.""" + here = _chock_Path(folder).absolute() + return next((f for f in (here, *here.parents) if (f / _GIT_DIR).exists()), None) + +def stop_roots(event, fallback): + """The event's `cwd` and every workspace root inside a repository, the first per repository; else [fallback].""" + by_repository = {} + for named in filter(None, (getattr(event, 'cwd', None), *workspace_roots(event))): + repository = _repository(named) + if repository is not None: + by_repository.setdefault(repository, _chock_Path(named)) + return list(by_repository.values()) or [fallback] + +def per_root(said): + """One (decision, judged files) for each root's own: the most severe verdict, findings under each root's name.""" + if len(said) == 1: + return said[0][1:] + spoken = [(root, decision) for root, decision, _judged in said if decision] + judged = {path: text for _root, _decision, files in said for path, text in files.items()} + if not spoken: + return (None, judged) + verdict = min((decision[0] for _root, decision in spoken), key=_SEVERITY.index) + return ((verdict, '\n'.join((f'In {root}:\n{decision[1]}' for root, decision in spoken))), judged) + +GATE_FLAG = '--gate' + +STOP_FLAG = '--stop' + +_GATE_DEPTH_TO_CHOCK = 3 + +_RUNNER_PARTS = ('bin', 'gate.py') + +_PACKAGED_RUNNER = 'gate.py' + +_GIT = 'git' + +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + +_DELETED = 'D' + +_RENAMED = 'R' + +VERDICT_DENY = 'deny' + +def gate_path_from_argv(argv): + """The `--gate ` argument a vendored runtime was invoked with, or None.""" + if GATE_FLAG in argv: + index = argv.index(GATE_FLAG) + if index + 1 < len(argv): + return _chock_Path(argv[index + 1]) + return None + +def runner_for(gate): + """The 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, 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 = created_text(event) + 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 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: + 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 = [] + for field in fields: + status, path = (field[:2], field[3:]) + 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, 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 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, 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(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)) + return runner_outcome(proc.returncode, proc.stderr) + +_EVENT_ARG = {'pre_tool': 'pre-tool-use', 'stop': 'stop'} + +PRE_TOOL = 'pre_tool' + +def root_for(gate): + """The repository this compiled gate belongs to, or None when the layout is not that.""" + parents = gate.resolve().parents + if len(parents) <= _GATE_DEPTH_TO_CHOCK: + return None + if parents[_GATE_DEPTH_TO_CHOCK - 1].name != 'compiled' or parents[_GATE_DEPTH_TO_CHOCK].name != '.chock': + return None + return parents[_GATE_DEPTH_TO_CHOCK].parent + +def repo_root_for(event, gate): + """The repository under judgement: the compiled layout's root, else the event's cwd, else its workspace root. + + Of several workspace roots, the one holding the written path; this process's cwd only when there are none. + """ + root = root_for(gate) + if root is not None: + return root + cwd = getattr(event, 'cwd', None) or workspace_root(event) + return _chock_Path(cwd) if cwd else _chock_Path.cwd() + +def writes_for(event, gate, deadline=None, why=None, root=None): + """What this event puts under judgement: the call's own text, or what the turn left behind (None: unlisted).""" + root = repo_root_for(event, gate) if root is None else root + if event.event == PRE_TOOL: + return writes_from_event(event, root) + return writes_from_worktree(root, 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, root): + """(decision, judged files): what the compiled gate says about this event in `root`, before a re-entered stop.""" + if not gate.exists(): + return (_missing_gate(gate), {}) + outside = outside_globs(gate) + why = [] + listed = writes_for(event, gate, deadline, why, root) + 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: + return None + deadline = engine_deadline() + if event.event == PRE_TOOL: + return _gate_says(gate, event, name, deadline, repo_root_for(event, gate))[0] + roots = [repo_root_for(event, gate)] if root_for(gate) else stop_roots(event, repo_root_for(event, gate)) + decision, judged = per_root([(root, *_gate_says(gate, event, name, deadline, root)) for root in roots]) + return settle_stop(event, roots[0], 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: {'event': 'PreToolUse'}, STOP: {'event': '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 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) +#: Per wire event mapped to pre_tool: whether the client shows a guard's ask as a prompt. +_CHOCK_CAN_ASK = {'PreToolUse': False} +_CHOCK_EVENT_KEYS = ('event', 'hook_event_name', 'hookEventName', 'agent_action_name') + + +def _chock_wire_name(raw): + names = (raw.get(key) for key in _CHOCK_EVENT_KEYS) if isinstance(raw, dict) else () + return next((name for name in names if isinstance(name, str)), None) + + +def _chock_can_ask(raw): + # An event this bundle cannot name, or one the vendor data does not list, cannot prompt. + return _CHOCK_CAN_ASK.get(_chock_wire_name(raw), False) + + +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: + verdict = evaluate(sys.argv[1:], event.command, event.tool or "", can_ask=_chock_can_ask(event.raw)) + if verdict is not None: + outcome, reason = verdict + return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + gated = evaluate_gate(sys.argv[1:], event) + if gated is not None: + return _spoken(gated) + called = evaluate_tool_call(sys.argv[1:], event) + if called is not None: + return _spoken(called) + return None + + +def handle(event): + # A door that cannot decide refuses. An exception escaping here would exit the hook + # with a traceback, which every client reads as a non-blocking error: fail-open, with + # the reason on a stderr nobody watches. The refusal carries the reason instead. + try: + return _judge(event) + except Exception as exc: # noqa: BLE001 -- any failure here must refuse, never fall through + return Decision.deny( + "chock could not check this call (%s: %s). Refusing rather than reporting an " + "allow it never established." % (type(exc).__name__, exc) + ) +# <<< agentseam handler <<< + + +def _coerce(result): + if result is None: + return Decision.allow() + if isinstance(result, Decision): + return result + raise TypeError("handle() must return a Decision or None, got %r" % (type(result),)) + + +def _read_payload(stream): + # Decode bytes ourselves rather than trust the platform locale -- a BOM'd or non-UTF-8 + # stdin must not silently disable the gate. See agentseam.dispatch for the incident + # this guards: a Windows console's cp1252 layer turning a UTF-8 BOM into three bytes + # json cannot parse, with the hook allowing everything while claiming enforcement. + buffer = getattr(stream, "buffer", None) + if buffer is not None: + return buffer.read().decode("utf-8-sig", errors="replace") + return stream.read().lstrip("\ufeff") + + +def _emit(out, text): + buffer = getattr(out, "buffer", None) + if buffer is not None: + buffer.write(text.encode("utf-8")) + buffer.flush() + else: + out.write(text) + out.flush() + + +def _report(text): + # Best effort, never raising: a diagnostic must not pre-empt the refusal it accompanies. + # A hook's stderr is whatever the host gave it -- closed (then sys.stderr is None, and + # the traceback module's default print would land on STDOUT, inside the verdict), a + # console code page that cannot hold the payload text quoted in the exception, a pipe + # nobody reads. + try: + sys.stderr.write(text) + sys.stderr.flush() + except Exception: + 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) + if event.event == UNKNOWN: + # A vendor event this adapter has no mapping for. handle() is not called: it + # reasons about the canonical vocabulary, and handing it something outside that + # vocabulary invites a decision made on a false premise. + return "", 0 + try: + 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 + # failure is printed for the operator and the gate answers deny in 'goose''s + # own dialect. Only the failure's class is named to the host: its message may quote + # the very payload content the policy was inspecting. + _report(traceback.format_exc()) + decision = Decision.deny( + "policy handler failed (%s); refusing rather than allowing what it could not judge" + % sys.exc_info()[0].__name__ + ) + return respond(degrade(decision, event), event) + + +def main(stdin=None, stdout=None, exit=True): + """Read one payload from stdin, dispatch, emit the 'goose' response, exit.""" + stream = stdin if stdin is not None else sys.stdin + out = stdout if stdout is not None else sys.stdout + try: + raw = json.loads(_read_payload(stream)) + except Exception: + raw = None + if not isinstance(raw, dict): + text, code = _chock_unreadable() + if text: + _emit(out, text) + if exit: + sys.exit(code) + return code + try: + text, code = _decide(raw) + except Exception: + # Past the handler, which _decide() already answers for: a fault in this file on a + # payload it did decode. With no Event to answer in dialect, the one refusal left is + # the host's blocking exit code, and nothing on stdout to be misread as a verdict. + _report(traceback.format_exc()) + text, code = "", 2 + if text: + _emit(out, text) + if exit: + sys.exit(code) + return code + + +if __name__ == "__main__": # pragma: no cover + main() + diff --git a/tests/fixtures/runtime_goldens/grok.py b/tests/fixtures/runtime_goldens/grok.py index 470de4f2..1e48cb6f 100644 --- a/tests/fixtures/runtime_goldens/grok.py +++ b/tests/fixtures/runtime_goldens/grok.py @@ -29,6 +29,7 @@ from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings +import zlib as _zlib # ------------------------------------------------------------------------------ # contract (agentseam 0.3.5) @@ -224,6 +225,43 @@ def tool_input_of(raw): # ------------------------------------------------------------------------------ # flat_decision family engine (trimmed to what this entry uses) +OBSERVED_MARKERS = ( + "transcript_path", + "permission_mode", + "stop_hook_active", + "agent_transcript_path", + "background_tasks", + "session_crons", + "custom_instructions", + "effort", +) + +_MCP_PREFIX = "mcp_" + +_CLAUDE_MCP_PREFIX = "mcp__" + +def looks_like_claude_code(raw): + """True when the payload carries a field only Claude Code has been seen to send.""" + return isinstance(raw, dict) and any(marker in raw for marker in OBSERVED_MARKERS) + +def names_a_single_underscore_mcp_tool(raw): + """True when tool_name is an MCP tool ID in a spelling Claude Code never sends.""" + name = raw.get("tool_name") if isinstance(raw, dict) else None + return isinstance(name, str) and name.startswith(_MCP_PREFIX) and not name.startswith(_CLAUDE_MCP_PREFIX) + +def event_keys_agree(raw): + """True when hookEventName names hook_event_name's event: the same text, or Grok Build's snake_case twin.""" + snake, pascal = (raw.get("hookEventName"), raw.get("hook_event_name")) if isinstance(raw, dict) else (None, None) + if not isinstance(pascal, str) or not isinstance(snake, str): + return False + return snake == pascal or (snake.islower() and snake.replace("_", "") == pascal.lower()) + +PROBES = { + "looks_like_claude_code": looks_like_claude_code, + "names_a_single_underscore_mcp_tool": names_a_single_underscore_mcp_tool, + "event_keys_agree": event_keys_agree, +} + UNREADABLE_NAME = "" def wire_name_of(raw, key): @@ -248,12 +286,22 @@ def _rejected_by_markers(c, raw): for marker in c.get("reject_markers", ()): if marker in raw: return True + for key, values in c.get("reject_values", {}).items(): + if key in raw and raw[key] in values: + return True for probe, markers in c.get("reject_markers_unless_probe", {}).items(): if any(marker in raw for marker in markers) and not PROBES[probe](raw): return True return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) +def _accepted_by_values(c, raw): + """True unless the entry names accepted values and the payload carries none of them.""" + values = c.get("accept_values") + return not values or any(key in raw and raw[key] in allowed for key, allowed in values.items()) + def _accepted_by_markers(c, raw, name): + if not _accepted_by_values(c, raw): + return False accept = c.get("accept_markers", ()) if not accept or any(marker in raw for marker in accept): return True @@ -292,12 +340,25 @@ def _segment(node, part): return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None return node.get(part) if isinstance(node, dict) else None +def _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + def _walk(node, path): - """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None if "[]." in path: head, sub = path.split("[].", 1) - items = _walk(node, head) - if not isinstance(items, (list, tuple)): + items = _list_at(node, head) + if items is None: return None joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) return joined or None @@ -327,7 +388,7 @@ def _tool_input_raw(cfg, raw): if not isinstance(raw, dict): return None for key in cfg["fields"].get("tool_input", ("tool_input",)): - value = raw.get(key) + value = _walk(raw, key) if value is not None: return value return None @@ -518,6 +579,8 @@ def hj_respond(cfg, decision, event, wire=None): return "", 0 if gate["grammar"] == "G2": return _g2(v, gate, decision, name) + if gate["grammar"] == "G7": + return _g7(v, decision, wire) return _g1(v, gate, decision, wire, name) _WINDOWS_KEYS = ("commandWindows", "windows") @@ -533,16 +596,25 @@ def _hook_dict(cfg, command): entry[key] = value return entry +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): rules = [] for ev in canonical_events: name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: - rule["matcher"] = matcher - rules.append(rule) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) return rules def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): @@ -571,6 +643,10 @@ def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): hooks.setdefault(extra, []).append({"command": command}) return {"hooks": hooks} +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] hooks = {} @@ -601,6 +677,8 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) if wrapper == "flat_entries": return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) return _default_wrapper(cfg, reverse, canonical_events, command, matcher) @@ -609,7 +687,7 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos AGENT = "grok" -VENDOR = {'agent': 'grok', 'claims': {'event_key': ('hookEventName',), 'mode': 'marker'}, 'config_format': 'json', 'config_path': '.grok/hooks/agentseam.json', 'display': 'Grok CLI', 'events': {'PermissionDenied': 'tool_failure', 'PostCompact': 'unknown', 'PostToolUse': 'post_tool', 'PostToolUseFailure': 'tool_failure', 'PreCompact': 'pre_compact', 'PreToolUse': 'pre_tool', 'SessionEnd': 'session_end', 'SessionStart': 'session_start', 'Stop': 'stop', 'StopFailure': 'stop', 'SubagentStart': 'subagent_start', 'SubagentStop': 'subagent_stop', 'UserPromptSubmit': 'prompt_submit'}, 'evidence': {'claims': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'flat_decision', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content', 'tool_input.new_string'), 'cwd': ('cwd', 'workspaceRoot'), 'output': ('toolOutput',), 'path': ('tool_input.file_path', 'tool_input.path'), 'prompt': ('prompt',), 'session_id': ('sessionId',), 'tool': ('toolName',), 'tool_input': ('toolInput',)}, 'hook_entry': {'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': True, 'tools': {}, 'verdicts': {'answer_events': ('PreToolUse',), 'bare_allow': 'silent', 'degrade_notes': {'escalate': 'Grok cannot prompt for confirmation', 'escalate_from_transform': 'Grok cannot modify a tool call', 'transform': 'Grok cannot modify a tool call'}, 'gates': {'PreToolUse': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'because', 'vocabulary': ('deny',), 'vocabulary_basis': 'verified', 'words': {'block': 'deny'}}, 'wire_events': {'pre_compact': 'PreCompact', 'stop': 'Stop', 'tool_failure': 'PostToolUseFailure'}} +VENDOR = {'agent': 'grok', 'claims': {'accept_markers': ('hookEventName',), 'event_key': ('hook_event_name', 'hookEventName'), 'mode': 'marker', 'notes': "Grok Build sends hookEventName with grok's snake_case value (pre_tool_use) beside a hook_event_name key carrying the PascalCase name (github.com/xai-org/grok-build user-guide/10-hooks.md, read 2026-10-11 against v1.0.50), so hook_event_name is read first, but only when event_keys_agree(raw) finds hookEventName naming the same event (its snake_case twin, or the same text); a payload whose two keys disagree is not grok's (Kimi Code sends both, with a camelCase hookEventName). The older camelCase-only shape falls back to hookEventName, which is required either way.", 'reject_markers_unless_probe': {'event_keys_agree': ('hook_event_name',)}}, 'config_format': 'json', 'config_path': '.grok/hooks/agentseam.json', 'display': 'Grok CLI', 'events': {'PermissionDenied': 'tool_failure', 'PostCompact': 'unknown', 'PostToolUse': 'post_tool', 'PostToolUseFailure': 'tool_failure', 'PreCompact': 'pre_compact', 'PreToolUse': 'pre_tool', 'SessionEnd': 'session_end', 'SessionStart': 'session_start', 'Stop': 'stop', 'StopFailure': 'stop', 'SubagentStart': 'subagent_start', 'SubagentStop': 'subagent_stop', 'UserPromptSubmit': 'prompt_submit'}, 'evidence': {'claims': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'flat_decision', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content', 'tool_input.new_string'), 'cwd': ('cwd', 'workspaceRoot'), 'output': ('toolOutput',), 'path': ('tool_input.file_path', 'tool_input.path'), 'prompt': ('prompt',), 'session_id': ('sessionId',), 'tool': ('toolName',), 'tool_input': ('toolInput',)}, 'hook_entry': {'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': True, 'tools': {}, 'verdicts': {'answer_events': ('PreToolUse',), 'bare_allow': 'silent', 'degrade_notes': {'escalate': 'Grok cannot prompt for confirmation', 'escalate_from_transform': 'Grok cannot modify a tool call', 'transform': 'Grok cannot modify a tool call'}, 'gates': {'PreToolUse': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'because', 'vocabulary': ('deny',), 'vocabulary_basis': 'verified', 'words': {'block': 'deny'}}, 'wire_events': {'pre_compact': 'PreCompact', 'stop': 'Stop', 'tool_failure': 'PostToolUseFailure'}} def claims(raw): @@ -2096,7 +2174,7 @@ def evaluate_tool_call(argv, event): ) #: A payload naming only this vendor's own wire event, so `respond` picks that event's grammar. -_CHOCK_WIRE_RAW = {PRE_TOOL: {'hookEventName': 'PreToolUse'}, STOP: {'hookEventName': 'Stop'}} +_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") @@ -2165,7 +2243,7 @@ def _chock_unreadable(): return (text, code) if _chock_refuses(text, code) else ("", 2) #: Per wire event mapped to pre_tool: whether the client shows a guard's ask as a prompt. _CHOCK_CAN_ASK = {'PreToolUse': False} -_CHOCK_EVENT_KEYS = ('hookEventName', 'hook_event_name', 'agent_action_name') +_CHOCK_EVENT_KEYS = ('hook_event_name', 'hookEventName', 'agent_action_name') def _chock_wire_name(raw): diff --git a/tests/fixtures/runtime_goldens/mistral_vibe.py b/tests/fixtures/runtime_goldens/mistral_vibe.py new file mode 100644 index 00000000..03d846d3 --- /dev/null +++ b/tests/fixtures/runtime_goldens/mistral_vibe.py @@ -0,0 +1,2432 @@ +# Generated by agentseam 0.3.5 -- bundle("mistral_vibe"). 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. +# +# Self-contained: stdlib only, no "import agentseam" anywhere in this file. Regenerate +# with `agentseam.bundler.bundle("mistral_vibe")` (same agentseam version -> identical bytes, +# except your own edits inside the handler block) rather than patching this by hand. +# https://github.com/open-coder-ai/agentseam + +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 +import zlib as _zlib + +# ------------------------------------------------------------------------------ +# contract (agentseam 0.3.5) + +"""Canonical event vocabulary, normalized envelope, and decision type.""" + + + +SESSION_START = "session_start" +SESSION_END = "session_end" +PROMPT_SUBMIT = "prompt_submit" +PRE_TOOL = "pre_tool" +POST_TOOL = "post_tool" +TOOL_FAILURE = "tool_failure" +PRE_COMPACT = "pre_compact" +STOP = "stop" +SUBAGENT_START = "subagent_start" +SUBAGENT_STOP = "subagent_stop" +INSTRUCTIONS_LOADED = "instructions_loaded" +FILE_CHANGED = "file_changed" + +UNKNOWN = "unknown" + +EVENTS = ( + SESSION_START, + SESSION_END, + PROMPT_SUBMIT, + PRE_TOOL, + POST_TOOL, + TOOL_FAILURE, + PRE_COMPACT, + STOP, + SUBAGENT_START, + SUBAGENT_STOP, + INSTRUCTIONS_LOADED, + FILE_CHANGED, +) + + +class Event: + """One agent lifecycle event, normalized.""" + + __slots__ = ( + "agent", + "command", + "content", + "cwd", + "event", + "output", + "path", + "prompt", + "raw", + "session_id", + "tool", + "tool_use_id", + ) + + def __init__( + self, + agent, + event, + *, + tool=None, + command=None, + path=None, + content=None, + output=None, + prompt=None, + session_id=None, + tool_use_id=None, + cwd=None, + raw=None, + ): + self.agent = agent + self.event = event + self.tool = tool + self.command = command + self.path = path + self.content = content + self.output = output + self.prompt = prompt + self.session_id = session_id + self.tool_use_id = tool_use_id + self.cwd = cwd + self.raw = raw if raw is not None else {} + + def __repr__(self): # pragma: no cover - debugging aid + return "Event(%s/%s tool=%r path=%r)" % (self.agent, self.event, self.tool, self.path) + + +ALLOW = "allow" +DENY = "deny" +ESCALATE = "escalate" +TRANSFORM = "transform" +WARN = "warn" +VOUCH = "vouch" + +# Pre-ACS-alignment names. Same strings as their ACS-named counterparts, so every existing +# `is`/`==` comparison against the old constant keeps working untouched. +ASK = ESCALATE +REWRITE = TRANSFORM + +_CANONICAL_OUTCOMES = (ALLOW, DENY, ESCALATE, TRANSFORM, WARN, VOUCH) + +# The literal spellings a caller might still pass to Decision(outcome, ...) directly, mapped +# to the value that now backs them. Only needed for the raw-string constructor path -- +# Decision.ask()/.rewrite() below build the canonical outcome themselves. +_LEGACY_SPELLING = {"ask": ESCALATE, "rewrite": TRANSFORM} + + +class Decision: + """What a handler wants to happen. Adapters translate this to vendor dialect.""" + + __slots__ = ("context", "evidence", "outcome", "reason", "updated_input") + + #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). + DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) + + def __init__(self, outcome, reason=None, updated_input=None, evidence=None, context=None): + outcome = _LEGACY_SPELLING.get(outcome, outcome) + if outcome not in _CANONICAL_OUTCOMES: + raise ValueError("unknown outcome: %r" % (outcome,)) + self.outcome = outcome + self.reason = reason + self.updated_input = updated_input + self.evidence = evidence or {} + self.context = context + + @classmethod + def allow(cls, reason=None, evidence=None, context=None): + return cls(ALLOW, reason, evidence=evidence, context=context) + + @classmethod + def deny(cls, reason, evidence=None, context=None): + return cls(DENY, reason, evidence=evidence, context=context) + + @classmethod + def escalate(cls, reason, evidence=None, context=None): + """Defer the action to the host's own approval path (ACS `escalate`).""" + return cls(ESCALATE, reason, evidence=evidence, context=context) + + @classmethod + def ask(cls, reason, evidence=None, context=None): + """Deprecated alias of escalate() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.ask() is deprecated; use Decision.escalate()", DeprecationWarning, stacklevel=2) + return cls.escalate(reason, evidence=evidence, context=context) + + @classmethod + def transform(cls, updated_input, reason=None, evidence=None, context=None): + """Replace the tool input wholesale (ACS `transform`, at whole-value granularity).""" + return cls(TRANSFORM, reason, updated_input=updated_input, evidence=evidence, context=context) + + @classmethod + def rewrite(cls, updated_input, reason=None, evidence=None, context=None): + """Deprecated alias of transform() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.rewrite() is deprecated; use Decision.transform()", DeprecationWarning, stacklevel=2) + return cls.transform(updated_input, reason, evidence=evidence, context=context) + + @classmethod + def warn(cls, reason=None, evidence=None, context=None): + """Permit the action with no change, recording a warning (ACS `warn`).""" + return cls(WARN, reason, evidence=evidence, context=context) + + @classmethod + def vouch(cls, reason=None, evidence=None, context=None): + return cls(VOUCH, reason, evidence=evidence, context=context) + + def __repr__(self): # pragma: no cover - debugging aid + return "Decision(%s, %r)" % (self.outcome, self.reason) + + +def degraded_from(decision): + """What this decision was before the dispatcher reduced it, or None.""" + return (decision.evidence or {}).get("degraded_from") + + +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.lstrip()[:1] == "{": + try: + parsed = _json.loads(raw) + except (ValueError, RecursionError): + # JSONDecodeError is a ValueError; a string nested past the interpreter's + # limit raises RecursionError instead, and neither is an input to crash on. + return {} + if isinstance(parsed, dict): + return parsed + return {} + + +# ------------------------------------------------------------------------------ +# flat_decision family engine (trimmed to what this entry uses) + +UNREADABLE_NAME = "" + +def wire_name_of(raw, key): + """The event name under `key`: text, None when absent, UNREADABLE_NAME when not text. + + Total over anything `json.loads` can return: a payload that is not an object names no + event, and a name that is not a string is not one any adapter can map. + """ + name = raw.get(key) if isinstance(raw, dict) else None + if name is None or isinstance(name, str): + return name + return UNREADABLE_NAME + +def _wire_name(cfg, raw): + for key in cfg["claims"].get("event_key", ()): + name = wire_name_of(raw, key) + if name is not None: + return name + return None + +def _rejected_by_markers(c, raw): + for marker in c.get("reject_markers", ()): + if marker in raw: + return True + for key, values in c.get("reject_values", {}).items(): + if key in raw and raw[key] in values: + return True + for probe, markers in c.get("reject_markers_unless_probe", {}).items(): + if any(marker in raw for marker in markers) and not PROBES[probe](raw): + return True + return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) + +def _accepted_by_values(c, raw): + """True unless the entry names accepted values and the payload carries none of them.""" + values = c.get("accept_values") + return not values or any(key in raw and raw[key] in allowed for key, allowed in values.items()) + +def _accepted_by_markers(c, raw, name): + if not _accepted_by_values(c, raw): + return False + accept = c.get("accept_markers", ()) + if not accept or any(marker in raw for marker in accept): + return True + return any( + name == event_name and all(key in raw for key in required) + for event_name, required in c.get("accept_when_all", {}).items() + ) + +def _disqualified(cfg, c, raw, name): + """Every reason an entry declines a payload once its wire event name is known.""" + if name not in cfg["events"] and not (c.get("accept_any_name") and isinstance(name, str)): + return True + if "client_types" in c and raw.get("client_type") not in c["client_types"]: + return True + return _rejected_by_markers(c, raw) + +def hj_claims(cfg, raw): + """True when this payload matches the entry's marker discipline.""" + if not isinstance(raw, dict): + return False + c = cfg["claims"] + name = _wire_name(cfg, raw) + # A positive self-identification beats a shared event name, accept_names included. + if raw.get("client_type") in c.get("reject_client_types", ()): + return False + if name in c.get("accept_names", ()): + return True + return not _disqualified(cfg, c, raw, name) and _accepted_by_markers(c, raw, name) + +def _segment(node, part): + """One path segment: a dict key, or `key[N]` indexing the list under it.""" + if part.endswith("]") and "[" in part: + key, _, index = part[:-1].partition("[") + items = node.get(key) if isinstance(node, dict) else None + i = int(index) + return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None + return node.get(part) if isinstance(node, dict) else None + +def _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + +def _walk(node, path): + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None + if "[]." in path: + head, sub = path.split("[].", 1) + items = _list_at(node, head) + if items is None: + return None + joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) + return joined or None + for part in path.split("."): + node = _segment(node, part) + if node is None: + return None + return node + +def _lookup(raw, ti, key): + """One config key: a `tool_input.` path walks the decoded tool input, else the payload.""" + if key.startswith("tool_input."): + return _walk(ti, key[len("tool_input.") :]) + return _walk(raw, key) + +def _field(raw, ti, chain): + value = None + for key in chain: + if value: + break + value = value or _lookup(raw, ti, key) + return value + +_FIELD_META = ("tool_input", "content_only_for_write_tools", "stringify") + +def _tool_input_raw(cfg, raw): + if not isinstance(raw, dict): + return None + for key in cfg["fields"].get("tool_input", ("tool_input",)): + value = _walk(raw, key) + if value is not None: + return value + return None + +def _canonical_of(cfg, name): + """The canonical event for one wire name; an entry with no `events` at all (antigravity, + whose payloads never carry one) maps the shape-inferred name back through `wire_events`.""" + events = cfg["events"] + if not events: + return {wire: canonical for canonical, wire in cfg.get("wire_events", {}).items()}.get(name, UNKNOWN) + return events.get(name, UNKNOWN) + +def hj_parse(cfg, raw, wire=None): + """Normalise one payload along the entry's ordered field-fallback chains. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + ti = tool_input_of(_tool_input_raw(cfg, raw)) + fields = {name: _field(raw, ti, chain) for name, chain in cfg["fields"].items() if name not in _FIELD_META} + if cfg["fields"].get("content_only_for_write_tools") and fields.get("tool") not in cfg["tools"].get("write", ()): + fields["content"] = None + if isinstance(fields.get("output"), (dict, list)): + fields["output"] = _json.dumps(fields["output"]) + for name in cfg["fields"].get("stringify", ()): + if fields.get(name) is not None: + fields[name] = str(fields[name]) + return Event( + cfg["agent"], + _canonical_of(cfg, wire if wire is not None else _wire_name(cfg, raw)), + tool=fields.get("tool"), + command=fields.get("command"), + path=fields.get("path"), + content=fields.get("content"), + output=fields.get("output"), + prompt=fields.get("prompt"), + session_id=fields.get("session_id"), + tool_use_id=fields.get("tool_use_id"), + cwd=fields.get("cwd"), + raw=raw, + ) + +_ESCALATE_FROM_TRANSFORM = "escalate_from_transform" + +_TRANSFORM_MISSING_INPUT = "transform_missing_input" + +def hj_reverse(cfg): + """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides. + + One wire name per canonical event, which is a real limit and not an oversight (R3, gap 4). + Cursor is where it bites: `pre_tool` pins to `preToolUse`, and Cursor honours `ask` only at + `beforeShellExecution` / `beforeMCPExecution`. So installing at `pre_tool` forecloses `ask` + before dispatch is ever reached -- the runtime degrade to `deny` is honest about it, but the + install already chose. Deny-style policies, which is all that ships today, are unaffected. + + The day an ask-style Cursor policy exists, this map has to select by decision dialect + (deny -> `preToolUse`, ask -> `beforeShellExecution`) and become one-to-many. Recorded here + rather than built, so the resolution is not re-litigated from scratch; the three facts it + rests on are pinned in tests/test_cursor_ask_dialect.py. + """ + reverse = {} + for name, canonical in cfg["events"].items(): + if canonical != UNKNOWN: + reverse[canonical] = name + reverse.update(cfg.get("wire_events", {})) + return reverse + +def _context_value(v, decision): + if v.get("context_source") == "context": + return decision.context + if v.get("context_source") == "reason": + return decision.reason + return None + +def _context_body(name, value): + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "additionalContext": value}}), 0 + +def _note_for(v, decision, at_gate, missing_input): + notes = v.get("degrade_notes", {}) + if decision.outcome == ESCALATE: + if degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in notes: + return notes[_ESCALATE_FROM_TRANSFORM] + if at_gate and "escalate_gate" in notes: + return notes["escalate_gate"] + return notes.get("escalate") + if decision.outcome == TRANSFORM: + if missing_input and _TRANSFORM_MISSING_INPUT in notes: + return notes[_TRANSFORM_MISSING_INPUT] + return notes.get("transform") + return None + +def _default_for(v, decision, at_gate, wire=None): + gate_defaults = v.get("gate_reason_defaults", {}) + if wire in gate_defaults: + return gate_defaults[wire] + defaults = v.get("reason_defaults", {}) + key = {DENY: "deny", ESCALATE: "escalate", TRANSFORM: "transform"}.get(decision.outcome, "deny") + if at_gate and key + "_gate" in defaults: + return defaults[key + "_gate"] + return defaults.get(key, "blocked by policy") + +def _refusal_text(v, decision, at_gate, wire=None): + note = _note_for(v, decision, at_gate, decision.updated_input is None) + default = _default_for(v, decision, at_gate, wire) + if note and "%s" in note: + # A template note fills (the reason or its default, the wire event name) itself. + return note % (decision.reason or default, wire) + reason = decision.reason + if v.get("note_style") == "suffix": + reason = reason or default + return "%s (%s)" % (reason, note) if note else reason + text = "%s (%s)" % (reason, note) if reason and note else (note or reason) + return text or default + +def _g1_allow(v, decision, wire, name, words): + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + if wire in v.get("allow_silent_events", ()): + return "", 0 + if "allow" in words: + out = {"decision": words["allow"]} + if v.get("allow_context_key") and decision.outcome == ALLOW and value: + out[v["allow_context_key"]] = value + return _json.dumps(out), 0 + return "", 0 + +def _g1_transform(v, decision, name, words): + """None when the transform isn't representable here; `_g1` falls through to the block path.""" + if v.get("transform_grammar") == "hook_specific_tool_input": + return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0 + if decision.updated_input is None: + return None + if v.get("transform_grammar") == "top_level_updated_input": + out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input} + if decision.reason: + out["reason"] = decision.reason + return _json.dumps(out), 0 + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}}), 0 + +def _g1(v, gate, decision, wire, name): + """Block dialect: a top-level decision word, or silence/context where nothing is read.""" + words = dict(v.get("words", {})) + words.update(v.get("words_at", {}).get(wire, {})) + at_context_event = wire in v.get("context_events", ()) + if decision.outcome in (ALLOW, VOUCH, WARN): + return _g1_allow(v, decision, wire, name, words) + if decision.outcome == TRANSFORM and gate["honours_transform"]: + transformed = _g1_transform(v, decision, name, words) + if transformed is not None: + return transformed + if ( + decision.outcome == ESCALATE + and gate["honours_escalate"] + and "escalate" in words + # An escalate the dispatcher degraded a transform into is a block where the entry + # names that degradation (antigravity): prompting would offer the unmodified call. + and not (degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in v.get("degrade_notes", {})) + ): + reason = decision.reason or _default_for(v, decision, at_gate=True, wire=wire) + return _json.dumps({"decision": words["escalate"], "reason": reason}), 0 + out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, at_gate=False, wire=wire)} + if at_context_event and v.get("context_source") == "context" and decision.context: + out["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": decision.context} + return _json.dumps(out), 0 + +def hj_respond(cfg, decision, event, wire=None): + """(stdout_text, exit_code) in this entry's dialect for the gate the payload names. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + v = cfg["verdicts"] + if wire is None: + wire = _wire_name(cfg, event.raw or {}) + if wire in v.get("empty_object_events", ()): + return _json.dumps({}), 0 + if wire is None: + wire = v.get("default_wire_event") + if wire is None and v.get("missing_wire") == "reverse_map": + wire = hj_reverse(cfg).get(event.event) + name = wire if v.get("echo") == "payload" else hj_reverse(cfg).get(event.event, "PreToolUse") + gate = v["gates"].get(wire) + if gate is None: + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + return "", 0 + if gate["grammar"] == "G2": + return _g2(v, gate, decision, name) + if gate["grammar"] == "G7": + return _g7(v, decision, wire) + return _g1(v, gate, decision, wire, name) + +_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(): + if key in _WINDOWS_KEYS: + entry[key] = powershell_command(command) + else: + entry[key] = value + return entry + +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + +def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): + rules = [] + for ev in canonical_events: + name = reverse.get(ev) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) + 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 and name not in allow_silent: + entry["failClosed"] = True + hooks.setdefault(name, []).append(entry) + return {"version": 1, "hooks": hooks} + +def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + hooks.setdefault(name, []).append({"command": command}) + extra = hook_entry.get("also_wires", {}).get(ev) + if extra: + hooks.setdefault(extra, []).append({"command": command}) + return {"hooks": hooks} + +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + +def _default_wrapper(cfg, reverse, canonical_events, command, matcher): + hook_entry = cfg["hook_entry"] + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + entry = {"hooks": [_hook_dict(cfg, command)]} + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: + entry["matcher"] = matcher + hooks.setdefault(name, []).append(entry) + if hook_entry.get("group"): + return {hook_entry["group"]: hooks} + return hooks if hook_entry.get("bare") else {"hooks": hooks} + +def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_closed=True): + """The vendor's hooks-config fragment wiring `command` for these canonical events. + + `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the + entry says otherwise; a False installs an observer, not a gate. + """ + hook_entry = cfg["hook_entry"] + reverse = hj_reverse(cfg) + wrapper = hook_entry["wrapper"] + if wrapper == "flat_list": + return _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher) + if wrapper == "cursor": + return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) + if wrapper == "flat_entries": + return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) + return _default_wrapper(cfg, reverse, canonical_events, command, matcher) + +_TOML_ESCAPES = {"\\": "\\\\", '"': '\\"', "\b": "\\b", "\t": "\\t", "\n": "\\n", "\f": "\\f", "\r": "\\r"} + +def _toml_char(ch): + if ch in _TOML_ESCAPES: + return _TOML_ESCAPES[ch] + return "\\u%04X" % ord(ch) if ch < " " or ch == "\x7f" else ch + +def _toml_value(value): + if isinstance(value, int) and not isinstance(value, bool): + return str(value) + return '"%s"' % "".join(_toml_char(ch) for ch in str(value)) + +_TOML_RULE_KEYS = ("name", "event", "type", "matcher", "match", "command", "timeout") + +def render_config(rules): + """Emit `[[hooks]]` tables. Only the documented fields, in a documented order.""" + blocks = [] + for rule in rules: + lines = ["[[hooks]]"] + for key in _TOML_RULE_KEYS: + if rule.get(key) is not None: + lines.append("%s = %s" % (key, _toml_value(rule[key]))) + blocks.append("\n".join(lines)) + return "\n\n".join(blocks) + "\n" + + +# ------------------------------------------------------------------------------ +# mistral_vibe vendor config + engine binding + +AGENT = "mistral_vibe" + +VENDOR = {'agent': 'mistral_vibe', 'claims': {'event_key': ('hook_event_name',), 'mode': 'marker', 'notes': 'Vibe names the event under hook_event_name with its own snake_case hook types (pre_tool, post_tool, post_agent), which no other adapter maps, so the event name alone separates it (docs.mistral.ai/vibe/code/cli/hooks and vibe/core/hooks/models.py HookType at mistral-vibe 2.26.1, read 2026-10-11).'}, 'config_format': 'toml', 'config_path': '.vibe/hooks.toml', 'display': 'Mistral Vibe', 'events': {'post_agent': 'stop', 'post_tool': 'post_tool', 'pre_tool': 'pre_tool'}, 'evidence': {'claims': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'flat_decision', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content', 'tool_input.new_string'), 'cwd': ('cwd',), 'output': ('tool_output_text',), 'path': ('tool_input.file_path', 'tool_input.path'), 'session_id': ('session_id',), 'tool': ('tool_name',), 'tool_use_id': ('tool_call_id',)}, 'hook_entry': {'default_match': '*', 'matcher': True, 'rule_keys': {'event': 'type', 'matcher': 'match'}, 'rule_name_prefix': 'agentseam-', 'wrapper': 'flat_list'}, 'needs_trust': True, 'tools': {'shell': ('bash', 'powershell', 'git_bash'), 'write': ('write_file', 'edit')}, 'verdicts': {'answer_events': ('post_agent', 'pre_tool'), 'bare_allow': 'silent', 'degrade_notes': {'escalate': 'Vibe cannot prompt for confirmation from a hook', 'escalate_from_transform': "agentseam does not write Vibe's tool_input rewrite", 'transform': "agentseam does not write Vibe's tool_input rewrite"}, 'echo': 'payload', 'gates': {'post_agent': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'pre_tool': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'because', 'vocabulary': ('allow', 'deny'), 'vocabulary_basis': 'verified', 'words': {'block': 'deny'}}} + + +def claims(raw): + return hj_claims(VENDOR, raw) + + +def parse(raw): + return hj_parse(VENDOR, raw) + + +def respond(decision, event): + return hj_respond(VENDOR, decision, event) + + +def hook_config(canonical_events, command, matcher=None): + return hook_entry_config(VENDOR, canonical_events, command, matcher) + + +# ------------------------------------------------------------------------------ +# runtime (agentseam dispatch, specialized to mistral_vibe) + +_VOUCH_SPEAKS = False +_WARN_SPEAKS = False +_TRANSFORM_EVENTS = frozenset(()) + + +def degrade(decision, event): + """Reduce a decision to what 'mistral_vibe' can actually honor, honestly. + + This is agentseam.dispatch.degrade(), specialized to one fixed agent so this file needs + no "import agentseam" -- no evidence establishes that an explicit approval word means anything beyond a plain allow here, so vouch degrades to one; see agentseam.allow_semantics. + """ + if decision.outcome == TRANSFORM and event.event not in _TRANSFORM_EVENTS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = TRANSFORM + return Decision.escalate( + decision.reason or "input requires modification before it can run", evidence=evidence + ) + if decision.outcome == VOUCH and not _VOUCH_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = VOUCH + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + if decision.outcome == WARN and not _WARN_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = WARN + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + return decision + + +# >>> 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' + +_CRASH_MARKERS = ('traceback (most recent call last)', 'syntax error', 'syntaxerror', 'unexpected eof') + +TOOL_UNKNOWN = 'unknown' + +ARGV_FALLBACK_ENV = 'CHOCK_ARGV_FALLBACK' + +CAN_ASK_ENV = 'CHOCK_CAN_ASK' + +_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 + +_BASH_PROBE_SECONDS = 10 + +GUARD_BLOCKED = 'blocked' + +GUARD_CLEAN = 'clean' + +GUARD_ASKED = 'asked' + +GUARD_UNCHECKED = 'unchecked' + +GUARD_ERRORED = 'errored' + +VERDICT_DENY = 'deny' + +VERDICT_ESCALATE = 'escalate' + +def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: + """The `--guard ` argument a vendored runtime was invoked with, or None.""" + if '--guard' in argv: + i = argv.index('--guard') + if i + 1 < len(argv): + return _chock_Path(argv[i + 1]) + return None + +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) + env.pop(CAN_ASK_ENV, None) + 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: + 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, 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, 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, tool: str='') -> str: + """`GUARD_BLOCKED` / `GUARD_ASKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + 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, tool: str='', *, can_ask: bool | None=None) -> tuple[str, str]: + """`run_guard`'s verdict plus the guard's own first line, which an ask carries to the user.""" + args, fallback = split_command(command) + if not args: + return (GUARD_UNCHECKED, '') + deadline = engine_deadline() + interpreter = find_interpreter(guard, deadline) + if interpreter is None: + return _no_interpreter(guard, deadline) + try: + env = {**interpreter_env(interpreter), 'CHOCK_RAW_COMMAND': command, 'CHOCK_TOOL': normalize_tool(tool)} + if fallback: + env[ARGV_FALLBACK_ENV] = '1' + env.update({} if can_ask is None else {CAN_ASK_ENV: '1' if can_ask else '0'}) + 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 {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) + return (GUARD_ERRORED, '') + output = ((proc.stderr or '') + (proc.stdout or '')).strip() + first_line = output.splitlines()[0].strip() if output else '' + if proc.returncode == GUARD_VIOLATION: + 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 '') + return (GUARD_BLOCKED, first_line) + if proc.returncode == GUARD_ASK_EXIT: + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + return (GUARD_ASKED, first_line) + if proc.returncode != 0: + print(f'chock: guard exited {proc.returncode}, not checked' + (f': {first_line[:120]}' if first_line else ''), file=sys.stderr) + return (GUARD_ERRORED, '') + return (GUARD_CLEAN, '') + +def 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: + if _chock_os.environ.get(GATE_LOG_ENV) == '0': + return + guard = guard.resolve() + if guard.parent.name != 'implementations': + return + artifact_root = None + for parent in guard.parents: + if (parent / '.chock').is_dir(): + artifact_root = parent / '.chock' + break + if artifact_root is None: + return + 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='', *, can_ask: bool | None=None) -> tuple[str, str] | None: + """Run the guard named on `argv` (`--guard `) against `command`.""" + guard = guard_path_from_argv(argv) + if guard is None: + return None + 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, can_ask=can_ask) + logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} + if verdict in logged: + log_outcome(guard, tool, verdict=logged[verdict]) + if verdict == GUARD_BLOCKED: + return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + if verdict == GUARD_ASKED: + return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') + if verdict == GUARD_ERRORED: + 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' + +_CREATE_TEXT = 'file_text' + +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 created_text(event): + """The whole file a write call carries: the adapter's content, else `tool_input.file_text`; None otherwise.""" + content = getattr(event, 'content', None) + if isinstance(content, str): + return content + text = _edit_call_input(event).get(_CREATE_TEXT) + return text if isinstance(text, str) else None + +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)) + +_PATCH_KEYS = ('command', 'input', 'patch') + +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 if isinstance(tool_input, str) else None + for key in _PATCH_KEYS if isinstance(tool_input, dict) else (): + if tool_input.get(key) is not None: + command = tool_input[key] + break + 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 '')) + +_PARENT = '..' + +_DRIVE_COLON = ':' + +_ROOTS_KEY = 'workspace_roots' + +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 _workspace_root(text): + """A workspace root as a path: `/C:/x` is `C:/x`; anything else as given.""" + if text[:1] == '/' and text[2:3] == _DRIVE_COLON and text[1:2].isalpha() and (text[3:4] in ('', '/', '\\')): + return text[1:] + return text + +def _holds(root, path): + """Whether `path`, absolute or relative, lies under `root`, compared the way `root` is spelled.""" + flavour = _chock_PureWindowsPath if root[1:2] == _DRIVE_COLON else _chock_PurePosixPath + try: + _folded(flavour(root) / str(path)).relative_to(flavour(root)) + except ValueError: + return False + return True + +def workspace_roots(event): + """The folders the client says the agent works in, normalised; [] when it names none.""" + raw = getattr(event, 'raw', None) + roots = raw.get(_ROOTS_KEY) if isinstance(raw, dict) else None + if not isinstance(roots, list): + return [] + return [_workspace_root(root) for root in roots if isinstance(root, str) and root] + +def workspace_root(event): + """The workspace root holding the event's path, else the first one; None when the client names none.""" + roots = workspace_roots(event) + path = getattr(event, 'path', None) + return next((root for root in roots if path and _holds(root, path)), roots[0] if roots else None) + +_SEVERITY = (VERDICT_DENY, VERDICT_ESCALATE, VERDICT_WARN) + +_GIT_DIR = '.git' + +def _repository(folder): + """The repository `folder` is in: the nearest folder holding `.git`, or None outside one.""" + here = _chock_Path(folder).absolute() + return next((f for f in (here, *here.parents) if (f / _GIT_DIR).exists()), None) + +def stop_roots(event, fallback): + """The event's `cwd` and every workspace root inside a repository, the first per repository; else [fallback].""" + by_repository = {} + for named in filter(None, (getattr(event, 'cwd', None), *workspace_roots(event))): + repository = _repository(named) + if repository is not None: + by_repository.setdefault(repository, _chock_Path(named)) + return list(by_repository.values()) or [fallback] + +def per_root(said): + """One (decision, judged files) for each root's own: the most severe verdict, findings under each root's name.""" + if len(said) == 1: + return said[0][1:] + spoken = [(root, decision) for root, decision, _judged in said if decision] + judged = {path: text for _root, _decision, files in said for path, text in files.items()} + if not spoken: + return (None, judged) + verdict = min((decision[0] for _root, decision in spoken), key=_SEVERITY.index) + return ((verdict, '\n'.join((f'In {root}:\n{decision[1]}' for root, decision in spoken))), judged) + +GATE_FLAG = '--gate' + +STOP_FLAG = '--stop' + +_GATE_DEPTH_TO_CHOCK = 3 + +_RUNNER_PARTS = ('bin', 'gate.py') + +_PACKAGED_RUNNER = 'gate.py' + +_GIT = 'git' + +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + +_DELETED = 'D' + +_RENAMED = 'R' + +VERDICT_DENY = 'deny' + +def gate_path_from_argv(argv): + """The `--gate ` argument a vendored runtime was invoked with, or None.""" + if GATE_FLAG in argv: + index = argv.index(GATE_FLAG) + if index + 1 < len(argv): + return _chock_Path(argv[index + 1]) + return None + +def runner_for(gate): + """The 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, 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 = created_text(event) + 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 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: + 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 = [] + for field in fields: + status, path = (field[:2], field[3:]) + 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, 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 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, 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(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)) + return runner_outcome(proc.returncode, proc.stderr) + +_EVENT_ARG = {'pre_tool': 'pre-tool-use', 'stop': 'stop'} + +PRE_TOOL = 'pre_tool' + +def root_for(gate): + """The repository this compiled gate belongs to, or None when the layout is not that.""" + parents = gate.resolve().parents + if len(parents) <= _GATE_DEPTH_TO_CHOCK: + return None + if parents[_GATE_DEPTH_TO_CHOCK - 1].name != 'compiled' or parents[_GATE_DEPTH_TO_CHOCK].name != '.chock': + return None + return parents[_GATE_DEPTH_TO_CHOCK].parent + +def repo_root_for(event, gate): + """The repository under judgement: the compiled layout's root, else the event's cwd, else its workspace root. + + Of several workspace roots, the one holding the written path; this process's cwd only when there are none. + """ + root = root_for(gate) + if root is not None: + return root + cwd = getattr(event, 'cwd', None) or workspace_root(event) + return _chock_Path(cwd) if cwd else _chock_Path.cwd() + +def writes_for(event, gate, deadline=None, why=None, root=None): + """What this event puts under judgement: the call's own text, or what the turn left behind (None: unlisted).""" + root = repo_root_for(event, gate) if root is None else root + if event.event == PRE_TOOL: + return writes_from_event(event, root) + return writes_from_worktree(root, 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, root): + """(decision, judged files): what the compiled gate says about this event in `root`, before a re-entered stop.""" + if not gate.exists(): + return (_missing_gate(gate), {}) + outside = outside_globs(gate) + why = [] + listed = writes_for(event, gate, deadline, why, root) + 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: + return None + deadline = engine_deadline() + if event.event == PRE_TOOL: + return _gate_says(gate, event, name, deadline, repo_root_for(event, gate))[0] + roots = [repo_root_for(event, gate)] if root_for(gate) else stop_roots(event, repo_root_for(event, gate)) + decision, judged = per_root([(root, *_gate_says(gate, event, name, deadline, root)) for root in roots]) + return settle_stop(event, roots[0], 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': 'pre_tool'}, STOP: {'hook_event_name': 'post_agent'}} + +#: 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) +#: Per wire event mapped to pre_tool: whether the client shows a guard's ask as a prompt. +_CHOCK_CAN_ASK = {'pre_tool': False} +_CHOCK_EVENT_KEYS = ('hook_event_name', 'hookEventName', 'agent_action_name') + + +def _chock_wire_name(raw): + names = (raw.get(key) for key in _CHOCK_EVENT_KEYS) if isinstance(raw, dict) else () + return next((name for name in names if isinstance(name, str)), None) + + +def _chock_can_ask(raw): + # An event this bundle cannot name, or one the vendor data does not list, cannot prompt. + return _CHOCK_CAN_ASK.get(_chock_wire_name(raw), False) + + +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: + verdict = evaluate(sys.argv[1:], event.command, event.tool or "", can_ask=_chock_can_ask(event.raw)) + if verdict is not None: + outcome, reason = verdict + return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + gated = evaluate_gate(sys.argv[1:], event) + if gated is not None: + return _spoken(gated) + called = evaluate_tool_call(sys.argv[1:], event) + if called is not None: + return _spoken(called) + return None + + +def handle(event): + # A door that cannot decide refuses. An exception escaping here would exit the hook + # with a traceback, which every client reads as a non-blocking error: fail-open, with + # the reason on a stderr nobody watches. The refusal carries the reason instead. + try: + return _judge(event) + except Exception as exc: # noqa: BLE001 -- any failure here must refuse, never fall through + return Decision.deny( + "chock could not check this call (%s: %s). Refusing rather than reporting an " + "allow it never established." % (type(exc).__name__, exc) + ) +# <<< agentseam handler <<< + + +def _coerce(result): + if result is None: + return Decision.allow() + if isinstance(result, Decision): + return result + raise TypeError("handle() must return a Decision or None, got %r" % (type(result),)) + + +def _read_payload(stream): + # Decode bytes ourselves rather than trust the platform locale -- a BOM'd or non-UTF-8 + # stdin must not silently disable the gate. See agentseam.dispatch for the incident + # this guards: a Windows console's cp1252 layer turning a UTF-8 BOM into three bytes + # json cannot parse, with the hook allowing everything while claiming enforcement. + buffer = getattr(stream, "buffer", None) + if buffer is not None: + return buffer.read().decode("utf-8-sig", errors="replace") + return stream.read().lstrip("\ufeff") + + +def _emit(out, text): + buffer = getattr(out, "buffer", None) + if buffer is not None: + buffer.write(text.encode("utf-8")) + buffer.flush() + else: + out.write(text) + out.flush() + + +def _report(text): + # Best effort, never raising: a diagnostic must not pre-empt the refusal it accompanies. + # A hook's stderr is whatever the host gave it -- closed (then sys.stderr is None, and + # the traceback module's default print would land on STDOUT, inside the verdict), a + # console code page that cannot hold the payload text quoted in the exception, a pipe + # nobody reads. + try: + sys.stderr.write(text) + sys.stderr.flush() + except Exception: + 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) + if event.event == UNKNOWN: + # A vendor event this adapter has no mapping for. handle() is not called: it + # reasons about the canonical vocabulary, and handing it something outside that + # vocabulary invites a decision made on a false premise. + return "", 0 + try: + 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 + # failure is printed for the operator and the gate answers deny in 'mistral_vibe''s + # own dialect. Only the failure's class is named to the host: its message may quote + # the very payload content the policy was inspecting. + _report(traceback.format_exc()) + decision = Decision.deny( + "policy handler failed (%s); refusing rather than allowing what it could not judge" + % sys.exc_info()[0].__name__ + ) + return respond(degrade(decision, event), event) + + +def main(stdin=None, stdout=None, exit=True): + """Read one payload from stdin, dispatch, emit the 'mistral_vibe' response, exit.""" + stream = stdin if stdin is not None else sys.stdin + out = stdout if stdout is not None else sys.stdout + try: + raw = json.loads(_read_payload(stream)) + except Exception: + raw = None + if not isinstance(raw, dict): + text, code = _chock_unreadable() + if text: + _emit(out, text) + if exit: + sys.exit(code) + return code + try: + text, code = _decide(raw) + except Exception: + # Past the handler, which _decide() already answers for: a fault in this file on a + # payload it did decode. With no Event to answer in dialect, the one refusal left is + # the host's blocking exit code, and nothing on stdout to be misread as a verdict. + _report(traceback.format_exc()) + text, code = "", 2 + if text: + _emit(out, text) + if exit: + sys.exit(code) + return code + + +if __name__ == "__main__": # pragma: no cover + main() + diff --git a/tests/fixtures/runtime_goldens/openhands.py b/tests/fixtures/runtime_goldens/openhands.py new file mode 100644 index 00000000..8aa0c93c --- /dev/null +++ b/tests/fixtures/runtime_goldens/openhands.py @@ -0,0 +1,2407 @@ +# Generated by agentseam 0.3.5 -- bundle("openhands"). 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. +# +# Self-contained: stdlib only, no "import agentseam" anywhere in this file. Regenerate +# with `agentseam.bundler.bundle("openhands")` (same agentseam version -> identical bytes, +# except your own edits inside the handler block) rather than patching this by hand. +# https://github.com/open-coder-ai/agentseam + +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 +import zlib as _zlib + +# ------------------------------------------------------------------------------ +# contract (agentseam 0.3.5) + +"""Canonical event vocabulary, normalized envelope, and decision type.""" + + + +SESSION_START = "session_start" +SESSION_END = "session_end" +PROMPT_SUBMIT = "prompt_submit" +PRE_TOOL = "pre_tool" +POST_TOOL = "post_tool" +TOOL_FAILURE = "tool_failure" +PRE_COMPACT = "pre_compact" +STOP = "stop" +SUBAGENT_START = "subagent_start" +SUBAGENT_STOP = "subagent_stop" +INSTRUCTIONS_LOADED = "instructions_loaded" +FILE_CHANGED = "file_changed" + +UNKNOWN = "unknown" + +EVENTS = ( + SESSION_START, + SESSION_END, + PROMPT_SUBMIT, + PRE_TOOL, + POST_TOOL, + TOOL_FAILURE, + PRE_COMPACT, + STOP, + SUBAGENT_START, + SUBAGENT_STOP, + INSTRUCTIONS_LOADED, + FILE_CHANGED, +) + + +class Event: + """One agent lifecycle event, normalized.""" + + __slots__ = ( + "agent", + "command", + "content", + "cwd", + "event", + "output", + "path", + "prompt", + "raw", + "session_id", + "tool", + "tool_use_id", + ) + + def __init__( + self, + agent, + event, + *, + tool=None, + command=None, + path=None, + content=None, + output=None, + prompt=None, + session_id=None, + tool_use_id=None, + cwd=None, + raw=None, + ): + self.agent = agent + self.event = event + self.tool = tool + self.command = command + self.path = path + self.content = content + self.output = output + self.prompt = prompt + self.session_id = session_id + self.tool_use_id = tool_use_id + self.cwd = cwd + self.raw = raw if raw is not None else {} + + def __repr__(self): # pragma: no cover - debugging aid + return "Event(%s/%s tool=%r path=%r)" % (self.agent, self.event, self.tool, self.path) + + +ALLOW = "allow" +DENY = "deny" +ESCALATE = "escalate" +TRANSFORM = "transform" +WARN = "warn" +VOUCH = "vouch" + +# Pre-ACS-alignment names. Same strings as their ACS-named counterparts, so every existing +# `is`/`==` comparison against the old constant keeps working untouched. +ASK = ESCALATE +REWRITE = TRANSFORM + +_CANONICAL_OUTCOMES = (ALLOW, DENY, ESCALATE, TRANSFORM, WARN, VOUCH) + +# The literal spellings a caller might still pass to Decision(outcome, ...) directly, mapped +# to the value that now backs them. Only needed for the raw-string constructor path -- +# Decision.ask()/.rewrite() below build the canonical outcome themselves. +_LEGACY_SPELLING = {"ask": ESCALATE, "rewrite": TRANSFORM} + + +class Decision: + """What a handler wants to happen. Adapters translate this to vendor dialect.""" + + __slots__ = ("context", "evidence", "outcome", "reason", "updated_input") + + #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). + DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) + + def __init__(self, outcome, reason=None, updated_input=None, evidence=None, context=None): + outcome = _LEGACY_SPELLING.get(outcome, outcome) + if outcome not in _CANONICAL_OUTCOMES: + raise ValueError("unknown outcome: %r" % (outcome,)) + self.outcome = outcome + self.reason = reason + self.updated_input = updated_input + self.evidence = evidence or {} + self.context = context + + @classmethod + def allow(cls, reason=None, evidence=None, context=None): + return cls(ALLOW, reason, evidence=evidence, context=context) + + @classmethod + def deny(cls, reason, evidence=None, context=None): + return cls(DENY, reason, evidence=evidence, context=context) + + @classmethod + def escalate(cls, reason, evidence=None, context=None): + """Defer the action to the host's own approval path (ACS `escalate`).""" + return cls(ESCALATE, reason, evidence=evidence, context=context) + + @classmethod + def ask(cls, reason, evidence=None, context=None): + """Deprecated alias of escalate() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.ask() is deprecated; use Decision.escalate()", DeprecationWarning, stacklevel=2) + return cls.escalate(reason, evidence=evidence, context=context) + + @classmethod + def transform(cls, updated_input, reason=None, evidence=None, context=None): + """Replace the tool input wholesale (ACS `transform`, at whole-value granularity).""" + return cls(TRANSFORM, reason, updated_input=updated_input, evidence=evidence, context=context) + + @classmethod + def rewrite(cls, updated_input, reason=None, evidence=None, context=None): + """Deprecated alias of transform() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.rewrite() is deprecated; use Decision.transform()", DeprecationWarning, stacklevel=2) + return cls.transform(updated_input, reason, evidence=evidence, context=context) + + @classmethod + def warn(cls, reason=None, evidence=None, context=None): + """Permit the action with no change, recording a warning (ACS `warn`).""" + return cls(WARN, reason, evidence=evidence, context=context) + + @classmethod + def vouch(cls, reason=None, evidence=None, context=None): + return cls(VOUCH, reason, evidence=evidence, context=context) + + def __repr__(self): # pragma: no cover - debugging aid + return "Decision(%s, %r)" % (self.outcome, self.reason) + + +def degraded_from(decision): + """What this decision was before the dispatcher reduced it, or None.""" + return (decision.evidence or {}).get("degraded_from") + + +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.lstrip()[:1] == "{": + try: + parsed = _json.loads(raw) + except (ValueError, RecursionError): + # JSONDecodeError is a ValueError; a string nested past the interpreter's + # limit raises RecursionError instead, and neither is an input to crash on. + return {} + if isinstance(parsed, dict): + return parsed + return {} + + +# ------------------------------------------------------------------------------ +# flat_decision family engine (trimmed to what this entry uses) + +UNREADABLE_NAME = "" + +def wire_name_of(raw, key): + """The event name under `key`: text, None when absent, UNREADABLE_NAME when not text. + + Total over anything `json.loads` can return: a payload that is not an object names no + event, and a name that is not a string is not one any adapter can map. + """ + name = raw.get(key) if isinstance(raw, dict) else None + if name is None or isinstance(name, str): + return name + return UNREADABLE_NAME + +def _wire_name(cfg, raw): + for key in cfg["claims"].get("event_key", ()): + name = wire_name_of(raw, key) + if name is not None: + return name + return None + +def _rejected_by_markers(c, raw): + for marker in c.get("reject_markers", ()): + if marker in raw: + return True + for key, values in c.get("reject_values", {}).items(): + if key in raw and raw[key] in values: + return True + for probe, markers in c.get("reject_markers_unless_probe", {}).items(): + if any(marker in raw for marker in markers) and not PROBES[probe](raw): + return True + return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) + +def _accepted_by_values(c, raw): + """True unless the entry names accepted values and the payload carries none of them.""" + values = c.get("accept_values") + return not values or any(key in raw and raw[key] in allowed for key, allowed in values.items()) + +def _accepted_by_markers(c, raw, name): + if not _accepted_by_values(c, raw): + return False + accept = c.get("accept_markers", ()) + if not accept or any(marker in raw for marker in accept): + return True + return any( + name == event_name and all(key in raw for key in required) + for event_name, required in c.get("accept_when_all", {}).items() + ) + +def _disqualified(cfg, c, raw, name): + """Every reason an entry declines a payload once its wire event name is known.""" + if name not in cfg["events"] and not (c.get("accept_any_name") and isinstance(name, str)): + return True + if "client_types" in c and raw.get("client_type") not in c["client_types"]: + return True + return _rejected_by_markers(c, raw) + +def hj_claims(cfg, raw): + """True when this payload matches the entry's marker discipline.""" + if not isinstance(raw, dict): + return False + c = cfg["claims"] + name = _wire_name(cfg, raw) + # A positive self-identification beats a shared event name, accept_names included. + if raw.get("client_type") in c.get("reject_client_types", ()): + return False + if name in c.get("accept_names", ()): + return True + return not _disqualified(cfg, c, raw, name) and _accepted_by_markers(c, raw, name) + +def _segment(node, part): + """One path segment: a dict key, or `key[N]` indexing the list under it.""" + if part.endswith("]") and "[" in part: + key, _, index = part[:-1].partition("[") + items = node.get(key) if isinstance(node, dict) else None + i = int(index) + return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None + return node.get(part) if isinstance(node, dict) else None + +def _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + +def _walk(node, path): + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None + if "[]." in path: + head, sub = path.split("[].", 1) + items = _list_at(node, head) + if items is None: + return None + joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) + return joined or None + for part in path.split("."): + node = _segment(node, part) + if node is None: + return None + return node + +def _lookup(raw, ti, key): + """One config key: a `tool_input.` path walks the decoded tool input, else the payload.""" + if key.startswith("tool_input."): + return _walk(ti, key[len("tool_input.") :]) + return _walk(raw, key) + +def _field(raw, ti, chain): + value = None + for key in chain: + if value: + break + value = value or _lookup(raw, ti, key) + return value + +_FIELD_META = ("tool_input", "content_only_for_write_tools", "stringify") + +def _tool_input_raw(cfg, raw): + if not isinstance(raw, dict): + return None + for key in cfg["fields"].get("tool_input", ("tool_input",)): + value = _walk(raw, key) + if value is not None: + return value + return None + +def _canonical_of(cfg, name): + """The canonical event for one wire name; an entry with no `events` at all (antigravity, + whose payloads never carry one) maps the shape-inferred name back through `wire_events`.""" + events = cfg["events"] + if not events: + return {wire: canonical for canonical, wire in cfg.get("wire_events", {}).items()}.get(name, UNKNOWN) + return events.get(name, UNKNOWN) + +def hj_parse(cfg, raw, wire=None): + """Normalise one payload along the entry's ordered field-fallback chains. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + ti = tool_input_of(_tool_input_raw(cfg, raw)) + fields = {name: _field(raw, ti, chain) for name, chain in cfg["fields"].items() if name not in _FIELD_META} + if cfg["fields"].get("content_only_for_write_tools") and fields.get("tool") not in cfg["tools"].get("write", ()): + fields["content"] = None + if isinstance(fields.get("output"), (dict, list)): + fields["output"] = _json.dumps(fields["output"]) + for name in cfg["fields"].get("stringify", ()): + if fields.get(name) is not None: + fields[name] = str(fields[name]) + return Event( + cfg["agent"], + _canonical_of(cfg, wire if wire is not None else _wire_name(cfg, raw)), + tool=fields.get("tool"), + command=fields.get("command"), + path=fields.get("path"), + content=fields.get("content"), + output=fields.get("output"), + prompt=fields.get("prompt"), + session_id=fields.get("session_id"), + tool_use_id=fields.get("tool_use_id"), + cwd=fields.get("cwd"), + raw=raw, + ) + +_ESCALATE_FROM_TRANSFORM = "escalate_from_transform" + +_TRANSFORM_MISSING_INPUT = "transform_missing_input" + +def hj_reverse(cfg): + """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides. + + One wire name per canonical event, which is a real limit and not an oversight (R3, gap 4). + Cursor is where it bites: `pre_tool` pins to `preToolUse`, and Cursor honours `ask` only at + `beforeShellExecution` / `beforeMCPExecution`. So installing at `pre_tool` forecloses `ask` + before dispatch is ever reached -- the runtime degrade to `deny` is honest about it, but the + install already chose. Deny-style policies, which is all that ships today, are unaffected. + + The day an ask-style Cursor policy exists, this map has to select by decision dialect + (deny -> `preToolUse`, ask -> `beforeShellExecution`) and become one-to-many. Recorded here + rather than built, so the resolution is not re-litigated from scratch; the three facts it + rests on are pinned in tests/test_cursor_ask_dialect.py. + """ + reverse = {} + for name, canonical in cfg["events"].items(): + if canonical != UNKNOWN: + reverse[canonical] = name + reverse.update(cfg.get("wire_events", {})) + return reverse + +def _context_value(v, decision): + if v.get("context_source") == "context": + return decision.context + if v.get("context_source") == "reason": + return decision.reason + return None + +def _context_body(name, value): + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "additionalContext": value}}), 0 + +def _note_for(v, decision, at_gate, missing_input): + notes = v.get("degrade_notes", {}) + if decision.outcome == ESCALATE: + if degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in notes: + return notes[_ESCALATE_FROM_TRANSFORM] + if at_gate and "escalate_gate" in notes: + return notes["escalate_gate"] + return notes.get("escalate") + if decision.outcome == TRANSFORM: + if missing_input and _TRANSFORM_MISSING_INPUT in notes: + return notes[_TRANSFORM_MISSING_INPUT] + return notes.get("transform") + return None + +def _default_for(v, decision, at_gate, wire=None): + gate_defaults = v.get("gate_reason_defaults", {}) + if wire in gate_defaults: + return gate_defaults[wire] + defaults = v.get("reason_defaults", {}) + key = {DENY: "deny", ESCALATE: "escalate", TRANSFORM: "transform"}.get(decision.outcome, "deny") + if at_gate and key + "_gate" in defaults: + return defaults[key + "_gate"] + return defaults.get(key, "blocked by policy") + +def _refusal_text(v, decision, at_gate, wire=None): + note = _note_for(v, decision, at_gate, decision.updated_input is None) + default = _default_for(v, decision, at_gate, wire) + if note and "%s" in note: + # A template note fills (the reason or its default, the wire event name) itself. + return note % (decision.reason or default, wire) + reason = decision.reason + if v.get("note_style") == "suffix": + reason = reason or default + return "%s (%s)" % (reason, note) if note else reason + text = "%s (%s)" % (reason, note) if reason and note else (note or reason) + return text or default + +def _g1_allow(v, decision, wire, name, words): + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + if wire in v.get("allow_silent_events", ()): + return "", 0 + if "allow" in words: + out = {"decision": words["allow"]} + if v.get("allow_context_key") and decision.outcome == ALLOW and value: + out[v["allow_context_key"]] = value + return _json.dumps(out), 0 + return "", 0 + +def _g1_transform(v, decision, name, words): + """None when the transform isn't representable here; `_g1` falls through to the block path.""" + if v.get("transform_grammar") == "hook_specific_tool_input": + return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0 + if decision.updated_input is None: + return None + if v.get("transform_grammar") == "top_level_updated_input": + out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input} + if decision.reason: + out["reason"] = decision.reason + return _json.dumps(out), 0 + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}}), 0 + +def _g1(v, gate, decision, wire, name): + """Block dialect: a top-level decision word, or silence/context where nothing is read.""" + words = dict(v.get("words", {})) + words.update(v.get("words_at", {}).get(wire, {})) + at_context_event = wire in v.get("context_events", ()) + if decision.outcome in (ALLOW, VOUCH, WARN): + return _g1_allow(v, decision, wire, name, words) + if decision.outcome == TRANSFORM and gate["honours_transform"]: + transformed = _g1_transform(v, decision, name, words) + if transformed is not None: + return transformed + if ( + decision.outcome == ESCALATE + and gate["honours_escalate"] + and "escalate" in words + # An escalate the dispatcher degraded a transform into is a block where the entry + # names that degradation (antigravity): prompting would offer the unmodified call. + and not (degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in v.get("degrade_notes", {})) + ): + reason = decision.reason or _default_for(v, decision, at_gate=True, wire=wire) + return _json.dumps({"decision": words["escalate"], "reason": reason}), 0 + out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, at_gate=False, wire=wire)} + if at_context_event and v.get("context_source") == "context" and decision.context: + out["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": decision.context} + return _json.dumps(out), 0 + +def hj_respond(cfg, decision, event, wire=None): + """(stdout_text, exit_code) in this entry's dialect for the gate the payload names. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + v = cfg["verdicts"] + if wire is None: + wire = _wire_name(cfg, event.raw or {}) + if wire in v.get("empty_object_events", ()): + return _json.dumps({}), 0 + if wire is None: + wire = v.get("default_wire_event") + if wire is None and v.get("missing_wire") == "reverse_map": + wire = hj_reverse(cfg).get(event.event) + name = wire if v.get("echo") == "payload" else hj_reverse(cfg).get(event.event, "PreToolUse") + gate = v["gates"].get(wire) + if gate is None: + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + return "", 0 + if gate["grammar"] == "G2": + return _g2(v, gate, decision, name) + if gate["grammar"] == "G7": + return _g7(v, decision, wire) + return _g1(v, gate, decision, wire, name) + +_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(): + if key in _WINDOWS_KEYS: + entry[key] = powershell_command(command) + else: + entry[key] = value + return entry + +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + +def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): + rules = [] + for ev in canonical_events: + name = reverse.get(ev) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) + 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 and name not in allow_silent: + entry["failClosed"] = True + hooks.setdefault(name, []).append(entry) + return {"version": 1, "hooks": hooks} + +def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + hooks.setdefault(name, []).append({"command": command}) + extra = hook_entry.get("also_wires", {}).get(ev) + if extra: + hooks.setdefault(extra, []).append({"command": command}) + return {"hooks": hooks} + +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + +def _default_wrapper(cfg, reverse, canonical_events, command, matcher): + hook_entry = cfg["hook_entry"] + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + entry = {"hooks": [_hook_dict(cfg, command)]} + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: + entry["matcher"] = matcher + hooks.setdefault(name, []).append(entry) + if hook_entry.get("group"): + return {hook_entry["group"]: hooks} + return hooks if hook_entry.get("bare") else {"hooks": hooks} + +def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_closed=True): + """The vendor's hooks-config fragment wiring `command` for these canonical events. + + `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the + entry says otherwise; a False installs an observer, not a gate. + """ + hook_entry = cfg["hook_entry"] + reverse = hj_reverse(cfg) + wrapper = hook_entry["wrapper"] + if wrapper == "flat_list": + return _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher) + if wrapper == "cursor": + return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) + if wrapper == "flat_entries": + return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) + return _default_wrapper(cfg, reverse, canonical_events, command, matcher) + + +# ------------------------------------------------------------------------------ +# openhands vendor config + engine binding + +AGENT = "openhands" + +VENDOR = {'agent': 'openhands', 'claims': {'event_key': ('event_type',), 'mode': 'marker', 'notes': "OpenHands names the event under `event_type`, a key no other adapter's payload uses, so the event key alone separates it (docs.openhands.dev hooks page, read 2026-10-11)."}, 'config_format': 'json', 'config_path': '.openhands/hooks.json', 'display': 'OpenHands', 'events': {'PostToolUse': 'post_tool', 'PreToolUse': 'pre_tool', 'SessionEnd': 'session_end', 'SessionStart': 'session_start', 'Stop': 'stop', 'UserPromptSubmit': 'prompt_submit'}, 'evidence': {'claims': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'flat_decision', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.file_text', 'tool_input.new_str'), 'cwd': ('working_dir',), 'output': ('tool_response',), 'path': ('tool_input.path',), 'prompt': ('message',), 'session_id': ('session_id',), 'tool': ('tool_name',)}, 'hook_entry': {'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'tools': {'shell': ('terminal',), 'write': ('file_editor',)}, 'verdicts': {'answer_events': ('PreToolUse', 'Stop', 'UserPromptSubmit'), 'bare_allow': 'silent', 'degrade_notes': {'escalate': 'OpenHands cannot prompt for confirmation', 'escalate_from_transform': 'OpenHands cannot modify a tool call', 'transform': 'OpenHands cannot modify a tool call'}, 'gates': {'PreToolUse': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'Stop': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'UserPromptSubmit': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'because', 'vocabulary': ('deny',), 'vocabulary_basis': 'verified', 'words': {'block': 'deny'}}} + + +def claims(raw): + return hj_claims(VENDOR, raw) + + +def parse(raw): + return hj_parse(VENDOR, raw) + + +def respond(decision, event): + return hj_respond(VENDOR, decision, event) + + +def hook_config(canonical_events, command, matcher=None): + return hook_entry_config(VENDOR, canonical_events, command, matcher) + + +# ------------------------------------------------------------------------------ +# runtime (agentseam dispatch, specialized to openhands) + +_VOUCH_SPEAKS = False +_WARN_SPEAKS = False +_TRANSFORM_EVENTS = frozenset(()) + + +def degrade(decision, event): + """Reduce a decision to what 'openhands' can actually honor, honestly. + + This is agentseam.dispatch.degrade(), specialized to one fixed agent so this file needs + no "import agentseam" -- no evidence establishes that an explicit approval word means anything beyond a plain allow here, so vouch degrades to one; see agentseam.allow_semantics. + """ + if decision.outcome == TRANSFORM and event.event not in _TRANSFORM_EVENTS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = TRANSFORM + return Decision.escalate( + decision.reason or "input requires modification before it can run", evidence=evidence + ) + if decision.outcome == VOUCH and not _VOUCH_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = VOUCH + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + if decision.outcome == WARN and not _WARN_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = WARN + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + return decision + + +# >>> 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' + +_CRASH_MARKERS = ('traceback (most recent call last)', 'syntax error', 'syntaxerror', 'unexpected eof') + +TOOL_UNKNOWN = 'unknown' + +ARGV_FALLBACK_ENV = 'CHOCK_ARGV_FALLBACK' + +CAN_ASK_ENV = 'CHOCK_CAN_ASK' + +_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 + +_BASH_PROBE_SECONDS = 10 + +GUARD_BLOCKED = 'blocked' + +GUARD_CLEAN = 'clean' + +GUARD_ASKED = 'asked' + +GUARD_UNCHECKED = 'unchecked' + +GUARD_ERRORED = 'errored' + +VERDICT_DENY = 'deny' + +VERDICT_ESCALATE = 'escalate' + +def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: + """The `--guard ` argument a vendored runtime was invoked with, or None.""" + if '--guard' in argv: + i = argv.index('--guard') + if i + 1 < len(argv): + return _chock_Path(argv[i + 1]) + return None + +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) + env.pop(CAN_ASK_ENV, None) + 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: + 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, 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, 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, tool: str='') -> str: + """`GUARD_BLOCKED` / `GUARD_ASKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + 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, tool: str='', *, can_ask: bool | None=None) -> tuple[str, str]: + """`run_guard`'s verdict plus the guard's own first line, which an ask carries to the user.""" + args, fallback = split_command(command) + if not args: + return (GUARD_UNCHECKED, '') + deadline = engine_deadline() + interpreter = find_interpreter(guard, deadline) + if interpreter is None: + return _no_interpreter(guard, deadline) + try: + env = {**interpreter_env(interpreter), 'CHOCK_RAW_COMMAND': command, 'CHOCK_TOOL': normalize_tool(tool)} + if fallback: + env[ARGV_FALLBACK_ENV] = '1' + env.update({} if can_ask is None else {CAN_ASK_ENV: '1' if can_ask else '0'}) + 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 {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) + return (GUARD_ERRORED, '') + output = ((proc.stderr or '') + (proc.stdout or '')).strip() + first_line = output.splitlines()[0].strip() if output else '' + if proc.returncode == GUARD_VIOLATION: + 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 '') + return (GUARD_BLOCKED, first_line) + if proc.returncode == GUARD_ASK_EXIT: + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + return (GUARD_ASKED, first_line) + if proc.returncode != 0: + print(f'chock: guard exited {proc.returncode}, not checked' + (f': {first_line[:120]}' if first_line else ''), file=sys.stderr) + return (GUARD_ERRORED, '') + return (GUARD_CLEAN, '') + +def 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: + if _chock_os.environ.get(GATE_LOG_ENV) == '0': + return + guard = guard.resolve() + if guard.parent.name != 'implementations': + return + artifact_root = None + for parent in guard.parents: + if (parent / '.chock').is_dir(): + artifact_root = parent / '.chock' + break + if artifact_root is None: + return + 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='', *, can_ask: bool | None=None) -> tuple[str, str] | None: + """Run the guard named on `argv` (`--guard `) against `command`.""" + guard = guard_path_from_argv(argv) + if guard is None: + return None + 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, can_ask=can_ask) + logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} + if verdict in logged: + log_outcome(guard, tool, verdict=logged[verdict]) + if verdict == GUARD_BLOCKED: + return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + if verdict == GUARD_ASKED: + return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') + if verdict == GUARD_ERRORED: + 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' + +_CREATE_TEXT = 'file_text' + +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 created_text(event): + """The whole file a write call carries: the adapter's content, else `tool_input.file_text`; None otherwise.""" + content = getattr(event, 'content', None) + if isinstance(content, str): + return content + text = _edit_call_input(event).get(_CREATE_TEXT) + return text if isinstance(text, str) else None + +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)) + +_PATCH_KEYS = ('command', 'input', 'patch') + +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 if isinstance(tool_input, str) else None + for key in _PATCH_KEYS if isinstance(tool_input, dict) else (): + if tool_input.get(key) is not None: + command = tool_input[key] + break + 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 '')) + +_PARENT = '..' + +_DRIVE_COLON = ':' + +_ROOTS_KEY = 'workspace_roots' + +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 _workspace_root(text): + """A workspace root as a path: `/C:/x` is `C:/x`; anything else as given.""" + if text[:1] == '/' and text[2:3] == _DRIVE_COLON and text[1:2].isalpha() and (text[3:4] in ('', '/', '\\')): + return text[1:] + return text + +def _holds(root, path): + """Whether `path`, absolute or relative, lies under `root`, compared the way `root` is spelled.""" + flavour = _chock_PureWindowsPath if root[1:2] == _DRIVE_COLON else _chock_PurePosixPath + try: + _folded(flavour(root) / str(path)).relative_to(flavour(root)) + except ValueError: + return False + return True + +def workspace_roots(event): + """The folders the client says the agent works in, normalised; [] when it names none.""" + raw = getattr(event, 'raw', None) + roots = raw.get(_ROOTS_KEY) if isinstance(raw, dict) else None + if not isinstance(roots, list): + return [] + return [_workspace_root(root) for root in roots if isinstance(root, str) and root] + +def workspace_root(event): + """The workspace root holding the event's path, else the first one; None when the client names none.""" + roots = workspace_roots(event) + path = getattr(event, 'path', None) + return next((root for root in roots if path and _holds(root, path)), roots[0] if roots else None) + +_SEVERITY = (VERDICT_DENY, VERDICT_ESCALATE, VERDICT_WARN) + +_GIT_DIR = '.git' + +def _repository(folder): + """The repository `folder` is in: the nearest folder holding `.git`, or None outside one.""" + here = _chock_Path(folder).absolute() + return next((f for f in (here, *here.parents) if (f / _GIT_DIR).exists()), None) + +def stop_roots(event, fallback): + """The event's `cwd` and every workspace root inside a repository, the first per repository; else [fallback].""" + by_repository = {} + for named in filter(None, (getattr(event, 'cwd', None), *workspace_roots(event))): + repository = _repository(named) + if repository is not None: + by_repository.setdefault(repository, _chock_Path(named)) + return list(by_repository.values()) or [fallback] + +def per_root(said): + """One (decision, judged files) for each root's own: the most severe verdict, findings under each root's name.""" + if len(said) == 1: + return said[0][1:] + spoken = [(root, decision) for root, decision, _judged in said if decision] + judged = {path: text for _root, _decision, files in said for path, text in files.items()} + if not spoken: + return (None, judged) + verdict = min((decision[0] for _root, decision in spoken), key=_SEVERITY.index) + return ((verdict, '\n'.join((f'In {root}:\n{decision[1]}' for root, decision in spoken))), judged) + +GATE_FLAG = '--gate' + +STOP_FLAG = '--stop' + +_GATE_DEPTH_TO_CHOCK = 3 + +_RUNNER_PARTS = ('bin', 'gate.py') + +_PACKAGED_RUNNER = 'gate.py' + +_GIT = 'git' + +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + +_DELETED = 'D' + +_RENAMED = 'R' + +VERDICT_DENY = 'deny' + +def gate_path_from_argv(argv): + """The `--gate ` argument a vendored runtime was invoked with, or None.""" + if GATE_FLAG in argv: + index = argv.index(GATE_FLAG) + if index + 1 < len(argv): + return _chock_Path(argv[index + 1]) + return None + +def runner_for(gate): + """The 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, 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 = created_text(event) + 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 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: + 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 = [] + for field in fields: + status, path = (field[:2], field[3:]) + 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, 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 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, 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(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)) + return runner_outcome(proc.returncode, proc.stderr) + +_EVENT_ARG = {'pre_tool': 'pre-tool-use', 'stop': 'stop'} + +PRE_TOOL = 'pre_tool' + +def root_for(gate): + """The repository this compiled gate belongs to, or None when the layout is not that.""" + parents = gate.resolve().parents + if len(parents) <= _GATE_DEPTH_TO_CHOCK: + return None + if parents[_GATE_DEPTH_TO_CHOCK - 1].name != 'compiled' or parents[_GATE_DEPTH_TO_CHOCK].name != '.chock': + return None + return parents[_GATE_DEPTH_TO_CHOCK].parent + +def repo_root_for(event, gate): + """The repository under judgement: the compiled layout's root, else the event's cwd, else its workspace root. + + Of several workspace roots, the one holding the written path; this process's cwd only when there are none. + """ + root = root_for(gate) + if root is not None: + return root + cwd = getattr(event, 'cwd', None) or workspace_root(event) + return _chock_Path(cwd) if cwd else _chock_Path.cwd() + +def writes_for(event, gate, deadline=None, why=None, root=None): + """What this event puts under judgement: the call's own text, or what the turn left behind (None: unlisted).""" + root = repo_root_for(event, gate) if root is None else root + if event.event == PRE_TOOL: + return writes_from_event(event, root) + return writes_from_worktree(root, 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, root): + """(decision, judged files): what the compiled gate says about this event in `root`, before a re-entered stop.""" + if not gate.exists(): + return (_missing_gate(gate), {}) + outside = outside_globs(gate) + why = [] + listed = writes_for(event, gate, deadline, why, root) + 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: + return None + deadline = engine_deadline() + if event.event == PRE_TOOL: + return _gate_says(gate, event, name, deadline, repo_root_for(event, gate))[0] + roots = [repo_root_for(event, gate)] if root_for(gate) else stop_roots(event, repo_root_for(event, gate)) + decision, judged = per_root([(root, *_gate_says(gate, event, name, deadline, root)) for root in roots]) + return settle_stop(event, roots[0], 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: {'event_type': 'PreToolUse'}, STOP: {'event_type': '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 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) +#: Per wire event mapped to pre_tool: whether the client shows a guard's ask as a prompt. +_CHOCK_CAN_ASK = {'PreToolUse': False} +_CHOCK_EVENT_KEYS = ('event_type', 'hook_event_name', 'hookEventName', 'agent_action_name') + + +def _chock_wire_name(raw): + names = (raw.get(key) for key in _CHOCK_EVENT_KEYS) if isinstance(raw, dict) else () + return next((name for name in names if isinstance(name, str)), None) + + +def _chock_can_ask(raw): + # An event this bundle cannot name, or one the vendor data does not list, cannot prompt. + return _CHOCK_CAN_ASK.get(_chock_wire_name(raw), False) + + +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: + verdict = evaluate(sys.argv[1:], event.command, event.tool or "", can_ask=_chock_can_ask(event.raw)) + if verdict is not None: + outcome, reason = verdict + return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + gated = evaluate_gate(sys.argv[1:], event) + if gated is not None: + return _spoken(gated) + called = evaluate_tool_call(sys.argv[1:], event) + if called is not None: + return _spoken(called) + return None + + +def handle(event): + # A door that cannot decide refuses. An exception escaping here would exit the hook + # with a traceback, which every client reads as a non-blocking error: fail-open, with + # the reason on a stderr nobody watches. The refusal carries the reason instead. + try: + return _judge(event) + except Exception as exc: # noqa: BLE001 -- any failure here must refuse, never fall through + return Decision.deny( + "chock could not check this call (%s: %s). Refusing rather than reporting an " + "allow it never established." % (type(exc).__name__, exc) + ) +# <<< agentseam handler <<< + + +def _coerce(result): + if result is None: + return Decision.allow() + if isinstance(result, Decision): + return result + raise TypeError("handle() must return a Decision or None, got %r" % (type(result),)) + + +def _read_payload(stream): + # Decode bytes ourselves rather than trust the platform locale -- a BOM'd or non-UTF-8 + # stdin must not silently disable the gate. See agentseam.dispatch for the incident + # this guards: a Windows console's cp1252 layer turning a UTF-8 BOM into three bytes + # json cannot parse, with the hook allowing everything while claiming enforcement. + buffer = getattr(stream, "buffer", None) + if buffer is not None: + return buffer.read().decode("utf-8-sig", errors="replace") + return stream.read().lstrip("\ufeff") + + +def _emit(out, text): + buffer = getattr(out, "buffer", None) + if buffer is not None: + buffer.write(text.encode("utf-8")) + buffer.flush() + else: + out.write(text) + out.flush() + + +def _report(text): + # Best effort, never raising: a diagnostic must not pre-empt the refusal it accompanies. + # A hook's stderr is whatever the host gave it -- closed (then sys.stderr is None, and + # the traceback module's default print would land on STDOUT, inside the verdict), a + # console code page that cannot hold the payload text quoted in the exception, a pipe + # nobody reads. + try: + sys.stderr.write(text) + sys.stderr.flush() + except Exception: + 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) + if event.event == UNKNOWN: + # A vendor event this adapter has no mapping for. handle() is not called: it + # reasons about the canonical vocabulary, and handing it something outside that + # vocabulary invites a decision made on a false premise. + return "", 0 + try: + 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 + # failure is printed for the operator and the gate answers deny in 'openhands''s + # own dialect. Only the failure's class is named to the host: its message may quote + # the very payload content the policy was inspecting. + _report(traceback.format_exc()) + decision = Decision.deny( + "policy handler failed (%s); refusing rather than allowing what it could not judge" + % sys.exc_info()[0].__name__ + ) + return respond(degrade(decision, event), event) + + +def main(stdin=None, stdout=None, exit=True): + """Read one payload from stdin, dispatch, emit the 'openhands' response, exit.""" + stream = stdin if stdin is not None else sys.stdin + out = stdout if stdout is not None else sys.stdout + try: + raw = json.loads(_read_payload(stream)) + except Exception: + raw = None + if not isinstance(raw, dict): + text, code = _chock_unreadable() + if text: + _emit(out, text) + if exit: + sys.exit(code) + return code + try: + text, code = _decide(raw) + except Exception: + # Past the handler, which _decide() already answers for: a fault in this file on a + # payload it did decode. With no Event to answer in dialect, the one refusal left is + # the host's blocking exit code, and nothing on stdout to be misread as a verdict. + _report(traceback.format_exc()) + text, code = "", 2 + if text: + _emit(out, text) + if exit: + sys.exit(code) + return code + + +if __name__ == "__main__": # pragma: no cover + main() + diff --git a/tests/fixtures/runtime_goldens/tabnine.py b/tests/fixtures/runtime_goldens/tabnine.py index 6d7f192c..e490c345 100644 --- a/tests/fixtures/runtime_goldens/tabnine.py +++ b/tests/fixtures/runtime_goldens/tabnine.py @@ -29,6 +29,7 @@ from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings +import zlib as _zlib # ------------------------------------------------------------------------------ # contract (agentseam 0.3.5) @@ -248,12 +249,22 @@ def _rejected_by_markers(c, raw): for marker in c.get("reject_markers", ()): if marker in raw: return True + for key, values in c.get("reject_values", {}).items(): + if key in raw and raw[key] in values: + return True for probe, markers in c.get("reject_markers_unless_probe", {}).items(): if any(marker in raw for marker in markers) and not PROBES[probe](raw): return True return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) +def _accepted_by_values(c, raw): + """True unless the entry names accepted values and the payload carries none of them.""" + values = c.get("accept_values") + return not values or any(key in raw and raw[key] in allowed for key, allowed in values.items()) + def _accepted_by_markers(c, raw, name): + if not _accepted_by_values(c, raw): + return False accept = c.get("accept_markers", ()) if not accept or any(marker in raw for marker in accept): return True @@ -292,12 +303,25 @@ def _segment(node, part): return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None return node.get(part) if isinstance(node, dict) else None +def _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + def _walk(node, path): - """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None if "[]." in path: head, sub = path.split("[].", 1) - items = _walk(node, head) - if not isinstance(items, (list, tuple)): + items = _list_at(node, head) + if items is None: return None joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) return joined or None @@ -327,7 +351,7 @@ def _tool_input_raw(cfg, raw): if not isinstance(raw, dict): return None for key in cfg["fields"].get("tool_input", ("tool_input",)): - value = raw.get(key) + value = _walk(raw, key) if value is not None: return value return None @@ -518,6 +542,8 @@ def hj_respond(cfg, decision, event, wire=None): return "", 0 if gate["grammar"] == "G2": return _g2(v, gate, decision, name) + if gate["grammar"] == "G7": + return _g7(v, decision, wire) return _g1(v, gate, decision, wire, name) _WINDOWS_KEYS = ("commandWindows", "windows") @@ -533,16 +559,25 @@ def _hook_dict(cfg, command): entry[key] = value return entry +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): rules = [] for ev in canonical_events: name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: - rule["matcher"] = matcher - rules.append(rule) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) return rules def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): @@ -571,6 +606,10 @@ def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): hooks.setdefault(extra, []).append({"command": command}) return {"hooks": hooks} +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] hooks = {} @@ -601,6 +640,8 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) if wrapper == "flat_entries": return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) return _default_wrapper(cfg, reverse, canonical_events, command, matcher) diff --git a/tests/fixtures/runtime_goldens/vscode_copilot.py b/tests/fixtures/runtime_goldens/vscode_copilot.py index c7c3a84a..82eaeddf 100644 --- a/tests/fixtures/runtime_goldens/vscode_copilot.py +++ b/tests/fixtures/runtime_goldens/vscode_copilot.py @@ -316,7 +316,7 @@ def _name(raw): def claims(raw): """True for a payload from either product.""" - if not isinstance(raw, dict): + if not isinstance(raw, dict) or _is_grok_build(raw): return False name = _name(raw) if name in EVENT_MAP and _VSCODE_ENVELOPE in raw and "turn_id" not in raw: @@ -334,6 +334,12 @@ def claims(raw): return raw.get("tool_name") in MEMORY_TOOLS and isinstance(ti, dict) and "command" in ti +def _is_grok_build(raw): + """Grok Build's envelope: hookEventName is hook_event_name's snake_case twin (pre_tool_use, PreToolUse).""" + snake, pascal = raw.get("hookEventName"), raw.get("hook_event_name") + return isinstance(pascal, str) and str(snake).islower() and str(snake).replace("_", "") == pascal.lower() + + 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") diff --git a/tests/fixtures/runtime_goldens/windsurf.py b/tests/fixtures/runtime_goldens/windsurf.py index 586e0904..4da24d18 100644 --- a/tests/fixtures/runtime_goldens/windsurf.py +++ b/tests/fixtures/runtime_goldens/windsurf.py @@ -29,6 +29,7 @@ from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings +import zlib as _zlib # ------------------------------------------------------------------------------ # contract (agentseam 0.3.5) @@ -253,12 +254,25 @@ def _segment(node, part): return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None return node.get(part) if isinstance(node, dict) else None +def _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + def _walk(node, path): - """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None if "[]." in path: head, sub = path.split("[].", 1) - items = _walk(node, head) - if not isinstance(items, (list, tuple)): + items = _list_at(node, head) + if items is None: return None joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) return joined or None @@ -288,7 +302,7 @@ def _tool_input_raw(cfg, raw): if not isinstance(raw, dict): return None for key in cfg["fields"].get("tool_input", ("tool_input",)): - value = raw.get(key) + value = _walk(raw, key) if value is not None: return value return None @@ -406,16 +420,25 @@ def _hook_dict(cfg, command): entry[key] = value return entry +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): rules = [] for ev in canonical_events: name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: - rule["matcher"] = matcher - rules.append(rule) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) return rules def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): @@ -444,6 +467,10 @@ def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): hooks.setdefault(extra, []).append({"command": command}) return {"hooks": hooks} +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] hooks = {} @@ -474,6 +501,8 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) if wrapper == "flat_entries": return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) return _default_wrapper(cfg, reverse, canonical_events, command, matcher) _MCP_EVENTS = ("pre_mcp_tool_use", "post_mcp_tool_use") diff --git a/tests/runtime_payloads.py b/tests/runtime_payloads.py new file mode 100644 index 00000000..b2efa3cb --- /dev/null +++ b/tests/runtime_payloads.py @@ -0,0 +1,128 @@ +"""One shell pre-tool payload per vendored runtime, and the deny word each one's wire carries.""" + +from __future__ import annotations + +PAYLOADS = { + "claude_code": lambda c: { + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": c}, + "session_id": "s", + "transcript_path": "/t", + "permission_mode": "default", + }, + "vscode_copilot": lambda c: { + "hook_event_name": "PreToolUse", + "timestamp": "2026-08-31T00:00:00Z", + "tool_name": "Bash", + "tool_input": {"command": c}, + "tool_use_id": "1", + }, + "cursor": lambda c: { + "hook_event_name": "beforeShellExecution", + "conversation_id": "a", + "generation_id": "b", + "cursor_version": "3.17.8", + "workspace_roots": ["/x"], + "command": c, + "cwd": "/x", + "sandbox": False, + }, + "codex_cli": lambda c: { + "hook_event_name": "PreToolUse", + "turn_id": "t1", + "tool_name": "Bash", + "tool_input": {"command": c}, + }, + "antigravity": lambda c: { + "toolCall": {"name": "run_command", "args": {"CommandLine": c, "Cwd": "/x"}}, + "conversationId": "c1", + "stepIdx": 1, + "workspacePaths": ["/x"], + }, + "devin": lambda c: { + "hook_event_name": "PreToolUse", + "prompt_id": "p1", + "tool_name": "Bash", + "tool_input": {"command": c}, + "session_id": "s", + }, + "gemini_cli": lambda c: { + "hook_event_name": "BeforeTool", + "tool_name": "run_shell_command", + "tool_input": {"command": c}, + "session_id": "s", + }, + "grok": lambda c: { + "hookEventName": "PreToolUse", + "toolName": "Bash", + "toolInput": {"command": c}, + "sessionId": "s", + }, + "tabnine": lambda c: { + "hook_event_name": "BeforeTool", + "timestamp": "2026-08-31T00:00:00Z", + "tool_name": "shell", + "tool_input": {"command": c}, + }, + "windsurf": lambda c: { + "trajectory_id": "t1", + "tool_info": {"command_line": c}, + "cwd": "/x", + }, + # The next four: agentseam's golden pre_tool payloads (tests/fixtures/golden at bebbfa6), shell tool. + "factory_droid": lambda c: { + "hook_event_name": "PreToolUse", + "permission_mode": "auto-medium", + "session_id": "s", + "transcript_path": "/x/.factory/projects/x/session.jsonl", + "cwd": "/x", + "tool_name": "Execute", + "tool_input": {"command": c}, + }, + "goose": lambda c: { + "event": "PreToolUse", + "matcher_context": "shell", + "session_id": "s", + "tool_call_id": "call-1", + "tool_name": "shell", + "tool_input": {"command": c}, + "working_dir": "/x", + }, + "openhands": lambda c: { + "event_type": "PreToolUse", + "session_id": "s", + "tool_name": "terminal", + "tool_input": {"command": c}, + "working_dir": "/x", + }, + "mistral_vibe": lambda c: { + "hook_event_name": "pre_tool", + "parent_session_id": None, + "session_id": "s", + "tool_call_id": "call-1", + "tool_name": "bash", + "tool_input": {"command": c}, + "transcript_path": "/x/.vibe/session.jsonl", + "cwd": "/x", + }, +} + +#: The deny each vendor's wire actually carries, witnessed by the deny-guard fixture below: +#: `block` is the devin-family spelling, `exit-2` windsurf's wordless exit-code grammar (G5). +DENY_ON_THE_WIRE = { + "claude_code": "deny", + "codex_cli": "deny", + "cursor": "deny", + "vscode_copilot": "deny", + "antigravity": "deny", + "devin": "block", + "gemini_cli": "deny", + "grok": "deny", + "tabnine": "deny", + "windsurf": "exit-2", + "factory_droid": "deny", + "goose": "block", + "openhands": "deny", + "mistral_vibe": "deny", +} diff --git a/tests/test_generic_hooks_install.py b/tests/test_generic_hooks_install.py index 49f769c4..40065697 100644 --- a/tests/test_generic_hooks_install.py +++ b/tests/test_generic_hooks_install.py @@ -4,6 +4,7 @@ import json import sys +import tomllib from pathlib import Path import pytest @@ -33,7 +34,8 @@ def _repo(tmp_path: Path) -> Path: def _config(repo: Path, vendor: str) -> dict: - return json.loads((repo / vendors.config_path(vendor)).read_text(encoding="utf-8")) + text = (repo / vendors.config_path(vendor)).read_text(encoding="utf-8") + return tomllib.loads(text) if vendors.config_format(vendor) == "toml" else json.loads(text) def _commands(node) -> list[str]: diff --git a/tests/test_guard_can_ask.py b/tests/test_guard_can_ask.py index bdc15306..8ae3e323 100644 --- a/tests/test_guard_can_ask.py +++ b/tests/test_guard_can_ask.py @@ -21,6 +21,8 @@ #: The plan's table (org-plan plan/one-oh-gaps.md C1b): each vendor's value at its shell pre-tool event. PLANNED = {"claude_code": "1", "vscode_copilot": "1", "cursor": "1", "codex_cli": "0", "devin": "0"} +#: E-ALL's agents: agentseam records `honours_escalate: false` at each one's pre-tool gate (bebbfa6). +PLANNED.update(dict.fromkeys(("factory_droid", "goose", "mistral_vibe", "openhands"), "0")) _HOOK = {"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": "ls"}} _CURSOR = {"command": "ls", "cwd": ".", "conversation_id": "c", "workspace_roots": ["."]} @@ -34,6 +36,10 @@ "tabnine": ("BeforeTool", {**_HOOK, "hook_event_name": "BeforeTool"}), "vscode_copilot": ("PreToolUse", _HOOK), "windsurf": ("pre_run_command", {"agent_action_name": "pre_run_command", "tool_info": {"command_line": "ls"}}), + "factory_droid": ("PreToolUse", {**_HOOK, "tool_name": "Execute", "permission_mode": "auto-medium"}), + "goose": ("PreToolUse", {"event": "PreToolUse", "tool_name": "shell", "tool_input": {"command": "ls"}}), + "mistral_vibe": ("pre_tool", {**_HOOK, "hook_event_name": "pre_tool", "tool_name": "bash", "tool_call_id": "1"}), + "openhands": ("PreToolUse", {"event_type": "PreToolUse", "tool_name": "terminal", "tool_input": {"command": "ls"}}), } #: Cursor events beyond the shell one: the value follows the event that fired, not the agent. CURSOR_EVENTS = [ diff --git a/tests/test_guard_fail_to_ask.py b/tests/test_guard_fail_to_ask.py index 21886745..fd12ede4 100644 --- a/tests/test_guard_fail_to_ask.py +++ b/tests/test_guard_fail_to_ask.py @@ -10,6 +10,7 @@ import pytest from agentseam import contract as _contract +from runtime_payloads import DENY_ON_THE_WIRE, PAYLOADS from chock import evidence from chock.gate import budget, guard_runner, runtime_bundle @@ -34,92 +35,6 @@ def make_guard(tmp_path: Path, name: str, body: str) -> Path: return guard -PAYLOADS = { - "claude_code": lambda c: { - "hook_event_name": "PreToolUse", - "tool_name": "Bash", - "tool_input": {"command": c}, - "session_id": "s", - "transcript_path": "/t", - "permission_mode": "default", - }, - "vscode_copilot": lambda c: { - "hook_event_name": "PreToolUse", - "timestamp": "2026-08-31T00:00:00Z", - "tool_name": "Bash", - "tool_input": {"command": c}, - "tool_use_id": "1", - }, - "cursor": lambda c: { - "hook_event_name": "beforeShellExecution", - "conversation_id": "a", - "generation_id": "b", - "cursor_version": "3.17.8", - "workspace_roots": ["/x"], - "command": c, - "cwd": "/x", - "sandbox": False, - }, - "codex_cli": lambda c: { - "hook_event_name": "PreToolUse", - "turn_id": "t1", - "tool_name": "Bash", - "tool_input": {"command": c}, - }, - "antigravity": lambda c: { - "toolCall": {"name": "run_command", "args": {"CommandLine": c, "Cwd": "/x"}}, - "conversationId": "c1", - "stepIdx": 1, - "workspacePaths": ["/x"], - }, - "devin": lambda c: { - "hook_event_name": "PreToolUse", - "prompt_id": "p1", - "tool_name": "Bash", - "tool_input": {"command": c}, - "session_id": "s", - }, - "gemini_cli": lambda c: { - "hook_event_name": "BeforeTool", - "tool_name": "run_shell_command", - "tool_input": {"command": c}, - "session_id": "s", - }, - "grok": lambda c: { - "hookEventName": "PreToolUse", - "toolName": "Bash", - "toolInput": {"command": c}, - "sessionId": "s", - }, - "tabnine": lambda c: { - "hook_event_name": "BeforeTool", - "timestamp": "2026-08-31T00:00:00Z", - "tool_name": "shell", - "tool_input": {"command": c}, - }, - "windsurf": lambda c: { - "trajectory_id": "t1", - "tool_info": {"command_line": c}, - "cwd": "/x", - }, -} - -#: The deny each vendor's wire actually carries, witnessed by the deny-guard fixture below: -#: `block` is the devin-family spelling, `exit-2` windsurf's wordless exit-code grammar (G5). -DENY_ON_THE_WIRE = { - "claude_code": "deny", - "codex_cli": "deny", - "cursor": "deny", - "vscode_copilot": "deny", - "antigravity": "deny", - "devin": "block", - "gemini_cli": "deny", - "grok": "deny", - "tabnine": "deny", - "windsurf": "exit-2", -} - - def run(runtimes: dict[str, Path], agent: str, guard: Path, command: str) -> subprocess.CompletedProcess: """Errors on DeprecationWarning: no path may lean on an agentseam spelling 0.2.0 retired.""" payload = json.dumps(PAYLOADS[agent](command)).encode("utf-8") diff --git a/tests/test_init_preserves_adopter_files.py b/tests/test_init_preserves_adopter_files.py index 9bf1440c..f96454db 100644 --- a/tests/test_init_preserves_adopter_files.py +++ b/tests/test_init_preserves_adopter_files.py @@ -54,29 +54,29 @@ def test_content_outside_the_marker_survives_a_second_init(onboarded: Path) -> N def test_a_deselected_wrapper_with_edits_is_not_deleted(tmp_path: Path) -> None: """Deleting a file the adopter added content to destroys it as surely as overwriting it.""" repo = init_repo(tmp_path) - assert _init(repo, "--agents", "grok") == 0 - grok_md = repo / ".grok" / "GROK.md" - _append(grok_md) + assert _init(repo, "--agents", "junie") == 0 + wrapper = repo / ".junie" / "guidelines.md" + _append(wrapper) assert _init(repo, "--agents", "claude") == 0 - assert grok_md.exists(), "init deleted an adopter-edited wrapper" - after = grok_md.read_text(encoding="utf-8") + assert wrapper.exists(), "init deleted an adopter-edited wrapper" + after = wrapper.read_text(encoding="utf-8") assert "never_deploy(on: friday)" in after, "the adopter's own content was deleted along with chock's block" - assert "Authoritative rules and conventions" not in after, "chock's block should be gone once grok is deselected" + assert "Authoritative rules and conventions" not in after, "chock's block should be gone once junie is deselected" def test_a_wrapper_with_no_adopter_content_is_deleted_on_deselect(tmp_path: Path) -> None: """The mirror case: nothing but chock's own block is an orphan, not adopter content.""" repo = init_repo(tmp_path) - assert _init(repo, "--agents", "grok") == 0 - grok_md = repo / ".grok" / "GROK.md" - assert grok_md.exists() + assert _init(repo, "--agents", "junie") == 0 + wrapper = repo / ".junie" / "guidelines.md" + assert wrapper.exists() assert _init(repo, "--agents", "claude") == 0 - assert not grok_md.exists(), "an orphaned wrapper with no adopter content was left behind" - assert not grok_md.parent.exists(), "its now-empty parent directory was left behind" + assert not wrapper.exists(), "an orphaned wrapper with no adopter content was left behind" + assert not wrapper.parent.exists(), "its now-empty parent directory was left behind" def test_the_marker_region_is_refreshed_with_no_force_needed(onboarded: Path) -> None: diff --git a/tests/test_install_clients.py b/tests/test_install_clients.py index b1b0a1bd..53085b3d 100644 --- a/tests/test_install_clients.py +++ b/tests/test_install_clients.py @@ -24,8 +24,8 @@ "cursor": ("chock-guardrails", ".cursor-plugin/plugin.json", []), "devin": ("chock-guardrails", ".devin-plugin/plugin.json", []), } -# Clients chock has not witnessed an install in; Claude Code, Codex, Copilot and Devin are witnessed. -UNWITNESSED = ("cursor",) +# Clients chock has not witnessed an install in; every client is witnessed (Cursor: W-CUR, 2026-10-11). +UNWITNESSED: tuple[str, ...] = () BINARY = {"claude-code": "claude", "copilot": "copilot", "codex": "codex", "devin": "devin"} @@ -128,7 +128,8 @@ def test_the_flag_overrides_the_selections_client_and_the_marker_records_it(cata "once its hooks are trusted in /hooks: refuses a matched shell command", "once its hooks are trusted in /hooks: blocks on an agent's file writes", ), - ("copilot", "refuses a matched shell command", "blocks at turn end only; the write itself is not judged"), + # Copilot CLI refused the write before it landed (org-plan witness/results/witness-copilot-2026-10-11.md). + ("copilot", "refuses a matched shell command", "blocks on an agent's file writes and at turn end"), ( "devin", "best-effort, fails open: refuses a matched shell command", @@ -157,7 +158,8 @@ def test_client_warnings_say_untested_for_every_unwitnessed_client(catalog, tmp_ assert ("warning: Untested: chock has not witnessed an install" in out) == (client in UNWITNESSED) assert ("trust them in /hooks" in out) == (client == "codex") assert ("fails open: a hook that errors" in out) == (client == "devin") - assert ("only at turn end" in out) == (client in ("copilot", "devin")) + assert ("only at turn end" in out) == (client == "devin") + assert ("in VS Code agent mode that is unmeasured" in out) == (client == "copilot") assert ("Allow Local Plugin Imports" in out) == (client == "cursor") diff --git a/tests/test_surface_doc_matches_code.py b/tests/test_surface_doc_matches_code.py index 92429a44..31c7edc8 100644 --- a/tests/test_surface_doc_matches_code.py +++ b/tests/test_surface_doc_matches_code.py @@ -31,6 +31,8 @@ "kimi code": "kimi-code", "vs code": "vscode", "antigravity cli": "antigravity", + "factory droid": "factory-droid", + "mistral vibe": "mistral-vibe", } diff --git a/tests/test_sync_every_adapter.py b/tests/test_sync_every_adapter.py new file mode 100644 index 00000000..8cafa795 --- /dev/null +++ b/tests/test_sync_every_adapter.py @@ -0,0 +1,162 @@ +"""E-ALL: `chock sync` reaches every agentseam adapter whose hook config sits in the repository.""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +import pytest +from agentseam import tier_table +from agentseam.vendor_config import VENDOR_CONFIG +from conftest import baseline_policy, init_repo + +from chock import vendors +from chock.hooks import sync_notes +from chock.hooks.in_agent_install import WIRED_VENDORS, installed_policy_ids +from chock.hooks.in_agent_toml import OWNER +from chock.hooks.runtime_vendor import runtime_rel +from chock.scaffold.recompile import HookWiringError, recompile + +#: The agents E-ALL added, by chock agent name. Each answers in its own grammar (tests/test_guard_fail_to_ask.py). +NEW = {"factory-droid": "factory_droid", "goose": "goose", "openhands": "openhands", "mistral-vibe": "mistral_vibe"} +#: Adapters sync does not wire yet, and why: a hooks directory runs one command per event, and a +#: home-anchored config is outside the repository. A new upstream adapter lands in one list or the other. +NOT_WIRED = {"cline": "hook_dir", "junie": "home", "kimi_code": "home"} +POLICIES = ("block-destructive-commands", "block-invisible-unicode") + + +def _repo(tmp_path: Path) -> Path: + repo = init_repo(tmp_path) + for policy in POLICIES: + shutil.copytree(baseline_policy(policy), repo / ".agents" / "policies" / policy) + return repo + + +def _tree(repo: Path) -> dict[str, bytes]: + return { + p.relative_to(repo).as_posix(): p.read_bytes() + for p in repo.rglob("*") + if p.is_file() and ".git" not in p.relative_to(repo).parts + } + + +def test_every_adapter_is_a_selectable_agent() -> None: + assert set(VENDOR_CONFIG) <= set(vendors.CHOCK_AGENT.values()) + for name, vendor in NEW.items(): + assert vendors.CHOCK_AGENT[name] == vendor + + +def test_every_adapter_is_wired_or_named_here_with_its_reason() -> None: + unwired = {v for v in VENDOR_CONFIG if v not in WIRED_VENDORS} + assert unwired == set(NOT_WIRED) + for vendor, reason in NOT_WIRED.items(): + home = str(VENDOR_CONFIG[vendor]["config_path"]).startswith("~") + assert (reason == "home") == home + assert (reason == "hook_dir") == (vendors.config_format(vendor) == "hook_dir") + + +@pytest.mark.parametrize("name", sorted(NEW)) +def test_sync_writes_the_vendors_documented_hook_file(name: str, tmp_path: Path) -> None: + vendor = NEW[name] + repo = _repo(tmp_path) + recompile(repo, [name], skip_hooks=False) + + config = repo / vendors.config_path(vendor) + assert config.is_file() + assert (repo / runtime_rel(vendor)).is_file() + assert runtime_rel(vendor).as_posix() in config.read_text(encoding="utf-8") + assert set(POLICIES) <= installed_policy_ids(repo, vendor) + + +def test_goose_gets_chocks_own_plugin_directory() -> None: + assert vendors.config_path("goose") == ".agents/plugins/chock/hooks/hooks.json" + + +@pytest.mark.parametrize("name", sorted(NEW)) +def test_a_second_sync_changes_nothing(name: str, tmp_path: Path) -> None: + repo = _repo(tmp_path) + recompile(repo, [name], skip_hooks=False) + before = _tree(repo) + + recompile(repo, [name], skip_hooks=False) + + assert _tree(repo) == before + + +def _foreign_json(vendor: str) -> dict: + """A hook entry chock does not own, in the vendor's own document shape.""" + return vendors.pre_tool_hook_config(vendor, "./their-own-hook.sh", "Shell") + + +@pytest.mark.parametrize("name", sorted(n for n, v in NEW.items() if vendors.config_format(v) == "json")) +def test_a_strangers_json_entry_survives_sync_and_deselect(name: str, tmp_path: Path) -> None: + vendor = NEW[name] + repo = _repo(tmp_path) + config = repo / vendors.config_path(vendor) + config.parent.mkdir(parents=True, exist_ok=True) + config.write_text(json.dumps(_foreign_json(vendor)), encoding="utf-8") + + recompile(repo, [name], skip_hooks=False) + assert "./their-own-hook.sh" in config.read_text(encoding="utf-8") + + recompile(repo, ["claude"], skip_hooks=False) + assert json.loads(config.read_text(encoding="utf-8")) == _foreign_json(vendor) + assert not (repo / runtime_rel(vendor)).exists() + + +FOREIGN_RULE = '[[hooks]]\nname = "theirs"\ntype = "pre_tool"\ncommand = "./their-own-hook.sh"\n' + + +def test_vibe_rules_are_one_owned_block_beside_a_strangers_rule(tmp_path: Path) -> None: + repo = _repo(tmp_path) + config = repo / vendors.config_path("mistral_vibe") + config.parent.mkdir(parents=True) + config.write_text(FOREIGN_RULE, encoding="utf-8") + + recompile(repo, ["mistral-vibe"], skip_hooks=False) + text = config.read_text(encoding="utf-8") + assert text.startswith(FOREIGN_RULE) + assert text.count(f">>> agentseam >>> {OWNER}") == 1 + assert "timeout = 45" in text, "the vendor's documented timeout field carries chock's budget" + + recompile(repo, ["claude"], skip_hooks=False) + assert config.read_text(encoding="utf-8") == FOREIGN_RULE + + +def test_vibe_file_chock_alone_wrote_is_removed_on_deselect(tmp_path: Path) -> None: + repo = _repo(tmp_path) + recompile(repo, ["mistral-vibe"], skip_hooks=False) + config = repo / vendors.config_path("mistral_vibe") + assert config.is_file() + + recompile(repo, ["claude"], skip_hooks=False) + + assert not config.exists() + assert not (repo / runtime_rel("mistral_vibe")).exists() + + +def test_vibe_refuses_a_file_it_cannot_read(tmp_path: Path) -> None: + repo = _repo(tmp_path) + config = repo / vendors.config_path("mistral_vibe") + config.parent.mkdir(parents=True) + config.write_bytes(b"\xff\xfe not text") + + with pytest.raises(HookWiringError, match="not readable text"): + recompile(repo, ["mistral-vibe"], skip_hooks=False) + + assert config.read_bytes() == b"\xff\xfe not text" + + +@pytest.mark.parametrize("vendor", sorted(NEW.values())) +def test_sync_says_agentseams_row_and_that_the_agent_is_untested(vendor: str) -> None: + row = next(r for r in tier_table.rows() if r["agent"] == vendor) + lines = sync_notes.notes(vendor) + + assert any(row["basis"] in line and row["grade"] in line and row["recorded"] in line for line in lines) + assert any("Untested" in line for line in lines) + + +def test_a_witnessed_agent_is_not_called_untested() -> None: + assert sync_notes.witnessed("vscode_copilot") + assert not any("Untested" in line for line in sync_notes.notes("vscode_copilot")) diff --git a/tests/test_tool_call_gate.py b/tests/test_tool_call_gate.py index 21ed44d2..1dfe20e0 100644 --- a/tests/test_tool_call_gate.py +++ b/tests/test_tool_call_gate.py @@ -13,7 +13,17 @@ from chock.hooks.in_agent_install import WIRED_VENDORS, install_hooks, installed_policy_ids from chock.validation.loading import schema_validator -TOOL_CALL_VENDORS = {"claude_code", "codex_cli", "cursor", "gemini_cli", "vscode_copilot"} +TOOL_CALL_VENDORS = { + "claude_code", + "codex_cli", + "cursor", + "factory_droid", + "gemini_cli", + "goose", + "mistral_vibe", + "openhands", + "vscode_copilot", +} NO_EVIDENCE = {"antigravity", "devin", "grok", "tabnine", "windsurf"}