From d4be53efbe91788ed886890f863fdfc5d80833ae Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 12:32:20 -0400 Subject: [PATCH 1/9] Adopt agentseam 0.3.4: PowerShell shell matcher, Codex write gate, verdict channel Regenerated with agentseam 0.3.4: Claude Code's shell guards match Bash|PowerShell (Windows' default shell tool), Codex gets a pre-write gate on apply_patch, and every vendored runtime keeps a handler's stdout off the verdict channel. Sync now prints the trust step Codex needs. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Claude --- .chock/bin/claude_code.py | 52 +++++++- .chock/bin/codex_cli.py | 59 ++++++++-- .chock/bin/cursor.py | 50 +++++++- .chock/bin/devin.py | 52 +++++++- .chock/bin/gemini_cli.py | 52 +++++++- .chock/bin/grok.py | 50 +++++++- .chock/bin/tabnine.py | 50 +++++++- .chock/bin/vscode_copilot.py | 111 ++++++++++++++++-- .chock/bin/windsurf.py | 50 +++++++- .../pre-tool-use/codex_cli-hooks.json | 2 +- .../pre-tool-use/pretooluse.json | 2 +- .../pre-tool-use/codex_cli-hooks.json | 2 +- .../pre-tool-use/pretooluse.json | 2 +- .../pre-tool-use/codex_cli-write-hooks.json | 16 +++ .../stop/codex_cli-hooks.json | 2 +- .../pre-tool-use/codex_cli-hooks.json | 2 +- .../pre-tool-use/pretooluse.json | 2 +- .../pre-tool-use/codex_cli-hooks.json | 2 +- .../pre-tool-use/pretooluse.json | 2 +- .claude/settings.json | 8 +- .codex/hooks.json | 20 +++- chock.lock | 10 +- pyproject.toml | 2 +- .../pre-tool-use/codex_cli-hooks.json | 2 +- .../pre-tool-use/pretooluse.json | 2 +- .../pre-tool-use/codex_cli-write-hooks.json | 16 +++ .../stop/codex_cli-hooks.json | 2 +- tests/fixtures/runtime_goldens/antigravity.py | 50 +++++++- tests/fixtures/runtime_goldens/claude_code.py | 52 +++++++- tests/fixtures/runtime_goldens/codex_cli.py | 59 ++++++++-- tests/fixtures/runtime_goldens/cursor.py | 50 +++++++- tests/fixtures/runtime_goldens/devin.py | 52 +++++++- tests/fixtures/runtime_goldens/gemini_cli.py | 52 +++++++- tests/fixtures/runtime_goldens/grok.py | 50 +++++++- tests/fixtures/runtime_goldens/tabnine.py | 50 +++++++- .../runtime_goldens/vscode_copilot.py | 111 ++++++++++++++++-- tests/fixtures/runtime_goldens/windsurf.py | 50 +++++++- 37 files changed, 1050 insertions(+), 148 deletions(-) create mode 100644 .chock/compiled/pin-github-actions/pre-tool-use/codex_cli-write-hooks.json create mode 100644 tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/codex_cli-write-hooks.json diff --git a/.chock/bin/claude_code.py b/.chock/bin/claude_code.py index 18f0e01..882f63c 100755 --- a/.chock/bin/claude_code.py +++ b/.chock/bin/claude_code.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.3 -- bundle("claude_code"). Do not hand-edit, except the +# Generated by agentseam 0.3.4 -- bundle("claude_code"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -9,8 +9,11 @@ from __future__ import annotations +import contextlib +import io import json _json = json +import os import sys import os as _chock_os @@ -24,7 +27,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.3) +# contract (agentseam 0.3.4) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -563,6 +566,8 @@ def hj_respond(cfg, decision, event, wire=None): _WINDOWS_KEYS = ("commandWindows", "windows") +_MATCHER_EVENTS = (PRE_TOOL, POST_TOOL, TOOL_FAILURE) + def _hook_dict(cfg, command): entry = {"type": "command", "command": command} for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): @@ -579,7 +584,7 @@ def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): if not name: continue rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: rule["matcher"] = matcher rules.append(rule) return rules @@ -618,7 +623,7 @@ def _default_wrapper(cfg, reverse, canonical_events, command, matcher): if not name: continue entry = {"hooks": [_hook_dict(cfg, command)]} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: entry["matcher"] = matcher hooks.setdefault(name, []).append(entry) if hook_entry.get("group"): @@ -648,7 +653,7 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos AGENT = "claude_code" -VENDOR = {'agent': 'claude_code', 'claims': {'client_types': (None, 'claude_code'), 'event_key': ('hook_event_name',), 'mode': 'marker', 'notes': 'prompt_id rejects only when looks_like_claude_code(raw) is also false; a real Claude Code payload may carry prompt_id and must still be accepted (matrix-notes.json: fixed 2026-08-27).', 'reject_markers': ('turn_id', 'project_path', 'timestamp'), 'reject_markers_unless_probe': {'looks_like_claude_code': ('prompt_id',)}}, 'config_format': 'json', 'config_path': '.claude/settings.json', 'display': 'Claude Code', 'events': {'FileChanged': 'file_changed', 'InstructionsLoaded': 'instructions_loaded', 'PostToolUse': 'post_tool', 'PostToolUseFailure': 'tool_failure', 'PreCompact': 'pre_compact', 'PreToolUse': 'pre_tool', 'SessionEnd': 'session_end', 'SessionStart': 'session_start', 'Stop': 'stop', 'SubagentStart': 'subagent_start', 'SubagentStop': 'subagent_stop', 'UserPromptSubmit': 'prompt_submit'}, 'evidence': {'claims': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'repo_root_token': {'basis': 'vendor-docs', 'date': '2026-09-01', 'test': 'tests/test_vendor_config.py::test_repo_root_token_is_recorded_only_where_primary_sourced'}, 'tools': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'hook_json', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content', 'tool_input.new_string', 'tool_input.new_source', 'content', 'tool_input.edits[].new_string'), 'cwd': ('cwd',), 'output': ('tool_output',), 'path': ('tool_input.file_path', 'tool_input.path', 'tool_input.notebook_path', 'file_path'), 'prompt': ('prompt',), 'session_id': ('session_id',), 'tool': ('tool_name',), 'tool_use_id': ('tool_use_id',)}, 'hook_entry': {'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'repo_root_token': '${CLAUDE_PROJECT_DIR}', 'tools': {'shell': ('Bash',), 'write': ('Write', 'Edit', 'MultiEdit', 'NotebookEdit')}, 'verdicts': {'answer_events': ('PreToolUse', 'Stop', 'UserPromptSubmit'), 'bare_allow': 'silent', 'context_events': ('SessionStart', 'UserPromptSubmit'), 'context_source': 'context', 'degrade_notes': {'escalate': 'confirmation requested; this event cannot prompt, so it blocks', 'transform': 'input rewrite requested; this event cannot modify input, so it blocks'}, 'echo': 'reverse_map', 'gates': {'PreToolUse': {'grammar': 'G2', 'honours_escalate': True, 'honours_transform': True}, 'Stop': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'UserPromptSubmit': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'suffix', 'reason_defaults': {'deny_gate': 'blocked', 'escalate_gate': 'confirmation required'}, 'transform_grammar': 'hook_specific_updated_input', 'vocabulary': ('allow', 'ask', 'block', 'deny'), 'vocabulary_basis': 'verified', 'words': {'block': 'block', 'deny': 'deny', 'escalate': 'ask', 'transform': 'allow', 'vouch': 'allow'}}} +VENDOR = {'agent': 'claude_code', 'claims': {'client_types': (None, 'claude_code'), 'event_key': ('hook_event_name',), 'mode': 'marker', 'notes': 'prompt_id rejects only when looks_like_claude_code(raw) is also false; a real Claude Code payload may carry prompt_id and must still be accepted (matrix-notes.json: fixed 2026-08-27).', 'reject_markers': ('turn_id', 'project_path', 'timestamp'), 'reject_markers_unless_probe': {'looks_like_claude_code': ('prompt_id',)}}, 'config_format': 'json', 'config_path': '.claude/settings.json', 'display': 'Claude Code', 'events': {'FileChanged': 'file_changed', 'InstructionsLoaded': 'instructions_loaded', 'PostToolUse': 'post_tool', 'PostToolUseFailure': 'tool_failure', 'PreCompact': 'pre_compact', 'PreToolUse': 'pre_tool', 'SessionEnd': 'session_end', 'SessionStart': 'session_start', 'Stop': 'stop', 'SubagentStart': 'subagent_start', 'SubagentStop': 'subagent_stop', 'UserPromptSubmit': 'prompt_submit'}, 'evidence': {'claims': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'repo_root_token': {'basis': 'vendor-docs', 'date': '2026-09-01', 'test': 'tests/test_vendor_config.py::test_repo_root_token_is_recorded_only_where_primary_sourced'}, 'tools': {'basis': 'vendor-docs', 'date': '2026-09-27', 'test': 'tests/test_adapter_claude_code.py::test_powershell_is_a_shell_tool_and_its_command_is_parsed'}, 'verdicts': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'hook_json', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content', 'tool_input.new_string', 'tool_input.new_source', 'content', 'tool_input.edits[].new_string'), 'cwd': ('cwd',), 'output': ('tool_output',), 'path': ('tool_input.file_path', 'tool_input.path', 'tool_input.notebook_path', 'file_path'), 'prompt': ('prompt',), 'session_id': ('session_id',), 'tool': ('tool_name',), 'tool_use_id': ('tool_use_id',)}, 'hook_entry': {'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'repo_root_token': '${CLAUDE_PROJECT_DIR}', 'tools': {'shell': ('Bash', 'PowerShell'), 'write': ('Write', 'Edit', 'MultiEdit', 'NotebookEdit')}, 'verdicts': {'answer_events': ('PreToolUse', 'Stop', 'UserPromptSubmit'), 'bare_allow': 'silent', 'context_events': ('SessionStart', 'UserPromptSubmit'), 'context_source': 'context', 'degrade_notes': {'escalate': 'confirmation requested; this event cannot prompt, so it blocks', 'transform': 'input rewrite requested; this event cannot modify input, so it blocks'}, 'echo': 'reverse_map', 'gates': {'PreToolUse': {'grammar': 'G2', 'honours_escalate': True, 'honours_transform': True}, 'Stop': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'UserPromptSubmit': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'suffix', 'reason_defaults': {'deny_gate': 'blocked', 'escalate_gate': 'confirmation required'}, 'transform_grammar': 'hook_specific_updated_input', 'vocabulary': ('allow', 'ask', 'block', 'deny'), 'vocabulary_basis': 'verified', 'words': {'block': 'block', 'deny': 'deny', 'escalate': 'ask', 'transform': 'allow', 'vouch': 'allow'}}} def claims(raw): @@ -1448,6 +1453,39 @@ def _report(text): return +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 + try: + os.dup2(2, 1) + except OSError: + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +@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() + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + with contextlib.suppress(Exception): + sink.flush() + 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) @@ -1457,7 +1495,9 @@ def _decide(raw): # vocabulary invites a decision made on a false premise. return "", 0 try: - decision = _coerce(handle(event)) + with _stdout_to_stderr(): + result = handle(event) + decision = _coerce(result) except Exception: # A door that cannot decide refuses. Escaping here would exit 1 with a traceback, # which every host reads as a non-blocking error and allows past; instead the diff --git a/.chock/bin/codex_cli.py b/.chock/bin/codex_cli.py index 4fea531..33c2a8d 100755 --- a/.chock/bin/codex_cli.py +++ b/.chock/bin/codex_cli.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.3 -- bundle("codex_cli"). Do not hand-edit, except the +# Generated by agentseam 0.3.4 -- bundle("codex_cli"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -9,8 +9,11 @@ from __future__ import annotations +import contextlib +import io import json _json = json +import os import sys import os as _chock_os @@ -24,7 +27,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.3) +# contract (agentseam 0.3.4) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -217,9 +220,12 @@ def tool_input_of(raw): # ------------------------------------------------------------------------------ # hook_json family engine (trimmed to what this entry uses) +_KEEP_EXIT = "; exit $LASTEXITCODE" + def powershell_command(command): - """`command` rewritten so PowerShell will actually run it.""" - return command if command.lstrip().startswith("&") else "& " + command + """`command` rewritten so PowerShell will actually run it and keep its exit code.""" + body = command if command.lstrip().startswith("&") else "& " + command + return body if body.rstrip().endswith(_KEEP_EXIT) else body + _KEEP_EXIT UNREADABLE_NAME = "" @@ -550,6 +556,8 @@ def hj_respond(cfg, decision, event, wire=None): _WINDOWS_KEYS = ("commandWindows", "windows") +_MATCHER_EVENTS = (PRE_TOOL, POST_TOOL, TOOL_FAILURE) + def _hook_dict(cfg, command): entry = {"type": "command", "command": command} for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): @@ -566,7 +574,7 @@ def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): if not name: continue rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: rule["matcher"] = matcher rules.append(rule) return rules @@ -605,7 +613,7 @@ def _default_wrapper(cfg, reverse, canonical_events, command, matcher): if not name: continue entry = {"hooks": [_hook_dict(cfg, command)]} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: entry["matcher"] = matcher hooks.setdefault(name, []).append(entry) if hook_entry.get("group"): @@ -635,7 +643,7 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos AGENT = "codex_cli" -VENDOR = {'agent': 'codex_cli', 'claims': {'accept_markers': ('turn_id',), 'accept_when_all': {'SessionStart': ('session_id', 'transcript_path', 'cwd', 'model', 'permission_mode', 'source')}, 'event_key': ('hook_event_name',), 'mode': 'marker', 'notes': 'Codex sends no turn_id at SessionStart, so that one event is claimed by the accept_when_all compound instead (confirmed live 2026-08-28).'}, 'config_format': 'json', 'config_path': '.codex/hooks.json', 'display': 'OpenAI Codex CLI', 'events': {'PostToolUse': 'post_tool', '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-partial', 'date': '2026-08-28', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'live-run-partial', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'live-run-partial', 'date': '2026-08-28', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'live-run-partial', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'live-run-partial', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'live-run-partial', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'live-run-partial', 'date': '2026-08-28', 'test': 'tests/test_vendor_lookups.py::test_shell_tools_are_recorded_only_where_established'}, 'verdicts': {'basis': 'live-run-partial', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'hook_json', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content',), 'cwd': ('cwd',), 'output': ('tool_output',), 'path': ('tool_input.file_path', 'tool_input.path'), 'prompt': ('prompt',), 'session_id': ('session_id',), 'tool': ('tool_name',), 'tool_use_id': ('tool_use_id',)}, 'hook_entry': {'entry_extra': {'commandWindows': 'powershell wrapper (_windows.py)'}, 'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'tools': {'shell': ('Bash',)}, 'verdicts': {'answer_events': ('PreToolUse', 'Stop', 'UserPromptSubmit'), 'bare_allow': 'silent', 'degrade_notes': {'escalate': 'Codex CLI cannot prompt for confirmation at this event', 'escalate_gate': 'Codex CLI does not support ask; asking would fail open', 'transform': 'Codex CLI cannot modify a tool call at this event', 'transform_missing_input': 'Codex CLI cannot apply a rewrite with no updatedInput'}, 'echo': 'reverse_map', 'gates': {'PreToolUse': {'grammar': 'G2', 'honours_escalate': False, 'honours_transform': True}, 'Stop': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'UserPromptSubmit': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'because', 'reason_defaults': {'deny_gate': 'blocked'}, 'transform_grammar': 'hook_specific_updated_input', 'vocabulary': ('allow', 'block', 'deny'), 'vocabulary_basis': 'verified', 'words': {'block': 'block', 'deny': 'deny', 'transform': 'allow'}}} +VENDOR = {'agent': 'codex_cli', 'claims': {'accept_markers': ('turn_id',), 'accept_when_all': {'SessionStart': ('session_id', 'transcript_path', 'cwd', 'model', 'permission_mode', 'source')}, 'event_key': ('hook_event_name',), 'mode': 'marker', 'notes': 'Codex sends no turn_id at SessionStart, so that one event is claimed by the accept_when_all compound instead (confirmed live 2026-08-28).'}, 'config_format': 'json', 'config_path': '.codex/hooks.json', 'display': 'OpenAI Codex CLI', 'events': {'PostToolUse': 'post_tool', '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-partial', 'date': '2026-08-28', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'live-run-partial', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'live-run-partial', 'date': '2026-08-28', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'live-run-partial', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'live-run-partial', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'live-run-partial', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'live-run-partial', 'date': '2026-08-28', 'test': 'tests/test_vendor_lookups.py::test_shell_tools_are_recorded_only_where_established'}, 'trust_hint': {'basis': 'vendor-docs', 'date': '2026-09-27', 'test': 'tests/test_cli.py::test_install_says_how_to_trust_a_hook_the_agent_will_not_run_yet'}, 'verdicts': {'basis': 'live-run-partial', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'hook_json', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content',), 'cwd': ('cwd',), 'output': ('tool_output',), 'path': ('tool_input.file_path', 'tool_input.path'), 'prompt': ('prompt',), 'session_id': ('session_id',), 'tool': ('tool_name',), 'tool_use_id': ('tool_use_id',)}, 'hook_entry': {'entry_extra': {'commandWindows': 'powershell wrapper (_windows.py)'}, 'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': True, 'tools': {'shell': ('Bash',), 'write': ('apply_patch',)}, 'trust_hint': 'run /hooks in Codex and trust the new hook (trust is per hook hash: re-trust after any change)', 'verdicts': {'answer_events': ('PreToolUse', 'Stop', 'UserPromptSubmit'), 'bare_allow': 'silent', 'degrade_notes': {'escalate': 'Codex CLI cannot prompt for confirmation at this event', 'escalate_gate': 'Codex CLI does not support ask; asking would fail open', 'transform': 'Codex CLI cannot modify a tool call at this event', 'transform_missing_input': 'Codex CLI cannot apply a rewrite with no updatedInput'}, 'echo': 'reverse_map', 'gates': {'PreToolUse': {'grammar': 'G2', 'honours_escalate': False, 'honours_transform': True}, 'Stop': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'UserPromptSubmit': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'because', 'reason_defaults': {'deny_gate': 'blocked'}, 'transform_grammar': 'hook_specific_updated_input', 'vocabulary': ('allow', 'block', 'deny'), 'vocabulary_basis': 'verified', 'words': {'block': 'block', 'deny': 'deny', 'transform': 'allow'}}} def claims(raw): @@ -1367,6 +1375,39 @@ def _report(text): return +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 + try: + os.dup2(2, 1) + except OSError: + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +@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() + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + with contextlib.suppress(Exception): + sink.flush() + 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) @@ -1376,7 +1417,9 @@ def _decide(raw): # vocabulary invites a decision made on a false premise. return "", 0 try: - decision = _coerce(handle(event)) + with _stdout_to_stderr(): + result = handle(event) + decision = _coerce(result) except Exception: # A door that cannot decide refuses. Escaping here would exit 1 with a traceback, # which every host reads as a non-blocking error and allows past; instead the diff --git a/.chock/bin/cursor.py b/.chock/bin/cursor.py index 86867cb..1fba217 100755 --- a/.chock/bin/cursor.py +++ b/.chock/bin/cursor.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.3 -- bundle("cursor"). Do not hand-edit, except the +# Generated by agentseam 0.3.4 -- bundle("cursor"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -9,8 +9,11 @@ from __future__ import annotations +import contextlib +import io import json _json = json +import os import sys import os as _chock_os @@ -24,7 +27,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.3) +# contract (agentseam 0.3.4) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -351,6 +354,8 @@ def hj_reverse(cfg): _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(): @@ -367,7 +372,7 @@ def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): if not name: continue rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: rule["matcher"] = matcher rules.append(rule) return rules @@ -406,7 +411,7 @@ def _default_wrapper(cfg, reverse, canonical_events, command, matcher): if not name: continue entry = {"hooks": [_hook_dict(cfg, command)]} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: entry["matcher"] = matcher hooks.setdefault(name, []).append(entry) if hook_entry.get("group"): @@ -1295,6 +1300,39 @@ def _report(text): return +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 + try: + os.dup2(2, 1) + except OSError: + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +@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() + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + with contextlib.suppress(Exception): + sink.flush() + 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) @@ -1304,7 +1342,9 @@ def _decide(raw): # vocabulary invites a decision made on a false premise. return "", 0 try: - decision = _coerce(handle(event)) + with _stdout_to_stderr(): + result = handle(event) + decision = _coerce(result) except Exception: # A door that cannot decide refuses. Escaping here would exit 1 with a traceback, # which every host reads as a non-blocking error and allows past; instead the diff --git a/.chock/bin/devin.py b/.chock/bin/devin.py index f2c3fca..141f5cc 100755 --- a/.chock/bin/devin.py +++ b/.chock/bin/devin.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.3 -- bundle("devin"). Do not hand-edit, except the +# Generated by agentseam 0.3.4 -- bundle("devin"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -9,8 +9,11 @@ from __future__ import annotations +import contextlib +import io import json _json = json +import os import sys import os as _chock_os @@ -24,7 +27,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.3) +# contract (agentseam 0.3.4) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -532,6 +535,8 @@ def hj_respond(cfg, decision, event, wire=None): _WINDOWS_KEYS = ("commandWindows", "windows") +_MATCHER_EVENTS = (PRE_TOOL, POST_TOOL, TOOL_FAILURE) + def _hook_dict(cfg, command): entry = {"type": "command", "command": command} for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): @@ -548,7 +553,7 @@ def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): if not name: continue rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: rule["matcher"] = matcher rules.append(rule) return rules @@ -587,7 +592,7 @@ def _default_wrapper(cfg, reverse, canonical_events, command, matcher): if not name: continue entry = {"hooks": [_hook_dict(cfg, command)]} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: entry["matcher"] = matcher hooks.setdefault(name, []).append(entry) if hook_entry.get("group"): @@ -617,7 +622,7 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos AGENT = "devin" -VENDOR = {'agent': 'devin', 'claims': {'accept_markers': ('prompt_id',), 'accept_names': ('PermissionRequest', 'PostCompaction'), 'event_key': ('hook_event_name',), 'mode': 'marker', 'notes': 'accept_names are names Claude Code never sends, claimed before any marker check -- except against a client_type that names another vendor, since Kimi Code sends PermissionRequest too; prompt_id is required alongside looks_like_claude_code(raw) being false.', 'reject_client_types': ('kimi_code_cli',), 'reject_probes': ('looks_like_claude_code',)}, 'config_format': 'json', 'config_path': '.devin/hooks.v1.json', 'display': 'Devin', 'events': {'PermissionRequest': 'pre_tool', 'PostToolUse': 'post_tool', 'PreToolUse': 'pre_tool', 'SessionEnd': 'session_end', 'SessionStart': 'session_start', 'Stop': 'stop', 'UserPromptSubmit': 'prompt_submit'}, 'evidence': {'claims': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'hook_json', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content', 'tool_input.new_string'), 'cwd': ('cwd',), 'output': ('tool_output',), 'path': ('tool_input.file_path', 'tool_input.path'), 'prompt': ('prompt',), 'session_id': ('session_id',), 'tool': ('tool_name',)}, 'hook_entry': {'bare': True, 'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'tools': {}, 'verdicts': {'answer_events': ('PermissionRequest', 'PreToolUse', 'Stop', 'UserPromptSubmit'), 'bare_allow': 'unverified', 'context_events': ('PostToolUse', 'SessionStart', 'UserPromptSubmit'), 'context_source': 'reason', 'default_wire_event': 'PreToolUse', 'degrade_notes': {'escalate': 'Devin cannot prompt for confirmation, so this is a block', 'escalate_from_transform': '%s (Devin cannot modify the input at %s, so this is a block)'}, 'echo': 'payload', 'gates': {'PermissionRequest': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'PreToolUse': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': True}, 'Stop': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'UserPromptSubmit': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'suffix', 'reason_defaults': {'transform': 'input requires modification'}, 'transform_grammar': 'hook_specific_updated_input', 'vocabulary': ('approve', 'block'), 'vocabulary_basis': 'verified', 'words': {'allow': 'approve', 'block': 'block'}}, 'wire_events': {'pre_tool': 'PreToolUse'}} +VENDOR = {'agent': 'devin', 'claims': {'accept_markers': ('prompt_id',), 'accept_names': ('PostCompaction',), 'event_key': ('hook_event_name',), 'mode': 'marker', 'notes': 'accept_names are names Claude Code never sends, claimed before any marker check -- except against a client_type that names another vendor. PermissionRequest is not one: Claude Code sends it too (code.claude.com/docs/en/hooks), so it takes the marker path; prompt_id is required alongside looks_like_claude_code(raw) being false.', 'reject_client_types': ('kimi_code_cli',), 'reject_probes': ('looks_like_claude_code',)}, 'config_format': 'json', 'config_path': '.devin/hooks.v1.json', 'display': 'Devin', 'events': {'PermissionRequest': 'pre_tool', 'PostToolUse': 'post_tool', 'PreToolUse': 'pre_tool', 'SessionEnd': 'session_end', 'SessionStart': 'session_start', 'Stop': 'stop', 'UserPromptSubmit': 'prompt_submit'}, 'evidence': {'claims': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'hook_json', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content', 'tool_input.new_string'), 'cwd': ('cwd',), 'output': ('tool_output',), 'path': ('tool_input.file_path', 'tool_input.path'), 'prompt': ('prompt',), 'session_id': ('session_id',), 'tool': ('tool_name',)}, 'hook_entry': {'bare': True, 'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'tools': {}, 'verdicts': {'answer_events': ('PermissionRequest', 'PreToolUse', 'Stop', 'UserPromptSubmit'), 'bare_allow': 'unverified', 'context_events': ('PostToolUse', 'SessionStart', 'UserPromptSubmit'), 'context_source': 'reason', 'default_wire_event': 'PreToolUse', 'degrade_notes': {'escalate': 'Devin cannot prompt for confirmation, so this is a block', 'escalate_from_transform': '%s (Devin cannot modify the input at %s, so this is a block)'}, 'echo': 'payload', 'gates': {'PermissionRequest': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'PreToolUse': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': True}, 'Stop': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'UserPromptSubmit': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'suffix', 'reason_defaults': {'transform': 'input requires modification'}, 'transform_grammar': 'hook_specific_updated_input', 'vocabulary': ('approve', 'block'), 'vocabulary_basis': 'verified', 'words': {'allow': 'approve', 'block': 'block'}}, 'wire_events': {'pre_tool': 'PreToolUse'}} def claims(raw): @@ -1349,6 +1354,39 @@ def _report(text): return +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 + try: + os.dup2(2, 1) + except OSError: + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +@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() + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + with contextlib.suppress(Exception): + sink.flush() + 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) @@ -1358,7 +1396,9 @@ def _decide(raw): # vocabulary invites a decision made on a false premise. return "", 0 try: - decision = _coerce(handle(event)) + with _stdout_to_stderr(): + result = handle(event) + decision = _coerce(result) except Exception: # A door that cannot decide refuses. Escaping here would exit 1 with a traceback, # which every host reads as a non-blocking error and allows past; instead the diff --git a/.chock/bin/gemini_cli.py b/.chock/bin/gemini_cli.py index bd64575..b3515d1 100755 --- a/.chock/bin/gemini_cli.py +++ b/.chock/bin/gemini_cli.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.3 -- bundle("gemini_cli"). Do not hand-edit, except the +# Generated by agentseam 0.3.4 -- bundle("gemini_cli"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -9,8 +9,11 @@ from __future__ import annotations +import contextlib +import io import json _json = json +import os import sys import os as _chock_os @@ -24,7 +27,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.3) +# contract (agentseam 0.3.4) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -532,6 +535,8 @@ def hj_respond(cfg, decision, event, wire=None): _WINDOWS_KEYS = ("commandWindows", "windows") +_MATCHER_EVENTS = (PRE_TOOL, POST_TOOL, TOOL_FAILURE) + def _hook_dict(cfg, command): entry = {"type": "command", "command": command} for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): @@ -548,7 +553,7 @@ def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): if not name: continue rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: rule["matcher"] = matcher rules.append(rule) return rules @@ -587,7 +592,7 @@ def _default_wrapper(cfg, reverse, canonical_events, command, matcher): if not name: continue entry = {"hooks": [_hook_dict(cfg, command)]} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: entry["matcher"] = matcher hooks.setdefault(name, []).append(entry) if hook_entry.get("group"): @@ -617,7 +622,7 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos AGENT = "gemini_cli" -VENDOR = {'agent': 'gemini_cli', 'claims': {'client_types': (None, 'gemini_cli', 'gemini'), 'event_key': ('hook_event_name',), 'mode': 'marker', 'reject_markers': ('timestamp', 'project_path', 'prompt_id', 'turn_id'), 'reject_probes': ('looks_like_claude_code',)}, 'config_format': 'json', 'config_path': '.gemini/settings.json', 'display': 'Gemini CLI', 'events': {'AfterAgent': 'stop', 'AfterTool': 'post_tool', 'BeforeAgent': 'prompt_submit', 'BeforeTool': 'pre_tool', 'PreCompress': 'pre_compact', 'SessionEnd': 'session_end', 'SessionStart': 'session_start'}, 'evidence': {'claims': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'flat_decision', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content', 'tool_input.new_string', 'tool_input.new_str'), 'content_only_for_write_tools': True, 'cwd': ('cwd',), 'output': ('tool_output', 'tool_response'), 'path': ('tool_input.file_path', 'tool_input.absolute_path', 'tool_input.path'), 'prompt': ('prompt', 'user_message'), 'session_id': ('session_id',), 'tool': ('tool_name',)}, 'hook_entry': {'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'tools': {'shell': ('run_shell_command',), 'write': ('write_file', 'replace')}, 'verdicts': {'answer_events': ('AfterAgent', 'AfterTool', 'BeforeAgent', 'BeforeTool'), 'bare_allow': 'inert', 'degrade_notes': {'escalate': '%s (confirmation required; %s cannot prompt from a hook)'}, 'gates': {'AfterAgent': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'AfterTool': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'BeforeAgent': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'BeforeTool': {'grammar': 'G1', 'honours_escalate': True, 'honours_transform': True}}, 'reason_defaults': {'escalate': 'policy requires confirmation', 'escalate_gate': 'confirmation required'}, 'transform_grammar': 'hook_specific_tool_input', 'vocabulary': ('allow', 'ask', 'deny'), 'vocabulary_basis': 'verified', 'words': {'allow': 'allow', 'block': 'deny', 'escalate': 'ask'}}} +VENDOR = {'agent': 'gemini_cli', 'claims': {'client_types': (None, 'gemini_cli', 'gemini'), 'event_key': ('hook_event_name',), 'mode': 'marker', 'notes': "timestamp is in Gemini's documented base input too: rejecting it is a deliberate tie-break toward Tabnine, which sends the same shape and whose deny/allow wire is identical (ask/transform degrade to deny); a declining detect() would allow silently. Name gemini_cli to get ask/transform.", 'reject_markers': ('timestamp', 'project_path', 'prompt_id', 'turn_id'), 'reject_probes': ('looks_like_claude_code',)}, 'config_format': 'json', 'config_path': '.gemini/settings.json', 'display': 'Gemini CLI', 'events': {'AfterAgent': 'stop', 'AfterTool': 'post_tool', 'BeforeAgent': 'prompt_submit', 'BeforeTool': 'pre_tool', 'PreCompress': 'pre_compact', 'SessionEnd': 'session_end', 'SessionStart': 'session_start'}, 'evidence': {'claims': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'flat_decision', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content', 'tool_input.new_string', 'tool_input.new_str'), 'content_only_for_write_tools': True, 'cwd': ('cwd',), 'output': ('tool_output', 'tool_response'), 'path': ('tool_input.file_path', 'tool_input.absolute_path', 'tool_input.path'), 'prompt': ('prompt', 'user_message'), 'session_id': ('session_id',), 'tool': ('tool_name',)}, 'hook_entry': {'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'tools': {'shell': ('run_shell_command',), 'write': ('write_file', 'replace')}, 'verdicts': {'answer_events': ('AfterAgent', 'AfterTool', 'BeforeAgent', 'BeforeTool'), 'bare_allow': 'inert', 'degrade_notes': {'escalate': '%s (confirmation required; %s cannot prompt from a hook)'}, 'gates': {'AfterAgent': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'AfterTool': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'BeforeAgent': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'BeforeTool': {'grammar': 'G1', 'honours_escalate': True, 'honours_transform': True}}, 'reason_defaults': {'escalate': 'policy requires confirmation', 'escalate_gate': 'confirmation required'}, 'transform_grammar': 'hook_specific_tool_input', 'vocabulary': ('allow', 'ask', 'deny'), 'vocabulary_basis': 'verified', 'words': {'allow': 'allow', 'block': 'deny', 'escalate': 'ask'}}} def claims(raw): @@ -1349,6 +1354,39 @@ def _report(text): return +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 + try: + os.dup2(2, 1) + except OSError: + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +@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() + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + with contextlib.suppress(Exception): + sink.flush() + 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) @@ -1358,7 +1396,9 @@ def _decide(raw): # vocabulary invites a decision made on a false premise. return "", 0 try: - decision = _coerce(handle(event)) + with _stdout_to_stderr(): + result = handle(event) + decision = _coerce(result) except Exception: # A door that cannot decide refuses. Escaping here would exit 1 with a traceback, # which every host reads as a non-blocking error and allows past; instead the diff --git a/.chock/bin/grok.py b/.chock/bin/grok.py index d07f8d0..60ddb37 100755 --- a/.chock/bin/grok.py +++ b/.chock/bin/grok.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.3 -- bundle("grok"). Do not hand-edit, except the +# Generated by agentseam 0.3.4 -- bundle("grok"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -9,8 +9,11 @@ from __future__ import annotations +import contextlib +import io import json _json = json +import os import sys import os as _chock_os @@ -24,7 +27,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.3) +# contract (agentseam 0.3.4) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -515,6 +518,8 @@ def hj_respond(cfg, decision, event, wire=None): _WINDOWS_KEYS = ("commandWindows", "windows") +_MATCHER_EVENTS = (PRE_TOOL, POST_TOOL, TOOL_FAILURE) + def _hook_dict(cfg, command): entry = {"type": "command", "command": command} for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): @@ -531,7 +536,7 @@ def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): if not name: continue rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: rule["matcher"] = matcher rules.append(rule) return rules @@ -570,7 +575,7 @@ def _default_wrapper(cfg, reverse, canonical_events, command, matcher): if not name: continue entry = {"hooks": [_hook_dict(cfg, command)]} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: entry["matcher"] = matcher hooks.setdefault(name, []).append(entry) if hook_entry.get("group"): @@ -1332,6 +1337,39 @@ def _report(text): return +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 + try: + os.dup2(2, 1) + except OSError: + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +@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() + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + with contextlib.suppress(Exception): + sink.flush() + 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) @@ -1341,7 +1379,9 @@ def _decide(raw): # vocabulary invites a decision made on a false premise. return "", 0 try: - decision = _coerce(handle(event)) + with _stdout_to_stderr(): + result = handle(event) + decision = _coerce(result) except Exception: # A door that cannot decide refuses. Escaping here would exit 1 with a traceback, # which every host reads as a non-blocking error and allows past; instead the diff --git a/.chock/bin/tabnine.py b/.chock/bin/tabnine.py index 5bb6f8d..78f7fe4 100755 --- a/.chock/bin/tabnine.py +++ b/.chock/bin/tabnine.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.3 -- bundle("tabnine"). Do not hand-edit, except the +# Generated by agentseam 0.3.4 -- bundle("tabnine"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -9,8 +9,11 @@ from __future__ import annotations +import contextlib +import io import json _json = json +import os import sys import os as _chock_os @@ -24,7 +27,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.3) +# contract (agentseam 0.3.4) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -515,6 +518,8 @@ def hj_respond(cfg, decision, event, wire=None): _WINDOWS_KEYS = ("commandWindows", "windows") +_MATCHER_EVENTS = (PRE_TOOL, POST_TOOL, TOOL_FAILURE) + def _hook_dict(cfg, command): entry = {"type": "command", "command": command} for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): @@ -531,7 +536,7 @@ def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): if not name: continue rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: rule["matcher"] = matcher rules.append(rule) return rules @@ -570,7 +575,7 @@ def _default_wrapper(cfg, reverse, canonical_events, command, matcher): if not name: continue entry = {"hooks": [_hook_dict(cfg, command)]} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: entry["matcher"] = matcher hooks.setdefault(name, []).append(entry) if hook_entry.get("group"): @@ -1332,6 +1337,39 @@ def _report(text): return +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 + try: + os.dup2(2, 1) + except OSError: + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +@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() + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + with contextlib.suppress(Exception): + sink.flush() + 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) @@ -1341,7 +1379,9 @@ def _decide(raw): # vocabulary invites a decision made on a false premise. return "", 0 try: - decision = _coerce(handle(event)) + with _stdout_to_stderr(): + result = handle(event) + decision = _coerce(result) except Exception: # A door that cannot decide refuses. Escaping here would exit 1 with a traceback, # which every host reads as a non-blocking error and allows past; instead the diff --git a/.chock/bin/vscode_copilot.py b/.chock/bin/vscode_copilot.py index 5f35eb1..bd6d4fa 100755 --- a/.chock/bin/vscode_copilot.py +++ b/.chock/bin/vscode_copilot.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.3 -- bundle("vscode_copilot"). Do not hand-edit, except the +# Generated by agentseam 0.3.4 -- bundle("vscode_copilot"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -9,8 +9,11 @@ from __future__ import annotations +import contextlib +import io import json _json = json +import os import sys import os as _chock_os @@ -24,7 +27,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.3) +# contract (agentseam 0.3.4) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -220,10 +223,15 @@ def tool_input_of(raw): """PowerShell's one rule that breaks hook commands, shared by the vendors it affects.""" +#: `pwsh -Command` exits 1 whenever the last native command failed, whatever its code, so a +#: hook's exit 2 (block) reached the host as 1 (a non-blocking error): openai/codex#48183. +_KEEP_EXIT = "; exit $LASTEXITCODE" + def powershell_command(command): - """`command` rewritten so PowerShell will actually run it.""" - return command if command.lstrip().startswith("&") else "& " + command + """`command` rewritten so PowerShell will actually run it and keep its exit code.""" + body = command if command.lstrip().startswith("&") else "& " + command + return body if body.rstrip().endswith(_KEEP_EXIT) else body + _KEEP_EXIT # ------------------------------------------------------------------------------ @@ -268,6 +276,14 @@ def powershell_command(command): _VSCODE_ENVELOPE = "timestamp" +#: Copilot CLI's own camelCase input (docs.github.com/en/copilot/reference/hooks-configuration, +#: read 2026-09-27): {sessionId, timestamp, cwd, toolName, toolArgs[, toolResult]} and no event +#: name at all. `toolArgs` is documented as the parsed arguments; the JSON-string form earlier +#: CLI builds sent is decoded too. +_CLI_ARGS = "toolArgs" +_CLI_RESULT = "toolResult" +_CLI_KEYS = (_CLI_ARGS, "toolName", "sessionId") + def _name(raw): """The payload's own event name as text; None when it names none, UNKNOWN when the @@ -291,15 +307,37 @@ def claims(raw): return False if name in _CLAIMABLE: return True + if name is None and _CLI_ARGS in raw: + return True ti = raw.get("tool_input") return raw.get("tool_name") in MEMORY_TOOLS and isinstance(ti, dict) and "command" in ti +def _tool_input(raw): + """The tool's arguments: VS Code's `tool_input`, else the CLI's `toolArgs` (object or JSON text).""" + ti = raw.get("tool_input") + return tool_input_of(raw.get(_CLI_ARGS) if ti is None else ti) + + +def is_cli_native(raw): + """True for Copilot CLI's camelCase payloads, which get its top-level permission answer.""" + if not isinstance(raw, dict): + return False + name = _name(raw) + if name is None: + return any(k in raw for k in _CLI_KEYS) + return name in _CLAIMABLE + + +def _cli_output(raw): + result = raw.get(_CLI_RESULT) + return result.get("textResultForLlm") if isinstance(result, dict) else None + + def parse(raw): if not isinstance(raw, dict): return Event(AGENT, UNKNOWN, raw=raw) - ti = raw.get("tool_input") - ti = tool_input_of(ti) + ti = _tool_input(raw) tool = raw.get("tool_name") or raw.get("toolName") path = content = None if tool in MEMORY_TOOLS: @@ -311,7 +349,7 @@ def parse(raw): else: path = ti.get("filePath") or ti.get("file_path") or ti.get("path") content = ti.get("content") or ti.get("newText") or ti.get("new_str") - name = _name(raw) or "preToolUse" + name = _name(raw) or ("postToolUse" if _CLI_RESULT in raw else "preToolUse") return Event( AGENT, EVENT_MAP.get(name, UNKNOWN), @@ -320,8 +358,8 @@ def parse(raw): path=path, content=content, prompt=raw.get("prompt"), - output=raw.get("tool_output") or raw.get("tool_response"), - session_id=raw.get("session_id"), + output=raw.get("tool_output") or raw.get("tool_response") or _cli_output(raw), + session_id=raw.get("session_id") or raw.get("sessionId"), tool_use_id=raw.get("tool_use_id"), cwd=raw.get("cwd"), raw=raw, @@ -330,8 +368,7 @@ def parse(raw): def is_memory_write(event): """True when this event is a memory-tool content write (VS Code's memory surface).""" - ti = event.raw.get("tool_input") - ti = tool_input_of(ti) + ti = _tool_input(event.raw) return event.tool in MEMORY_TOOLS and ti.get("command") in MEMORY_WRITE_COMMANDS @@ -395,6 +432,18 @@ def _pre_tool_out(decision, event): return out +def _cli_pre_tool_out(decision): + """The CLI's documented top-level answer; it has no input rewrite, so a rewrite blocks.""" + if decision.outcome == VOUCH: + out = {"permissionDecision": "allow"} + if decision.reason: + out[_PERMISSION_DECISION_REASON] = decision.reason + return out + if decision.outcome == ASK: + return {"permissionDecision": "ask", _PERMISSION_DECISION_REASON: decision.reason or "confirmation required"} + return {"permissionDecision": "deny", _PERMISSION_DECISION_REASON: _refusal_reason(decision)} + + def respond(decision, event): """Three dialects, one per event group -- not one gate shape everywhere.""" import json as _json # noqa: PLC0415 (bundler.py keeps this vendored file's own function-local @@ -413,6 +462,9 @@ def respond(decision, event): if event.event != PRE_TOOL or decision.outcome == ALLOW: return "", 0 + if is_cli_native(event.raw): + return _json.dumps(_cli_pre_tool_out(decision)), 0 + return _json.dumps({"hookSpecificOutput": _pre_tool_out(decision, event)}), 0 @@ -1156,6 +1208,39 @@ def _report(text): return +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 + try: + os.dup2(2, 1) + except OSError: + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +@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() + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + with contextlib.suppress(Exception): + sink.flush() + 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) @@ -1165,7 +1250,9 @@ def _decide(raw): # vocabulary invites a decision made on a false premise. return "", 0 try: - decision = _coerce(handle(event)) + with _stdout_to_stderr(): + result = handle(event) + decision = _coerce(result) except Exception: # A door that cannot decide refuses. Escaping here would exit 1 with a traceback, # which every host reads as a non-blocking error and allows past; instead the diff --git a/.chock/bin/windsurf.py b/.chock/bin/windsurf.py index bd3fa66..d323fbb 100755 --- a/.chock/bin/windsurf.py +++ b/.chock/bin/windsurf.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.3 -- bundle("windsurf"). Do not hand-edit, except the +# Generated by agentseam 0.3.4 -- bundle("windsurf"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -9,8 +9,11 @@ from __future__ import annotations +import contextlib +import io import json _json = json +import os import sys import os as _chock_os @@ -24,7 +27,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.3) +# contract (agentseam 0.3.4) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -388,6 +391,8 @@ def _refusal_text(v, decision, at_gate, wire=None): _WINDOWS_KEYS = ("commandWindows", "windows") +_MATCHER_EVENTS = (PRE_TOOL, POST_TOOL, TOOL_FAILURE) + def _hook_dict(cfg, command): entry = {"type": "command", "command": command} for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): @@ -404,7 +409,7 @@ def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): if not name: continue rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: rule["matcher"] = matcher rules.append(rule) return rules @@ -443,7 +448,7 @@ def _default_wrapper(cfg, reverse, canonical_events, command, matcher): if not name: continue entry = {"hooks": [_hook_dict(cfg, command)]} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: entry["matcher"] = matcher hooks.setdefault(name, []).append(entry) if hook_entry.get("group"): @@ -1246,6 +1251,39 @@ def _report(text): return +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 + try: + os.dup2(2, 1) + except OSError: + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +@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() + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + with contextlib.suppress(Exception): + sink.flush() + 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) @@ -1255,7 +1293,9 @@ def _decide(raw): # vocabulary invites a decision made on a false premise. return "", 0 try: - decision = _coerce(handle(event)) + with _stdout_to_stderr(): + result = handle(event) + decision = _coerce(result) except Exception: # A door that cannot decide refuses. Escaping here would exit 1 with a traceback, # which every host reads as a non-blocking error and allows past; instead the diff --git a/.chock/compiled/block-destructive-commands/pre-tool-use/codex_cli-hooks.json b/.chock/compiled/block-destructive-commands/pre-tool-use/codex_cli-hooks.json index 4e37e74..fc66ee3 100644 --- a/.chock/compiled/block-destructive-commands/pre-tool-use/codex_cli-hooks.json +++ b/.chock/compiled/block-destructive-commands/pre-tool-use/codex_cli-hooks.json @@ -6,7 +6,7 @@ { "type": "command", "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", - "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"; exit $LASTEXITCODE" } ], "matcher": "Bash" diff --git a/.chock/compiled/block-destructive-commands/pre-tool-use/pretooluse.json b/.chock/compiled/block-destructive-commands/pre-tool-use/pretooluse.json index 7ca0611..39e5e1f 100644 --- a/.chock/compiled/block-destructive-commands/pre-tool-use/pretooluse.json +++ b/.chock/compiled/block-destructive-commands/pre-tool-use/pretooluse.json @@ -1,5 +1,5 @@ { - "matcher": "Bash", + "matcher": "Bash|PowerShell", "hooks": [ { "type": "command", diff --git a/.chock/compiled/block-no-verify/pre-tool-use/codex_cli-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/codex_cli-hooks.json index 6760f15..146b667 100644 --- a/.chock/compiled/block-no-verify/pre-tool-use/codex_cli-hooks.json +++ b/.chock/compiled/block-no-verify/pre-tool-use/codex_cli-hooks.json @@ -6,7 +6,7 @@ { "type": "command", "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", - "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"; exit $LASTEXITCODE" } ], "matcher": "Bash" diff --git a/.chock/compiled/block-no-verify/pre-tool-use/pretooluse.json b/.chock/compiled/block-no-verify/pre-tool-use/pretooluse.json index 5eb9e59..38d9825 100644 --- a/.chock/compiled/block-no-verify/pre-tool-use/pretooluse.json +++ b/.chock/compiled/block-no-verify/pre-tool-use/pretooluse.json @@ -1,5 +1,5 @@ { - "matcher": "Bash", + "matcher": "Bash|PowerShell", "hooks": [ { "type": "command", diff --git a/.chock/compiled/pin-github-actions/pre-tool-use/codex_cli-write-hooks.json b/.chock/compiled/pin-github-actions/pre-tool-use/codex_cli-write-hooks.json new file mode 100644 index 0000000..f5dcf4c --- /dev/null +++ b/.chock/compiled/pin-github-actions/pre-tool-use/codex_cli-write-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", + "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/pin-github-actions/pre-tool-use/gate.json\"; exit $LASTEXITCODE" + } + ], + "matcher": "apply_patch" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/pin-github-actions/stop/codex_cli-hooks.json b/.chock/compiled/pin-github-actions/stop/codex_cli-hooks.json index 5d533fe..620fb59 100644 --- a/.chock/compiled/pin-github-actions/stop/codex_cli-hooks.json +++ b/.chock/compiled/pin-github-actions/stop/codex_cli-hooks.json @@ -6,7 +6,7 @@ { "type": "command", "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", - "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" + "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"; exit $LASTEXITCODE" } ] } diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/codex_cli-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/codex_cli-hooks.json index f75d4c3..083af41 100644 --- a/.chock/compiled/protect-agent-config/pre-tool-use/codex_cli-hooks.json +++ b/.chock/compiled/protect-agent-config/pre-tool-use/codex_cli-hooks.json @@ -6,7 +6,7 @@ { "type": "command", "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", - "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"; exit $LASTEXITCODE" } ], "matcher": "Bash" diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/pretooluse.json b/.chock/compiled/protect-agent-config/pre-tool-use/pretooluse.json index 7b04d6a..8971eb8 100644 --- a/.chock/compiled/protect-agent-config/pre-tool-use/pretooluse.json +++ b/.chock/compiled/protect-agent-config/pre-tool-use/pretooluse.json @@ -1,5 +1,5 @@ { - "matcher": "Bash", + "matcher": "Bash|PowerShell", "hooks": [ { "type": "command", diff --git a/.chock/compiled/protect-commit-privacy/pre-tool-use/codex_cli-hooks.json b/.chock/compiled/protect-commit-privacy/pre-tool-use/codex_cli-hooks.json index f231dd9..5aaead8 100644 --- a/.chock/compiled/protect-commit-privacy/pre-tool-use/codex_cli-hooks.json +++ b/.chock/compiled/protect-commit-privacy/pre-tool-use/codex_cli-hooks.json @@ -6,7 +6,7 @@ { "type": "command", "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", - "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"; exit $LASTEXITCODE" } ], "matcher": "Bash" diff --git a/.chock/compiled/protect-commit-privacy/pre-tool-use/pretooluse.json b/.chock/compiled/protect-commit-privacy/pre-tool-use/pretooluse.json index d87a7e6..7c459e7 100644 --- a/.chock/compiled/protect-commit-privacy/pre-tool-use/pretooluse.json +++ b/.chock/compiled/protect-commit-privacy/pre-tool-use/pretooluse.json @@ -1,5 +1,5 @@ { - "matcher": "Bash", + "matcher": "Bash|PowerShell", "hooks": [ { "type": "command", diff --git a/.claude/settings.json b/.claude/settings.json index 87d403c..ff8f8f8 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -2,7 +2,7 @@ "hooks": { "PreToolUse": [ { - "matcher": "Bash", + "matcher": "Bash|PowerShell", "hooks": [ { "type": "command", @@ -12,7 +12,7 @@ ] }, { - "matcher": "Bash", + "matcher": "Bash|PowerShell", "hooks": [ { "type": "command", @@ -32,7 +32,7 @@ ] }, { - "matcher": "Bash", + "matcher": "Bash|PowerShell", "hooks": [ { "type": "command", @@ -42,7 +42,7 @@ ] }, { - "matcher": "Bash", + "matcher": "Bash|PowerShell", "hooks": [ { "type": "command", diff --git a/.codex/hooks.json b/.codex/hooks.json index 39117d0..3bf17c4 100644 --- a/.codex/hooks.json +++ b/.codex/hooks.json @@ -6,7 +6,7 @@ { "type": "command", "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", - "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"; exit $LASTEXITCODE" } ], "matcher": "Bash" @@ -16,17 +16,27 @@ { "type": "command", "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", - "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"; exit $LASTEXITCODE" } ], "matcher": "Bash" }, + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", + "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/pin-github-actions/pre-tool-use/gate.json\"; exit $LASTEXITCODE" + } + ], + "matcher": "apply_patch" + }, { "hooks": [ { "type": "command", "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", - "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"; exit $LASTEXITCODE" } ], "matcher": "Bash" @@ -36,7 +46,7 @@ { "type": "command", "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", - "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"; exit $LASTEXITCODE" } ], "matcher": "Bash" @@ -48,7 +58,7 @@ { "type": "command", "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", - "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" + "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"; exit $LASTEXITCODE" } ] } diff --git a/chock.lock b/chock.lock index a2a3737..6cb7a30 100644 --- a/chock.lock +++ b/chock.lock @@ -16,7 +16,7 @@ "managed": false, "sha256": "59fffb91c6f65710b461e87782d355c1403953f36700e05369fca065683821c0", "source": "local", - "artifacts_sha256": "f621c131b3c183d70b729be8360212ff3b896af35d8f8c26899dc961d47e7a35" + "artifacts_sha256": "05d2a6b526cb31fadf13b08da0a6211b27f2f4352f1247075702a0a9eb73147c" }, { "id": "block-invisible-unicode", @@ -32,7 +32,7 @@ "managed": false, "sha256": "ef0d729c413086bd4ce77c7191bcc1393f4e0cdfcfc959b33a1ddb771afdf2b0", "source": "local", - "artifacts_sha256": "2382a8a41fc1d0920311ae9cf8b5498c54ef3931b25f330db1f6244a73800f22" + "artifacts_sha256": "9bb8ab646401b8a5fa586cbdd1b3654307ad1754dddb342952bbe27d7bdfef6a" }, { "id": "block-wildcard-agent-permissions", @@ -96,7 +96,7 @@ "managed": false, "sha256": "e73dbc039cdcbf137049a1f5035e4b595d12afe74a12a0f78e2e102d20d29479", "source": "local", - "artifacts_sha256": "cd4e097ea6a53c077cec25e0fd7116d73c26c51edf9f54d9fb4a9eb17f849f32" + "artifacts_sha256": "4ffb28559f53e6b3ccdb37b2ad48da2a7b747f3c161d3ff02fd260625c5768a4" }, { "id": "pre-generated-scripts", @@ -112,7 +112,7 @@ "managed": false, "sha256": "b98d5472c534d381b87e253086642675c71ce3947e2cfe01ed9fec5ed8b66295", "source": "local", - "artifacts_sha256": "f9087daed9092fb0ab4f41b3f478a1d79e9e004d892f90b2cd18793f008aba5d" + "artifacts_sha256": "433cd778b6236ae31cbc05524e71c420d7dd1cce665d0272192c75a1785357b5" }, { "id": "protect-commit-privacy", @@ -120,7 +120,7 @@ "managed": false, "sha256": "281522db2b259ea9a12d76b30d57ed9993882089b04a1fb4ce9932bc15a3388f", "source": "local", - "artifacts_sha256": "59517e11f8226a731f7371a48682aa6f3d0741ce6c9497f74e29beb0a85a1223" + "artifacts_sha256": "3c0acf064c6bf01714e0f53360ba786a0f800328225e91cee3b81258f7117686" }, { "id": "protect-main-branch", diff --git a/pyproject.toml b/pyproject.toml index 77a4276..e4e10c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,7 +60,7 @@ dependencies = [ # 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.3", + "agentseam==0.3.4", ] [project.optional-dependencies] diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/codex_cli-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/codex_cli-hooks.json index 4e5969e..fe57625 100644 --- a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/codex_cli-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/codex_cli-hooks.json @@ -6,7 +6,7 @@ { "type": "command", "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", - "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" + "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"; exit $LASTEXITCODE" } ], "matcher": "Bash" diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/pretooluse.json b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/pretooluse.json index 471317c..8aa8e13 100644 --- a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/pretooluse.json +++ b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/pretooluse.json @@ -1,5 +1,5 @@ { - "matcher": "Bash", + "matcher": "Bash|PowerShell", "hooks": [ { "type": "command", diff --git a/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/codex_cli-write-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/codex_cli-write-hooks.json new file mode 100644 index 0000000..2ad1f80 --- /dev/null +++ b/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/codex_cli-write-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/stability-script/pre-tool-use/gate.json\"", + "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/stability-script/pre-tool-use/gate.json\"; exit $LASTEXITCODE" + } + ], + "matcher": "apply_patch" + } + ] + } +} \ No newline at end of file diff --git a/tests/fixtures/emitter_stability/golden/stability-script/stop/codex_cli-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/stop/codex_cli-hooks.json index 0514481..7678485 100644 --- a/tests/fixtures/emitter_stability/golden/stability-script/stop/codex_cli-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-script/stop/codex_cli-hooks.json @@ -6,7 +6,7 @@ { "type": "command", "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/stability-script/stop/gate.json\"", - "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/stability-script/stop/gate.json\"" + "commandWindows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/stability-script/stop/gate.json\"; exit $LASTEXITCODE" } ] } diff --git a/tests/fixtures/runtime_goldens/antigravity.py b/tests/fixtures/runtime_goldens/antigravity.py index 13e739f..e255c53 100644 --- a/tests/fixtures/runtime_goldens/antigravity.py +++ b/tests/fixtures/runtime_goldens/antigravity.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.3 -- bundle("antigravity"). Do not hand-edit, except the +# Generated by agentseam 0.3.4 -- bundle("antigravity"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -9,8 +9,11 @@ from __future__ import annotations +import contextlib +import io import json _json = json +import os import sys import os as _chock_os @@ -24,7 +27,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.3) +# contract (agentseam 0.3.4) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -476,6 +479,8 @@ def hj_respond(cfg, decision, event, wire=None): _WINDOWS_KEYS = ("commandWindows", "windows") +_MATCHER_EVENTS = (PRE_TOOL, POST_TOOL, TOOL_FAILURE) + def _hook_dict(cfg, command): entry = {"type": "command", "command": command} for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): @@ -492,7 +497,7 @@ def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): if not name: continue rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: rule["matcher"] = matcher rules.append(rule) return rules @@ -531,7 +536,7 @@ def _default_wrapper(cfg, reverse, canonical_events, command, matcher): if not name: continue entry = {"hooks": [_hook_dict(cfg, command)]} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: entry["matcher"] = matcher hooks.setdefault(name, []).append(entry) if hook_entry.get("group"): @@ -1315,6 +1320,39 @@ def _report(text): return +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 + try: + os.dup2(2, 1) + except OSError: + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +@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() + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + with contextlib.suppress(Exception): + sink.flush() + 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) @@ -1324,7 +1362,9 @@ def _decide(raw): # vocabulary invites a decision made on a false premise. return "", 0 try: - decision = _coerce(handle(event)) + with _stdout_to_stderr(): + result = handle(event) + decision = _coerce(result) except Exception: # A door that cannot decide refuses. Escaping here would exit 1 with a traceback, # which every host reads as a non-blocking error and allows past; instead the diff --git a/tests/fixtures/runtime_goldens/claude_code.py b/tests/fixtures/runtime_goldens/claude_code.py index 18f0e01..882f63c 100644 --- a/tests/fixtures/runtime_goldens/claude_code.py +++ b/tests/fixtures/runtime_goldens/claude_code.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.3 -- bundle("claude_code"). Do not hand-edit, except the +# Generated by agentseam 0.3.4 -- bundle("claude_code"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -9,8 +9,11 @@ from __future__ import annotations +import contextlib +import io import json _json = json +import os import sys import os as _chock_os @@ -24,7 +27,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.3) +# contract (agentseam 0.3.4) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -563,6 +566,8 @@ def hj_respond(cfg, decision, event, wire=None): _WINDOWS_KEYS = ("commandWindows", "windows") +_MATCHER_EVENTS = (PRE_TOOL, POST_TOOL, TOOL_FAILURE) + def _hook_dict(cfg, command): entry = {"type": "command", "command": command} for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): @@ -579,7 +584,7 @@ def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): if not name: continue rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: rule["matcher"] = matcher rules.append(rule) return rules @@ -618,7 +623,7 @@ def _default_wrapper(cfg, reverse, canonical_events, command, matcher): if not name: continue entry = {"hooks": [_hook_dict(cfg, command)]} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: entry["matcher"] = matcher hooks.setdefault(name, []).append(entry) if hook_entry.get("group"): @@ -648,7 +653,7 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos AGENT = "claude_code" -VENDOR = {'agent': 'claude_code', 'claims': {'client_types': (None, 'claude_code'), 'event_key': ('hook_event_name',), 'mode': 'marker', 'notes': 'prompt_id rejects only when looks_like_claude_code(raw) is also false; a real Claude Code payload may carry prompt_id and must still be accepted (matrix-notes.json: fixed 2026-08-27).', 'reject_markers': ('turn_id', 'project_path', 'timestamp'), 'reject_markers_unless_probe': {'looks_like_claude_code': ('prompt_id',)}}, 'config_format': 'json', 'config_path': '.claude/settings.json', 'display': 'Claude Code', 'events': {'FileChanged': 'file_changed', 'InstructionsLoaded': 'instructions_loaded', 'PostToolUse': 'post_tool', 'PostToolUseFailure': 'tool_failure', 'PreCompact': 'pre_compact', 'PreToolUse': 'pre_tool', 'SessionEnd': 'session_end', 'SessionStart': 'session_start', 'Stop': 'stop', 'SubagentStart': 'subagent_start', 'SubagentStop': 'subagent_stop', 'UserPromptSubmit': 'prompt_submit'}, 'evidence': {'claims': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'repo_root_token': {'basis': 'vendor-docs', 'date': '2026-09-01', 'test': 'tests/test_vendor_config.py::test_repo_root_token_is_recorded_only_where_primary_sourced'}, 'tools': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'hook_json', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content', 'tool_input.new_string', 'tool_input.new_source', 'content', 'tool_input.edits[].new_string'), 'cwd': ('cwd',), 'output': ('tool_output',), 'path': ('tool_input.file_path', 'tool_input.path', 'tool_input.notebook_path', 'file_path'), 'prompt': ('prompt',), 'session_id': ('session_id',), 'tool': ('tool_name',), 'tool_use_id': ('tool_use_id',)}, 'hook_entry': {'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'repo_root_token': '${CLAUDE_PROJECT_DIR}', 'tools': {'shell': ('Bash',), 'write': ('Write', 'Edit', 'MultiEdit', 'NotebookEdit')}, 'verdicts': {'answer_events': ('PreToolUse', 'Stop', 'UserPromptSubmit'), 'bare_allow': 'silent', 'context_events': ('SessionStart', 'UserPromptSubmit'), 'context_source': 'context', 'degrade_notes': {'escalate': 'confirmation requested; this event cannot prompt, so it blocks', 'transform': 'input rewrite requested; this event cannot modify input, so it blocks'}, 'echo': 'reverse_map', 'gates': {'PreToolUse': {'grammar': 'G2', 'honours_escalate': True, 'honours_transform': True}, 'Stop': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'UserPromptSubmit': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'suffix', 'reason_defaults': {'deny_gate': 'blocked', 'escalate_gate': 'confirmation required'}, 'transform_grammar': 'hook_specific_updated_input', 'vocabulary': ('allow', 'ask', 'block', 'deny'), 'vocabulary_basis': 'verified', 'words': {'block': 'block', 'deny': 'deny', 'escalate': 'ask', 'transform': 'allow', 'vouch': 'allow'}}} +VENDOR = {'agent': 'claude_code', 'claims': {'client_types': (None, 'claude_code'), 'event_key': ('hook_event_name',), 'mode': 'marker', 'notes': 'prompt_id rejects only when looks_like_claude_code(raw) is also false; a real Claude Code payload may carry prompt_id and must still be accepted (matrix-notes.json: fixed 2026-08-27).', 'reject_markers': ('turn_id', 'project_path', 'timestamp'), 'reject_markers_unless_probe': {'looks_like_claude_code': ('prompt_id',)}}, 'config_format': 'json', 'config_path': '.claude/settings.json', 'display': 'Claude Code', 'events': {'FileChanged': 'file_changed', 'InstructionsLoaded': 'instructions_loaded', 'PostToolUse': 'post_tool', 'PostToolUseFailure': 'tool_failure', 'PreCompact': 'pre_compact', 'PreToolUse': 'pre_tool', 'SessionEnd': 'session_end', 'SessionStart': 'session_start', 'Stop': 'stop', 'SubagentStart': 'subagent_start', 'SubagentStop': 'subagent_stop', 'UserPromptSubmit': 'prompt_submit'}, 'evidence': {'claims': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'repo_root_token': {'basis': 'vendor-docs', 'date': '2026-09-01', 'test': 'tests/test_vendor_config.py::test_repo_root_token_is_recorded_only_where_primary_sourced'}, 'tools': {'basis': 'vendor-docs', 'date': '2026-09-27', 'test': 'tests/test_adapter_claude_code.py::test_powershell_is_a_shell_tool_and_its_command_is_parsed'}, 'verdicts': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'hook_json', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content', 'tool_input.new_string', 'tool_input.new_source', 'content', 'tool_input.edits[].new_string'), 'cwd': ('cwd',), 'output': ('tool_output',), 'path': ('tool_input.file_path', 'tool_input.path', 'tool_input.notebook_path', 'file_path'), 'prompt': ('prompt',), 'session_id': ('session_id',), 'tool': ('tool_name',), 'tool_use_id': ('tool_use_id',)}, 'hook_entry': {'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'repo_root_token': '${CLAUDE_PROJECT_DIR}', 'tools': {'shell': ('Bash', 'PowerShell'), 'write': ('Write', 'Edit', 'MultiEdit', 'NotebookEdit')}, 'verdicts': {'answer_events': ('PreToolUse', 'Stop', 'UserPromptSubmit'), 'bare_allow': 'silent', 'context_events': ('SessionStart', 'UserPromptSubmit'), 'context_source': 'context', 'degrade_notes': {'escalate': 'confirmation requested; this event cannot prompt, so it blocks', 'transform': 'input rewrite requested; this event cannot modify input, so it blocks'}, 'echo': 'reverse_map', 'gates': {'PreToolUse': {'grammar': 'G2', 'honours_escalate': True, 'honours_transform': True}, 'Stop': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'UserPromptSubmit': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'suffix', 'reason_defaults': {'deny_gate': 'blocked', 'escalate_gate': 'confirmation required'}, 'transform_grammar': 'hook_specific_updated_input', 'vocabulary': ('allow', 'ask', 'block', 'deny'), 'vocabulary_basis': 'verified', 'words': {'block': 'block', 'deny': 'deny', 'escalate': 'ask', 'transform': 'allow', 'vouch': 'allow'}}} def claims(raw): @@ -1448,6 +1453,39 @@ def _report(text): return +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 + try: + os.dup2(2, 1) + except OSError: + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +@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() + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + with contextlib.suppress(Exception): + sink.flush() + 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) @@ -1457,7 +1495,9 @@ def _decide(raw): # vocabulary invites a decision made on a false premise. return "", 0 try: - decision = _coerce(handle(event)) + with _stdout_to_stderr(): + result = handle(event) + decision = _coerce(result) except Exception: # A door that cannot decide refuses. Escaping here would exit 1 with a traceback, # which every host reads as a non-blocking error and allows past; instead the diff --git a/tests/fixtures/runtime_goldens/codex_cli.py b/tests/fixtures/runtime_goldens/codex_cli.py index 4fea531..33c2a8d 100644 --- a/tests/fixtures/runtime_goldens/codex_cli.py +++ b/tests/fixtures/runtime_goldens/codex_cli.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.3 -- bundle("codex_cli"). Do not hand-edit, except the +# Generated by agentseam 0.3.4 -- bundle("codex_cli"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -9,8 +9,11 @@ from __future__ import annotations +import contextlib +import io import json _json = json +import os import sys import os as _chock_os @@ -24,7 +27,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.3) +# contract (agentseam 0.3.4) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -217,9 +220,12 @@ def tool_input_of(raw): # ------------------------------------------------------------------------------ # hook_json family engine (trimmed to what this entry uses) +_KEEP_EXIT = "; exit $LASTEXITCODE" + def powershell_command(command): - """`command` rewritten so PowerShell will actually run it.""" - return command if command.lstrip().startswith("&") else "& " + command + """`command` rewritten so PowerShell will actually run it and keep its exit code.""" + body = command if command.lstrip().startswith("&") else "& " + command + return body if body.rstrip().endswith(_KEEP_EXIT) else body + _KEEP_EXIT UNREADABLE_NAME = "" @@ -550,6 +556,8 @@ def hj_respond(cfg, decision, event, wire=None): _WINDOWS_KEYS = ("commandWindows", "windows") +_MATCHER_EVENTS = (PRE_TOOL, POST_TOOL, TOOL_FAILURE) + def _hook_dict(cfg, command): entry = {"type": "command", "command": command} for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): @@ -566,7 +574,7 @@ def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): if not name: continue rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: rule["matcher"] = matcher rules.append(rule) return rules @@ -605,7 +613,7 @@ def _default_wrapper(cfg, reverse, canonical_events, command, matcher): if not name: continue entry = {"hooks": [_hook_dict(cfg, command)]} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: entry["matcher"] = matcher hooks.setdefault(name, []).append(entry) if hook_entry.get("group"): @@ -635,7 +643,7 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos AGENT = "codex_cli" -VENDOR = {'agent': 'codex_cli', 'claims': {'accept_markers': ('turn_id',), 'accept_when_all': {'SessionStart': ('session_id', 'transcript_path', 'cwd', 'model', 'permission_mode', 'source')}, 'event_key': ('hook_event_name',), 'mode': 'marker', 'notes': 'Codex sends no turn_id at SessionStart, so that one event is claimed by the accept_when_all compound instead (confirmed live 2026-08-28).'}, 'config_format': 'json', 'config_path': '.codex/hooks.json', 'display': 'OpenAI Codex CLI', 'events': {'PostToolUse': 'post_tool', '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-partial', 'date': '2026-08-28', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'live-run-partial', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'live-run-partial', 'date': '2026-08-28', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'live-run-partial', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'live-run-partial', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'live-run-partial', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'live-run-partial', 'date': '2026-08-28', 'test': 'tests/test_vendor_lookups.py::test_shell_tools_are_recorded_only_where_established'}, 'verdicts': {'basis': 'live-run-partial', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'hook_json', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content',), 'cwd': ('cwd',), 'output': ('tool_output',), 'path': ('tool_input.file_path', 'tool_input.path'), 'prompt': ('prompt',), 'session_id': ('session_id',), 'tool': ('tool_name',), 'tool_use_id': ('tool_use_id',)}, 'hook_entry': {'entry_extra': {'commandWindows': 'powershell wrapper (_windows.py)'}, 'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'tools': {'shell': ('Bash',)}, 'verdicts': {'answer_events': ('PreToolUse', 'Stop', 'UserPromptSubmit'), 'bare_allow': 'silent', 'degrade_notes': {'escalate': 'Codex CLI cannot prompt for confirmation at this event', 'escalate_gate': 'Codex CLI does not support ask; asking would fail open', 'transform': 'Codex CLI cannot modify a tool call at this event', 'transform_missing_input': 'Codex CLI cannot apply a rewrite with no updatedInput'}, 'echo': 'reverse_map', 'gates': {'PreToolUse': {'grammar': 'G2', 'honours_escalate': False, 'honours_transform': True}, 'Stop': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'UserPromptSubmit': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'because', 'reason_defaults': {'deny_gate': 'blocked'}, 'transform_grammar': 'hook_specific_updated_input', 'vocabulary': ('allow', 'block', 'deny'), 'vocabulary_basis': 'verified', 'words': {'block': 'block', 'deny': 'deny', 'transform': 'allow'}}} +VENDOR = {'agent': 'codex_cli', 'claims': {'accept_markers': ('turn_id',), 'accept_when_all': {'SessionStart': ('session_id', 'transcript_path', 'cwd', 'model', 'permission_mode', 'source')}, 'event_key': ('hook_event_name',), 'mode': 'marker', 'notes': 'Codex sends no turn_id at SessionStart, so that one event is claimed by the accept_when_all compound instead (confirmed live 2026-08-28).'}, 'config_format': 'json', 'config_path': '.codex/hooks.json', 'display': 'OpenAI Codex CLI', 'events': {'PostToolUse': 'post_tool', '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-partial', 'date': '2026-08-28', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'live-run-partial', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'live-run-partial', 'date': '2026-08-28', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'live-run-partial', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'live-run-partial', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'live-run-partial', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'live-run-partial', 'date': '2026-08-28', 'test': 'tests/test_vendor_lookups.py::test_shell_tools_are_recorded_only_where_established'}, 'trust_hint': {'basis': 'vendor-docs', 'date': '2026-09-27', 'test': 'tests/test_cli.py::test_install_says_how_to_trust_a_hook_the_agent_will_not_run_yet'}, 'verdicts': {'basis': 'live-run-partial', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'hook_json', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content',), 'cwd': ('cwd',), 'output': ('tool_output',), 'path': ('tool_input.file_path', 'tool_input.path'), 'prompt': ('prompt',), 'session_id': ('session_id',), 'tool': ('tool_name',), 'tool_use_id': ('tool_use_id',)}, 'hook_entry': {'entry_extra': {'commandWindows': 'powershell wrapper (_windows.py)'}, 'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': True, 'tools': {'shell': ('Bash',), 'write': ('apply_patch',)}, 'trust_hint': 'run /hooks in Codex and trust the new hook (trust is per hook hash: re-trust after any change)', 'verdicts': {'answer_events': ('PreToolUse', 'Stop', 'UserPromptSubmit'), 'bare_allow': 'silent', 'degrade_notes': {'escalate': 'Codex CLI cannot prompt for confirmation at this event', 'escalate_gate': 'Codex CLI does not support ask; asking would fail open', 'transform': 'Codex CLI cannot modify a tool call at this event', 'transform_missing_input': 'Codex CLI cannot apply a rewrite with no updatedInput'}, 'echo': 'reverse_map', 'gates': {'PreToolUse': {'grammar': 'G2', 'honours_escalate': False, 'honours_transform': True}, 'Stop': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'UserPromptSubmit': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'because', 'reason_defaults': {'deny_gate': 'blocked'}, 'transform_grammar': 'hook_specific_updated_input', 'vocabulary': ('allow', 'block', 'deny'), 'vocabulary_basis': 'verified', 'words': {'block': 'block', 'deny': 'deny', 'transform': 'allow'}}} def claims(raw): @@ -1367,6 +1375,39 @@ def _report(text): return +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 + try: + os.dup2(2, 1) + except OSError: + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +@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() + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + with contextlib.suppress(Exception): + sink.flush() + 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) @@ -1376,7 +1417,9 @@ def _decide(raw): # vocabulary invites a decision made on a false premise. return "", 0 try: - decision = _coerce(handle(event)) + with _stdout_to_stderr(): + result = handle(event) + decision = _coerce(result) except Exception: # A door that cannot decide refuses. Escaping here would exit 1 with a traceback, # which every host reads as a non-blocking error and allows past; instead the diff --git a/tests/fixtures/runtime_goldens/cursor.py b/tests/fixtures/runtime_goldens/cursor.py index 86867cb..1fba217 100644 --- a/tests/fixtures/runtime_goldens/cursor.py +++ b/tests/fixtures/runtime_goldens/cursor.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.3 -- bundle("cursor"). Do not hand-edit, except the +# Generated by agentseam 0.3.4 -- bundle("cursor"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -9,8 +9,11 @@ from __future__ import annotations +import contextlib +import io import json _json = json +import os import sys import os as _chock_os @@ -24,7 +27,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.3) +# contract (agentseam 0.3.4) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -351,6 +354,8 @@ def hj_reverse(cfg): _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(): @@ -367,7 +372,7 @@ def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): if not name: continue rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: rule["matcher"] = matcher rules.append(rule) return rules @@ -406,7 +411,7 @@ def _default_wrapper(cfg, reverse, canonical_events, command, matcher): if not name: continue entry = {"hooks": [_hook_dict(cfg, command)]} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: entry["matcher"] = matcher hooks.setdefault(name, []).append(entry) if hook_entry.get("group"): @@ -1295,6 +1300,39 @@ def _report(text): return +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 + try: + os.dup2(2, 1) + except OSError: + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +@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() + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + with contextlib.suppress(Exception): + sink.flush() + 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) @@ -1304,7 +1342,9 @@ def _decide(raw): # vocabulary invites a decision made on a false premise. return "", 0 try: - decision = _coerce(handle(event)) + with _stdout_to_stderr(): + result = handle(event) + decision = _coerce(result) except Exception: # A door that cannot decide refuses. Escaping here would exit 1 with a traceback, # which every host reads as a non-blocking error and allows past; instead the diff --git a/tests/fixtures/runtime_goldens/devin.py b/tests/fixtures/runtime_goldens/devin.py index f2c3fca..141f5cc 100644 --- a/tests/fixtures/runtime_goldens/devin.py +++ b/tests/fixtures/runtime_goldens/devin.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.3 -- bundle("devin"). Do not hand-edit, except the +# Generated by agentseam 0.3.4 -- bundle("devin"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -9,8 +9,11 @@ from __future__ import annotations +import contextlib +import io import json _json = json +import os import sys import os as _chock_os @@ -24,7 +27,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.3) +# contract (agentseam 0.3.4) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -532,6 +535,8 @@ def hj_respond(cfg, decision, event, wire=None): _WINDOWS_KEYS = ("commandWindows", "windows") +_MATCHER_EVENTS = (PRE_TOOL, POST_TOOL, TOOL_FAILURE) + def _hook_dict(cfg, command): entry = {"type": "command", "command": command} for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): @@ -548,7 +553,7 @@ def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): if not name: continue rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: rule["matcher"] = matcher rules.append(rule) return rules @@ -587,7 +592,7 @@ def _default_wrapper(cfg, reverse, canonical_events, command, matcher): if not name: continue entry = {"hooks": [_hook_dict(cfg, command)]} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: entry["matcher"] = matcher hooks.setdefault(name, []).append(entry) if hook_entry.get("group"): @@ -617,7 +622,7 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos AGENT = "devin" -VENDOR = {'agent': 'devin', 'claims': {'accept_markers': ('prompt_id',), 'accept_names': ('PermissionRequest', 'PostCompaction'), 'event_key': ('hook_event_name',), 'mode': 'marker', 'notes': 'accept_names are names Claude Code never sends, claimed before any marker check -- except against a client_type that names another vendor, since Kimi Code sends PermissionRequest too; prompt_id is required alongside looks_like_claude_code(raw) being false.', 'reject_client_types': ('kimi_code_cli',), 'reject_probes': ('looks_like_claude_code',)}, 'config_format': 'json', 'config_path': '.devin/hooks.v1.json', 'display': 'Devin', 'events': {'PermissionRequest': 'pre_tool', 'PostToolUse': 'post_tool', 'PreToolUse': 'pre_tool', 'SessionEnd': 'session_end', 'SessionStart': 'session_start', 'Stop': 'stop', 'UserPromptSubmit': 'prompt_submit'}, 'evidence': {'claims': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'hook_json', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content', 'tool_input.new_string'), 'cwd': ('cwd',), 'output': ('tool_output',), 'path': ('tool_input.file_path', 'tool_input.path'), 'prompt': ('prompt',), 'session_id': ('session_id',), 'tool': ('tool_name',)}, 'hook_entry': {'bare': True, 'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'tools': {}, 'verdicts': {'answer_events': ('PermissionRequest', 'PreToolUse', 'Stop', 'UserPromptSubmit'), 'bare_allow': 'unverified', 'context_events': ('PostToolUse', 'SessionStart', 'UserPromptSubmit'), 'context_source': 'reason', 'default_wire_event': 'PreToolUse', 'degrade_notes': {'escalate': 'Devin cannot prompt for confirmation, so this is a block', 'escalate_from_transform': '%s (Devin cannot modify the input at %s, so this is a block)'}, 'echo': 'payload', 'gates': {'PermissionRequest': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'PreToolUse': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': True}, 'Stop': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'UserPromptSubmit': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'suffix', 'reason_defaults': {'transform': 'input requires modification'}, 'transform_grammar': 'hook_specific_updated_input', 'vocabulary': ('approve', 'block'), 'vocabulary_basis': 'verified', 'words': {'allow': 'approve', 'block': 'block'}}, 'wire_events': {'pre_tool': 'PreToolUse'}} +VENDOR = {'agent': 'devin', 'claims': {'accept_markers': ('prompt_id',), 'accept_names': ('PostCompaction',), 'event_key': ('hook_event_name',), 'mode': 'marker', 'notes': 'accept_names are names Claude Code never sends, claimed before any marker check -- except against a client_type that names another vendor. PermissionRequest is not one: Claude Code sends it too (code.claude.com/docs/en/hooks), so it takes the marker path; prompt_id is required alongside looks_like_claude_code(raw) being false.', 'reject_client_types': ('kimi_code_cli',), 'reject_probes': ('looks_like_claude_code',)}, 'config_format': 'json', 'config_path': '.devin/hooks.v1.json', 'display': 'Devin', 'events': {'PermissionRequest': 'pre_tool', 'PostToolUse': 'post_tool', 'PreToolUse': 'pre_tool', 'SessionEnd': 'session_end', 'SessionStart': 'session_start', 'Stop': 'stop', 'UserPromptSubmit': 'prompt_submit'}, 'evidence': {'claims': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'hook_json', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content', 'tool_input.new_string'), 'cwd': ('cwd',), 'output': ('tool_output',), 'path': ('tool_input.file_path', 'tool_input.path'), 'prompt': ('prompt',), 'session_id': ('session_id',), 'tool': ('tool_name',)}, 'hook_entry': {'bare': True, 'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'tools': {}, 'verdicts': {'answer_events': ('PermissionRequest', 'PreToolUse', 'Stop', 'UserPromptSubmit'), 'bare_allow': 'unverified', 'context_events': ('PostToolUse', 'SessionStart', 'UserPromptSubmit'), 'context_source': 'reason', 'default_wire_event': 'PreToolUse', 'degrade_notes': {'escalate': 'Devin cannot prompt for confirmation, so this is a block', 'escalate_from_transform': '%s (Devin cannot modify the input at %s, so this is a block)'}, 'echo': 'payload', 'gates': {'PermissionRequest': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'PreToolUse': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': True}, 'Stop': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'UserPromptSubmit': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'suffix', 'reason_defaults': {'transform': 'input requires modification'}, 'transform_grammar': 'hook_specific_updated_input', 'vocabulary': ('approve', 'block'), 'vocabulary_basis': 'verified', 'words': {'allow': 'approve', 'block': 'block'}}, 'wire_events': {'pre_tool': 'PreToolUse'}} def claims(raw): @@ -1349,6 +1354,39 @@ def _report(text): return +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 + try: + os.dup2(2, 1) + except OSError: + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +@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() + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + with contextlib.suppress(Exception): + sink.flush() + 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) @@ -1358,7 +1396,9 @@ def _decide(raw): # vocabulary invites a decision made on a false premise. return "", 0 try: - decision = _coerce(handle(event)) + with _stdout_to_stderr(): + result = handle(event) + decision = _coerce(result) except Exception: # A door that cannot decide refuses. Escaping here would exit 1 with a traceback, # which every host reads as a non-blocking error and allows past; instead the diff --git a/tests/fixtures/runtime_goldens/gemini_cli.py b/tests/fixtures/runtime_goldens/gemini_cli.py index bd64575..b3515d1 100644 --- a/tests/fixtures/runtime_goldens/gemini_cli.py +++ b/tests/fixtures/runtime_goldens/gemini_cli.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.3 -- bundle("gemini_cli"). Do not hand-edit, except the +# Generated by agentseam 0.3.4 -- bundle("gemini_cli"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -9,8 +9,11 @@ from __future__ import annotations +import contextlib +import io import json _json = json +import os import sys import os as _chock_os @@ -24,7 +27,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.3) +# contract (agentseam 0.3.4) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -532,6 +535,8 @@ def hj_respond(cfg, decision, event, wire=None): _WINDOWS_KEYS = ("commandWindows", "windows") +_MATCHER_EVENTS = (PRE_TOOL, POST_TOOL, TOOL_FAILURE) + def _hook_dict(cfg, command): entry = {"type": "command", "command": command} for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): @@ -548,7 +553,7 @@ def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): if not name: continue rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: rule["matcher"] = matcher rules.append(rule) return rules @@ -587,7 +592,7 @@ def _default_wrapper(cfg, reverse, canonical_events, command, matcher): if not name: continue entry = {"hooks": [_hook_dict(cfg, command)]} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: entry["matcher"] = matcher hooks.setdefault(name, []).append(entry) if hook_entry.get("group"): @@ -617,7 +622,7 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos AGENT = "gemini_cli" -VENDOR = {'agent': 'gemini_cli', 'claims': {'client_types': (None, 'gemini_cli', 'gemini'), 'event_key': ('hook_event_name',), 'mode': 'marker', 'reject_markers': ('timestamp', 'project_path', 'prompt_id', 'turn_id'), 'reject_probes': ('looks_like_claude_code',)}, 'config_format': 'json', 'config_path': '.gemini/settings.json', 'display': 'Gemini CLI', 'events': {'AfterAgent': 'stop', 'AfterTool': 'post_tool', 'BeforeAgent': 'prompt_submit', 'BeforeTool': 'pre_tool', 'PreCompress': 'pre_compact', 'SessionEnd': 'session_end', 'SessionStart': 'session_start'}, 'evidence': {'claims': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'flat_decision', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content', 'tool_input.new_string', 'tool_input.new_str'), 'content_only_for_write_tools': True, 'cwd': ('cwd',), 'output': ('tool_output', 'tool_response'), 'path': ('tool_input.file_path', 'tool_input.absolute_path', 'tool_input.path'), 'prompt': ('prompt', 'user_message'), 'session_id': ('session_id',), 'tool': ('tool_name',)}, 'hook_entry': {'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'tools': {'shell': ('run_shell_command',), 'write': ('write_file', 'replace')}, 'verdicts': {'answer_events': ('AfterAgent', 'AfterTool', 'BeforeAgent', 'BeforeTool'), 'bare_allow': 'inert', 'degrade_notes': {'escalate': '%s (confirmation required; %s cannot prompt from a hook)'}, 'gates': {'AfterAgent': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'AfterTool': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'BeforeAgent': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'BeforeTool': {'grammar': 'G1', 'honours_escalate': True, 'honours_transform': True}}, 'reason_defaults': {'escalate': 'policy requires confirmation', 'escalate_gate': 'confirmation required'}, 'transform_grammar': 'hook_specific_tool_input', 'vocabulary': ('allow', 'ask', 'deny'), 'vocabulary_basis': 'verified', 'words': {'allow': 'allow', 'block': 'deny', 'escalate': 'ask'}}} +VENDOR = {'agent': 'gemini_cli', 'claims': {'client_types': (None, 'gemini_cli', 'gemini'), 'event_key': ('hook_event_name',), 'mode': 'marker', 'notes': "timestamp is in Gemini's documented base input too: rejecting it is a deliberate tie-break toward Tabnine, which sends the same shape and whose deny/allow wire is identical (ask/transform degrade to deny); a declining detect() would allow silently. Name gemini_cli to get ask/transform.", 'reject_markers': ('timestamp', 'project_path', 'prompt_id', 'turn_id'), 'reject_probes': ('looks_like_claude_code',)}, 'config_format': 'json', 'config_path': '.gemini/settings.json', 'display': 'Gemini CLI', 'events': {'AfterAgent': 'stop', 'AfterTool': 'post_tool', 'BeforeAgent': 'prompt_submit', 'BeforeTool': 'pre_tool', 'PreCompress': 'pre_compact', 'SessionEnd': 'session_end', 'SessionStart': 'session_start'}, 'evidence': {'claims': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'flat_decision', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content', 'tool_input.new_string', 'tool_input.new_str'), 'content_only_for_write_tools': True, 'cwd': ('cwd',), 'output': ('tool_output', 'tool_response'), 'path': ('tool_input.file_path', 'tool_input.absolute_path', 'tool_input.path'), 'prompt': ('prompt', 'user_message'), 'session_id': ('session_id',), 'tool': ('tool_name',)}, 'hook_entry': {'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'tools': {'shell': ('run_shell_command',), 'write': ('write_file', 'replace')}, 'verdicts': {'answer_events': ('AfterAgent', 'AfterTool', 'BeforeAgent', 'BeforeTool'), 'bare_allow': 'inert', 'degrade_notes': {'escalate': '%s (confirmation required; %s cannot prompt from a hook)'}, 'gates': {'AfterAgent': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'AfterTool': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'BeforeAgent': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'BeforeTool': {'grammar': 'G1', 'honours_escalate': True, 'honours_transform': True}}, 'reason_defaults': {'escalate': 'policy requires confirmation', 'escalate_gate': 'confirmation required'}, 'transform_grammar': 'hook_specific_tool_input', 'vocabulary': ('allow', 'ask', 'deny'), 'vocabulary_basis': 'verified', 'words': {'allow': 'allow', 'block': 'deny', 'escalate': 'ask'}}} def claims(raw): @@ -1349,6 +1354,39 @@ def _report(text): return +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 + try: + os.dup2(2, 1) + except OSError: + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +@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() + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + with contextlib.suppress(Exception): + sink.flush() + 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) @@ -1358,7 +1396,9 @@ def _decide(raw): # vocabulary invites a decision made on a false premise. return "", 0 try: - decision = _coerce(handle(event)) + with _stdout_to_stderr(): + result = handle(event) + decision = _coerce(result) except Exception: # A door that cannot decide refuses. Escaping here would exit 1 with a traceback, # which every host reads as a non-blocking error and allows past; instead the diff --git a/tests/fixtures/runtime_goldens/grok.py b/tests/fixtures/runtime_goldens/grok.py index d07f8d0..60ddb37 100644 --- a/tests/fixtures/runtime_goldens/grok.py +++ b/tests/fixtures/runtime_goldens/grok.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.3 -- bundle("grok"). Do not hand-edit, except the +# Generated by agentseam 0.3.4 -- bundle("grok"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -9,8 +9,11 @@ from __future__ import annotations +import contextlib +import io import json _json = json +import os import sys import os as _chock_os @@ -24,7 +27,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.3) +# contract (agentseam 0.3.4) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -515,6 +518,8 @@ def hj_respond(cfg, decision, event, wire=None): _WINDOWS_KEYS = ("commandWindows", "windows") +_MATCHER_EVENTS = (PRE_TOOL, POST_TOOL, TOOL_FAILURE) + def _hook_dict(cfg, command): entry = {"type": "command", "command": command} for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): @@ -531,7 +536,7 @@ def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): if not name: continue rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: rule["matcher"] = matcher rules.append(rule) return rules @@ -570,7 +575,7 @@ def _default_wrapper(cfg, reverse, canonical_events, command, matcher): if not name: continue entry = {"hooks": [_hook_dict(cfg, command)]} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: entry["matcher"] = matcher hooks.setdefault(name, []).append(entry) if hook_entry.get("group"): @@ -1332,6 +1337,39 @@ def _report(text): return +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 + try: + os.dup2(2, 1) + except OSError: + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +@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() + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + with contextlib.suppress(Exception): + sink.flush() + 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) @@ -1341,7 +1379,9 @@ def _decide(raw): # vocabulary invites a decision made on a false premise. return "", 0 try: - decision = _coerce(handle(event)) + with _stdout_to_stderr(): + result = handle(event) + decision = _coerce(result) except Exception: # A door that cannot decide refuses. Escaping here would exit 1 with a traceback, # which every host reads as a non-blocking error and allows past; instead the diff --git a/tests/fixtures/runtime_goldens/tabnine.py b/tests/fixtures/runtime_goldens/tabnine.py index 5bb6f8d..78f7fe4 100644 --- a/tests/fixtures/runtime_goldens/tabnine.py +++ b/tests/fixtures/runtime_goldens/tabnine.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.3 -- bundle("tabnine"). Do not hand-edit, except the +# Generated by agentseam 0.3.4 -- bundle("tabnine"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -9,8 +9,11 @@ from __future__ import annotations +import contextlib +import io import json _json = json +import os import sys import os as _chock_os @@ -24,7 +27,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.3) +# contract (agentseam 0.3.4) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -515,6 +518,8 @@ def hj_respond(cfg, decision, event, wire=None): _WINDOWS_KEYS = ("commandWindows", "windows") +_MATCHER_EVENTS = (PRE_TOOL, POST_TOOL, TOOL_FAILURE) + def _hook_dict(cfg, command): entry = {"type": "command", "command": command} for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): @@ -531,7 +536,7 @@ def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): if not name: continue rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: rule["matcher"] = matcher rules.append(rule) return rules @@ -570,7 +575,7 @@ def _default_wrapper(cfg, reverse, canonical_events, command, matcher): if not name: continue entry = {"hooks": [_hook_dict(cfg, command)]} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: entry["matcher"] = matcher hooks.setdefault(name, []).append(entry) if hook_entry.get("group"): @@ -1332,6 +1337,39 @@ def _report(text): return +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 + try: + os.dup2(2, 1) + except OSError: + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +@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() + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + with contextlib.suppress(Exception): + sink.flush() + 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) @@ -1341,7 +1379,9 @@ def _decide(raw): # vocabulary invites a decision made on a false premise. return "", 0 try: - decision = _coerce(handle(event)) + with _stdout_to_stderr(): + result = handle(event) + decision = _coerce(result) except Exception: # A door that cannot decide refuses. Escaping here would exit 1 with a traceback, # which every host reads as a non-blocking error and allows past; instead the diff --git a/tests/fixtures/runtime_goldens/vscode_copilot.py b/tests/fixtures/runtime_goldens/vscode_copilot.py index 5f35eb1..bd6d4fa 100644 --- a/tests/fixtures/runtime_goldens/vscode_copilot.py +++ b/tests/fixtures/runtime_goldens/vscode_copilot.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.3 -- bundle("vscode_copilot"). Do not hand-edit, except the +# Generated by agentseam 0.3.4 -- bundle("vscode_copilot"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -9,8 +9,11 @@ from __future__ import annotations +import contextlib +import io import json _json = json +import os import sys import os as _chock_os @@ -24,7 +27,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.3) +# contract (agentseam 0.3.4) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -220,10 +223,15 @@ def tool_input_of(raw): """PowerShell's one rule that breaks hook commands, shared by the vendors it affects.""" +#: `pwsh -Command` exits 1 whenever the last native command failed, whatever its code, so a +#: hook's exit 2 (block) reached the host as 1 (a non-blocking error): openai/codex#48183. +_KEEP_EXIT = "; exit $LASTEXITCODE" + def powershell_command(command): - """`command` rewritten so PowerShell will actually run it.""" - return command if command.lstrip().startswith("&") else "& " + command + """`command` rewritten so PowerShell will actually run it and keep its exit code.""" + body = command if command.lstrip().startswith("&") else "& " + command + return body if body.rstrip().endswith(_KEEP_EXIT) else body + _KEEP_EXIT # ------------------------------------------------------------------------------ @@ -268,6 +276,14 @@ def powershell_command(command): _VSCODE_ENVELOPE = "timestamp" +#: Copilot CLI's own camelCase input (docs.github.com/en/copilot/reference/hooks-configuration, +#: read 2026-09-27): {sessionId, timestamp, cwd, toolName, toolArgs[, toolResult]} and no event +#: name at all. `toolArgs` is documented as the parsed arguments; the JSON-string form earlier +#: CLI builds sent is decoded too. +_CLI_ARGS = "toolArgs" +_CLI_RESULT = "toolResult" +_CLI_KEYS = (_CLI_ARGS, "toolName", "sessionId") + def _name(raw): """The payload's own event name as text; None when it names none, UNKNOWN when the @@ -291,15 +307,37 @@ def claims(raw): return False if name in _CLAIMABLE: return True + if name is None and _CLI_ARGS in raw: + return True ti = raw.get("tool_input") return raw.get("tool_name") in MEMORY_TOOLS and isinstance(ti, dict) and "command" in ti +def _tool_input(raw): + """The tool's arguments: VS Code's `tool_input`, else the CLI's `toolArgs` (object or JSON text).""" + ti = raw.get("tool_input") + return tool_input_of(raw.get(_CLI_ARGS) if ti is None else ti) + + +def is_cli_native(raw): + """True for Copilot CLI's camelCase payloads, which get its top-level permission answer.""" + if not isinstance(raw, dict): + return False + name = _name(raw) + if name is None: + return any(k in raw for k in _CLI_KEYS) + return name in _CLAIMABLE + + +def _cli_output(raw): + result = raw.get(_CLI_RESULT) + return result.get("textResultForLlm") if isinstance(result, dict) else None + + def parse(raw): if not isinstance(raw, dict): return Event(AGENT, UNKNOWN, raw=raw) - ti = raw.get("tool_input") - ti = tool_input_of(ti) + ti = _tool_input(raw) tool = raw.get("tool_name") or raw.get("toolName") path = content = None if tool in MEMORY_TOOLS: @@ -311,7 +349,7 @@ def parse(raw): else: path = ti.get("filePath") or ti.get("file_path") or ti.get("path") content = ti.get("content") or ti.get("newText") or ti.get("new_str") - name = _name(raw) or "preToolUse" + name = _name(raw) or ("postToolUse" if _CLI_RESULT in raw else "preToolUse") return Event( AGENT, EVENT_MAP.get(name, UNKNOWN), @@ -320,8 +358,8 @@ def parse(raw): path=path, content=content, prompt=raw.get("prompt"), - output=raw.get("tool_output") or raw.get("tool_response"), - session_id=raw.get("session_id"), + output=raw.get("tool_output") or raw.get("tool_response") or _cli_output(raw), + session_id=raw.get("session_id") or raw.get("sessionId"), tool_use_id=raw.get("tool_use_id"), cwd=raw.get("cwd"), raw=raw, @@ -330,8 +368,7 @@ def parse(raw): def is_memory_write(event): """True when this event is a memory-tool content write (VS Code's memory surface).""" - ti = event.raw.get("tool_input") - ti = tool_input_of(ti) + ti = _tool_input(event.raw) return event.tool in MEMORY_TOOLS and ti.get("command") in MEMORY_WRITE_COMMANDS @@ -395,6 +432,18 @@ def _pre_tool_out(decision, event): return out +def _cli_pre_tool_out(decision): + """The CLI's documented top-level answer; it has no input rewrite, so a rewrite blocks.""" + if decision.outcome == VOUCH: + out = {"permissionDecision": "allow"} + if decision.reason: + out[_PERMISSION_DECISION_REASON] = decision.reason + return out + if decision.outcome == ASK: + return {"permissionDecision": "ask", _PERMISSION_DECISION_REASON: decision.reason or "confirmation required"} + return {"permissionDecision": "deny", _PERMISSION_DECISION_REASON: _refusal_reason(decision)} + + def respond(decision, event): """Three dialects, one per event group -- not one gate shape everywhere.""" import json as _json # noqa: PLC0415 (bundler.py keeps this vendored file's own function-local @@ -413,6 +462,9 @@ def respond(decision, event): if event.event != PRE_TOOL or decision.outcome == ALLOW: return "", 0 + if is_cli_native(event.raw): + return _json.dumps(_cli_pre_tool_out(decision)), 0 + return _json.dumps({"hookSpecificOutput": _pre_tool_out(decision, event)}), 0 @@ -1156,6 +1208,39 @@ def _report(text): return +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 + try: + os.dup2(2, 1) + except OSError: + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +@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() + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + with contextlib.suppress(Exception): + sink.flush() + 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) @@ -1165,7 +1250,9 @@ def _decide(raw): # vocabulary invites a decision made on a false premise. return "", 0 try: - decision = _coerce(handle(event)) + with _stdout_to_stderr(): + result = handle(event) + decision = _coerce(result) except Exception: # A door that cannot decide refuses. Escaping here would exit 1 with a traceback, # which every host reads as a non-blocking error and allows past; instead the diff --git a/tests/fixtures/runtime_goldens/windsurf.py b/tests/fixtures/runtime_goldens/windsurf.py index bd3fa66..d323fbb 100644 --- a/tests/fixtures/runtime_goldens/windsurf.py +++ b/tests/fixtures/runtime_goldens/windsurf.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.3 -- bundle("windsurf"). Do not hand-edit, except the +# Generated by agentseam 0.3.4 -- bundle("windsurf"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -9,8 +9,11 @@ from __future__ import annotations +import contextlib +import io import json _json = json +import os import sys import os as _chock_os @@ -24,7 +27,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.3) +# contract (agentseam 0.3.4) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -388,6 +391,8 @@ def _refusal_text(v, decision, at_gate, wire=None): _WINDOWS_KEYS = ("commandWindows", "windows") +_MATCHER_EVENTS = (PRE_TOOL, POST_TOOL, TOOL_FAILURE) + def _hook_dict(cfg, command): entry = {"type": "command", "command": command} for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): @@ -404,7 +409,7 @@ def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): if not name: continue rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: rule["matcher"] = matcher rules.append(rule) return rules @@ -443,7 +448,7 @@ def _default_wrapper(cfg, reverse, canonical_events, command, matcher): if not name: continue entry = {"hooks": [_hook_dict(cfg, command)]} - if matcher and hook_entry["matcher"]: + if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: entry["matcher"] = matcher hooks.setdefault(name, []).append(entry) if hook_entry.get("group"): @@ -1246,6 +1251,39 @@ def _report(text): return +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 + try: + os.dup2(2, 1) + except OSError: + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +@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() + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + with contextlib.suppress(Exception): + sink.flush() + 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) @@ -1255,7 +1293,9 @@ def _decide(raw): # vocabulary invites a decision made on a false premise. return "", 0 try: - decision = _coerce(handle(event)) + with _stdout_to_stderr(): + result = handle(event) + decision = _coerce(result) except Exception: # A door that cannot decide refuses. Escaping here would exit 1 with a traceback, # which every host reads as a non-blocking error and allows past; instead the From 0e15a634e24c364e817a6abc6cc4470824e76cf7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 12:41:59 -0400 Subject: [PATCH 2/9] Stop the edit reader shadowing agentseam's vscode_copilot tool-input reader The vendored runtime is one flat module. agentseam 0.3.4 added vscode_copilot's _tool_input(raw) (tool_input, else Copilot CLI's toolArgs); edit_image's own _tool_input(event), spliced in after it, rebound the name, so parse() read no command and every Copilot guard and gate allowed the call, in VS Code and the CLI alike. Rename chock's helper and pin that no top-level name in chock's handler rebinds one of agentseam's bundle (PRE_TOOL, equal on both sides, excepted). Runtime goldens and this repo's runtimes regenerated. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Claude --- .chock/bin/claude_code.py | 4 ++-- .chock/bin/codex_cli.py | 4 ++-- .chock/bin/cursor.py | 4 ++-- .chock/bin/devin.py | 4 ++-- .chock/bin/gemini_cli.py | 4 ++-- .chock/bin/grok.py | 4 ++-- .chock/bin/tabnine.py | 4 ++-- .chock/bin/vscode_copilot.py | 4 ++-- .chock/bin/windsurf.py | 4 ++-- src/chock/gate/edit_image.py | 4 ++-- tests/fixtures/runtime_goldens/antigravity.py | 4 ++-- tests/fixtures/runtime_goldens/claude_code.py | 4 ++-- tests/fixtures/runtime_goldens/codex_cli.py | 4 ++-- tests/fixtures/runtime_goldens/cursor.py | 4 ++-- tests/fixtures/runtime_goldens/devin.py | 4 ++-- tests/fixtures/runtime_goldens/gemini_cli.py | 4 ++-- tests/fixtures/runtime_goldens/grok.py | 4 ++-- tests/fixtures/runtime_goldens/tabnine.py | 4 ++-- .../runtime_goldens/vscode_copilot.py | 4 ++-- tests/fixtures/runtime_goldens/windsurf.py | 4 ++-- tests/test_runtime_goldens.py | 24 ++++++++++++++++++- 21 files changed, 63 insertions(+), 41 deletions(-) diff --git a/.chock/bin/claude_code.py b/.chock/bin/claude_code.py index 882f63c..eb4ff9d 100755 --- a/.chock/bin/claude_code.py +++ b/.chock/bin/claude_code.py @@ -898,7 +898,7 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _CRLF = '\r\n' -def _tool_input(event): +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] == '{': @@ -918,7 +918,7 @@ def _pair(item): def edit_replacements(event): """The (old, new, replace_all) replacements an edit call applies, in order; None if not an edit.""" - tool_input = _tool_input(event) + 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: diff --git a/.chock/bin/codex_cli.py b/.chock/bin/codex_cli.py index 33c2a8d..5f5330b 100755 --- a/.chock/bin/codex_cli.py +++ b/.chock/bin/codex_cli.py @@ -888,7 +888,7 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _CRLF = '\r\n' -def _tool_input(event): +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] == '{': @@ -908,7 +908,7 @@ def _pair(item): def edit_replacements(event): """The (old, new, replace_all) replacements an edit call applies, in order; None if not an edit.""" - tool_input = _tool_input(event) + 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: diff --git a/.chock/bin/cursor.py b/.chock/bin/cursor.py index 1fba217..f724ae1 100755 --- a/.chock/bin/cursor.py +++ b/.chock/bin/cursor.py @@ -813,7 +813,7 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _CRLF = '\r\n' -def _tool_input(event): +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] == '{': @@ -833,7 +833,7 @@ def _pair(item): def edit_replacements(event): """The (old, new, replace_all) replacements an edit call applies, in order; None if not an edit.""" - tool_input = _tool_input(event) + 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: diff --git a/.chock/bin/devin.py b/.chock/bin/devin.py index 141f5cc..8c4ed53 100755 --- a/.chock/bin/devin.py +++ b/.chock/bin/devin.py @@ -867,7 +867,7 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _CRLF = '\r\n' -def _tool_input(event): +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] == '{': @@ -887,7 +887,7 @@ def _pair(item): def edit_replacements(event): """The (old, new, replace_all) replacements an edit call applies, in order; None if not an edit.""" - tool_input = _tool_input(event) + 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: diff --git a/.chock/bin/gemini_cli.py b/.chock/bin/gemini_cli.py index b3515d1..c3c9d7d 100755 --- a/.chock/bin/gemini_cli.py +++ b/.chock/bin/gemini_cli.py @@ -867,7 +867,7 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _CRLF = '\r\n' -def _tool_input(event): +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] == '{': @@ -887,7 +887,7 @@ def _pair(item): def edit_replacements(event): """The (old, new, replace_all) replacements an edit call applies, in order; None if not an edit.""" - tool_input = _tool_input(event) + 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: diff --git a/.chock/bin/grok.py b/.chock/bin/grok.py index 60ddb37..62799b7 100755 --- a/.chock/bin/grok.py +++ b/.chock/bin/grok.py @@ -850,7 +850,7 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _CRLF = '\r\n' -def _tool_input(event): +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] == '{': @@ -870,7 +870,7 @@ def _pair(item): def edit_replacements(event): """The (old, new, replace_all) replacements an edit call applies, in order; None if not an edit.""" - tool_input = _tool_input(event) + 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: diff --git a/.chock/bin/tabnine.py b/.chock/bin/tabnine.py index 78f7fe4..30fd876 100755 --- a/.chock/bin/tabnine.py +++ b/.chock/bin/tabnine.py @@ -850,7 +850,7 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _CRLF = '\r\n' -def _tool_input(event): +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] == '{': @@ -870,7 +870,7 @@ def _pair(item): def edit_replacements(event): """The (old, new, replace_all) replacements an edit call applies, in order; None if not an edit.""" - tool_input = _tool_input(event) + 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: diff --git a/.chock/bin/vscode_copilot.py b/.chock/bin/vscode_copilot.py index bd6d4fa..10ec103 100755 --- a/.chock/bin/vscode_copilot.py +++ b/.chock/bin/vscode_copilot.py @@ -721,7 +721,7 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _CRLF = '\r\n' -def _tool_input(event): +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] == '{': @@ -741,7 +741,7 @@ def _pair(item): def edit_replacements(event): """The (old, new, replace_all) replacements an edit call applies, in order; None if not an edit.""" - tool_input = _tool_input(event) + 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: diff --git a/.chock/bin/windsurf.py b/.chock/bin/windsurf.py index d323fbb..d1a92a2 100755 --- a/.chock/bin/windsurf.py +++ b/.chock/bin/windsurf.py @@ -764,7 +764,7 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _CRLF = '\r\n' -def _tool_input(event): +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] == '{': @@ -784,7 +784,7 @@ def _pair(item): def edit_replacements(event): """The (old, new, replace_all) replacements an edit call applies, in order; None if not an edit.""" - tool_input = _tool_input(event) + 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: diff --git a/src/chock/gate/edit_image.py b/src/chock/gate/edit_image.py index 4c9f748..9ede0a5 100644 --- a/src/chock/gate/edit_image.py +++ b/src/chock/gate/edit_image.py @@ -18,7 +18,7 @@ _CRLF = "\r\n" -def _tool_input(event): +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] == "{": @@ -40,7 +40,7 @@ def _pair(item): def edit_replacements(event): """The (old, new, replace_all) replacements an edit call applies, in order; None if not an edit.""" - tool_input = _tool_input(event) + 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: diff --git a/tests/fixtures/runtime_goldens/antigravity.py b/tests/fixtures/runtime_goldens/antigravity.py index e255c53..fcc81f5 100644 --- a/tests/fixtures/runtime_goldens/antigravity.py +++ b/tests/fixtures/runtime_goldens/antigravity.py @@ -833,7 +833,7 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _CRLF = '\r\n' -def _tool_input(event): +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] == '{': @@ -853,7 +853,7 @@ def _pair(item): def edit_replacements(event): """The (old, new, replace_all) replacements an edit call applies, in order; None if not an edit.""" - tool_input = _tool_input(event) + 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: diff --git a/tests/fixtures/runtime_goldens/claude_code.py b/tests/fixtures/runtime_goldens/claude_code.py index 882f63c..eb4ff9d 100644 --- a/tests/fixtures/runtime_goldens/claude_code.py +++ b/tests/fixtures/runtime_goldens/claude_code.py @@ -898,7 +898,7 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _CRLF = '\r\n' -def _tool_input(event): +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] == '{': @@ -918,7 +918,7 @@ def _pair(item): def edit_replacements(event): """The (old, new, replace_all) replacements an edit call applies, in order; None if not an edit.""" - tool_input = _tool_input(event) + 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: diff --git a/tests/fixtures/runtime_goldens/codex_cli.py b/tests/fixtures/runtime_goldens/codex_cli.py index 33c2a8d..5f5330b 100644 --- a/tests/fixtures/runtime_goldens/codex_cli.py +++ b/tests/fixtures/runtime_goldens/codex_cli.py @@ -888,7 +888,7 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _CRLF = '\r\n' -def _tool_input(event): +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] == '{': @@ -908,7 +908,7 @@ def _pair(item): def edit_replacements(event): """The (old, new, replace_all) replacements an edit call applies, in order; None if not an edit.""" - tool_input = _tool_input(event) + 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: diff --git a/tests/fixtures/runtime_goldens/cursor.py b/tests/fixtures/runtime_goldens/cursor.py index 1fba217..f724ae1 100644 --- a/tests/fixtures/runtime_goldens/cursor.py +++ b/tests/fixtures/runtime_goldens/cursor.py @@ -813,7 +813,7 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _CRLF = '\r\n' -def _tool_input(event): +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] == '{': @@ -833,7 +833,7 @@ def _pair(item): def edit_replacements(event): """The (old, new, replace_all) replacements an edit call applies, in order; None if not an edit.""" - tool_input = _tool_input(event) + 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: diff --git a/tests/fixtures/runtime_goldens/devin.py b/tests/fixtures/runtime_goldens/devin.py index 141f5cc..8c4ed53 100644 --- a/tests/fixtures/runtime_goldens/devin.py +++ b/tests/fixtures/runtime_goldens/devin.py @@ -867,7 +867,7 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _CRLF = '\r\n' -def _tool_input(event): +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] == '{': @@ -887,7 +887,7 @@ def _pair(item): def edit_replacements(event): """The (old, new, replace_all) replacements an edit call applies, in order; None if not an edit.""" - tool_input = _tool_input(event) + 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: diff --git a/tests/fixtures/runtime_goldens/gemini_cli.py b/tests/fixtures/runtime_goldens/gemini_cli.py index b3515d1..c3c9d7d 100644 --- a/tests/fixtures/runtime_goldens/gemini_cli.py +++ b/tests/fixtures/runtime_goldens/gemini_cli.py @@ -867,7 +867,7 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _CRLF = '\r\n' -def _tool_input(event): +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] == '{': @@ -887,7 +887,7 @@ def _pair(item): def edit_replacements(event): """The (old, new, replace_all) replacements an edit call applies, in order; None if not an edit.""" - tool_input = _tool_input(event) + 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: diff --git a/tests/fixtures/runtime_goldens/grok.py b/tests/fixtures/runtime_goldens/grok.py index 60ddb37..62799b7 100644 --- a/tests/fixtures/runtime_goldens/grok.py +++ b/tests/fixtures/runtime_goldens/grok.py @@ -850,7 +850,7 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _CRLF = '\r\n' -def _tool_input(event): +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] == '{': @@ -870,7 +870,7 @@ def _pair(item): def edit_replacements(event): """The (old, new, replace_all) replacements an edit call applies, in order; None if not an edit.""" - tool_input = _tool_input(event) + 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: diff --git a/tests/fixtures/runtime_goldens/tabnine.py b/tests/fixtures/runtime_goldens/tabnine.py index 78f7fe4..30fd876 100644 --- a/tests/fixtures/runtime_goldens/tabnine.py +++ b/tests/fixtures/runtime_goldens/tabnine.py @@ -850,7 +850,7 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _CRLF = '\r\n' -def _tool_input(event): +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] == '{': @@ -870,7 +870,7 @@ def _pair(item): def edit_replacements(event): """The (old, new, replace_all) replacements an edit call applies, in order; None if not an edit.""" - tool_input = _tool_input(event) + 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: diff --git a/tests/fixtures/runtime_goldens/vscode_copilot.py b/tests/fixtures/runtime_goldens/vscode_copilot.py index bd6d4fa..10ec103 100644 --- a/tests/fixtures/runtime_goldens/vscode_copilot.py +++ b/tests/fixtures/runtime_goldens/vscode_copilot.py @@ -721,7 +721,7 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _CRLF = '\r\n' -def _tool_input(event): +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] == '{': @@ -741,7 +741,7 @@ def _pair(item): def edit_replacements(event): """The (old, new, replace_all) replacements an edit call applies, in order; None if not an edit.""" - tool_input = _tool_input(event) + 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: diff --git a/tests/fixtures/runtime_goldens/windsurf.py b/tests/fixtures/runtime_goldens/windsurf.py index d323fbb..d1a92a2 100644 --- a/tests/fixtures/runtime_goldens/windsurf.py +++ b/tests/fixtures/runtime_goldens/windsurf.py @@ -764,7 +764,7 @@ def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | N _CRLF = '\r\n' -def _tool_input(event): +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] == '{': @@ -784,7 +784,7 @@ def _pair(item): def edit_replacements(event): """The (old, new, replace_all) replacements an edit call applies, in order; None if not an edit.""" - tool_input = _tool_input(event) + 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: diff --git a/tests/test_runtime_goldens.py b/tests/test_runtime_goldens.py index ccf4dcb..b95bc36 100644 --- a/tests/test_runtime_goldens.py +++ b/tests/test_runtime_goldens.py @@ -2,13 +2,15 @@ from __future__ import annotations +import ast import os import shutil from pathlib import Path import pytest +from agentseam import bundler, contract -from chock.gate import runtime_bundle +from chock.gate import runtime_bundle, write_gate GOLDEN = Path(__file__).resolve().parent / "fixtures" / "runtime_goldens" @@ -58,3 +60,23 @@ def test_chock_imports_land_after_import_sys_however_agentseam_orders_its_block( def test_a_bundle_without_the_import_block_is_refused() -> None: assert runtime_bundle._hoist_point("from __future__ import annotations\n\nimport json\n\nimport sys\n") == -1 assert runtime_bundle._hoist_point("import sys\n") == -1 + + +def _top_level_names(source: str) -> set[str]: + names = set() + for node in ast.parse(source).body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + names.add(node.name) + elif isinstance(node, ast.Assign): + names.update(t.id for t in node.targets if isinstance(t, ast.Name)) + return names + + +@pytest.mark.parametrize("agent", sorted(runtime_bundle.RUNTIME_AGENTS)) +def test_chock_handler_shadows_no_agentseam_name(agent: str) -> None: + """One flat namespace: agentseam 0.3.4's vscode_copilot `_tool_input(raw)` lost to chock's.""" + head, _, rest = bundler.bundle(agent).partition(runtime_bundle.BEGIN) + _, _, tail = rest.partition(runtime_bundle.END) + shared = _top_level_names(head + tail) & _top_level_names(runtime_bundle._handler_source(agent)) + assert shared <= {"PRE_TOOL"}, f"{agent}: chock's handler rebinds agentseam's {sorted(shared)}" + assert write_gate.PRE_TOOL == contract.PRE_TOOL From fd55ef8cf94e0b3260925a5b254b819bbe5c0f84 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 12:42:06 -0400 Subject: [PATCH 3/9] Expect Claude Code's shell matcher to be Bash|PowerShell agentseam 0.3.4 records PowerShell as a Claude Code shell tool; the hooks reference says "The matcher `Bash|PowerShell` covers the PowerShell tool as well as Bash", and its input carries tool_input.command like Bash. The wire-fact alarm, the settings install and the Claude plugin now pin that matcher. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Claude --- tests/test_claude_plugin.py | 2 +- tests/test_pretooluse.py | 2 +- tests/test_vendor_wire_facts.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_claude_plugin.py b/tests/test_claude_plugin.py index f93827e..1f056e9 100644 --- a/tests/test_claude_plugin.py +++ b/tests/test_claude_plugin.py @@ -69,7 +69,7 @@ def test_guard_policy_ships_hooks_adapter_and_guard(policy, tmp_path: Path) -> N hooks = json.loads((out / "hooks" / "hooks.json").read_text(encoding="utf-8")) entry = hooks["hooks"]["PreToolUse"][0] - assert entry["matcher"] == "Bash" + assert entry["matcher"] == "Bash|PowerShell" command = entry["hooks"][0]["command"] assert "${CLAUDE_PLUGIN_ROOT}/scripts/claude_code.py" in command assert "${CLAUDE_PLUGIN_ROOT}/scripts/block-destructive-commands.sh" in command diff --git a/tests/test_pretooluse.py b/tests/test_pretooluse.py index d5132da..a4fa449 100644 --- a/tests/test_pretooluse.py +++ b/tests/test_pretooluse.py @@ -163,7 +163,7 @@ def test_install_writes_claude_settings_schema() -> None: entries = settings["hooks"]["PreToolUse"] assert entries, "no PreToolUse entries installed" for entry in entries: - assert entry["matcher"] == "Bash" + assert entry["matcher"] == "Bash|PowerShell" hook = entry["hooks"][0] assert hook["type"] == "command" assert hook["command"].startswith('git -c "alias.chock-hook=!sh .chock/bin/launch.sh" chock-hook ') diff --git a/tests/test_vendor_wire_facts.py b/tests/test_vendor_wire_facts.py index 93b635e..ad50620 100644 --- a/tests/test_vendor_wire_facts.py +++ b/tests/test_vendor_wire_facts.py @@ -31,7 +31,7 @@ def test_derived_wire_facts_still_produce_todays_bytes() -> None: assert vendors.shell_gate_event("cursor") == "beforeShellExecution" assert vendors.config_envelope("cursor") == {"version": 1} assert vendors.config_envelope("claude_code") == {} - assert in_agent.MATCHER == "Bash" + assert in_agent.MATCHER == "Bash|PowerShell" def test_every_wired_vendor_has_a_public_vendor_entry() -> None: @@ -47,7 +47,7 @@ def test_shell_vocabulary_is_derived_per_vendor_not_borrowed() -> None: claude_code's MATCHER; if a future release drops the vocabulary again, this fails and says to reinstate the borrow. """ - assert adapters.shell_tools("claude_code") == ("Bash",) + assert adapters.shell_tools("claude_code") == ("Bash", "PowerShell") for vendor in ("codex_cli", "vscode_copilot"): assert adapters.shell_tools(vendor), f"agentseam records no shell vocabulary for {vendor} anymore" From 551d6aff0c3d91419514daec73891412a1a39fd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 12:42:07 -0400 Subject: [PATCH 4/9] Expect Codex's gate to judge the write via apply_patch agentseam 0.3.4 records apply_patch as Codex's write tool, so gate_reach answers ("apply_patch", True) and a Codex gate package hooks PreToolUse as well as Stop. Codex leaves the stop-only package test; the catalog page's stop-only wording is now pinned through _explain directly, since no page tree publishes a stop-only gate, and the Codex page is pinned to say the write is judged. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Claude --- tests/test_catalog_page.py | 17 +++++++++++++---- tests/test_plugin_gate_stores.py | 8 ++++---- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/tests/test_catalog_page.py b/tests/test_catalog_page.py index 4178c62..5f4d589 100644 --- a/tests/test_catalog_page.py +++ b/tests/test_catalog_page.py @@ -7,6 +7,7 @@ import pytest import yaml +from chock.plugin import catalog_page from chock.plugin.cli import main as plugin_main from chock.plugin.marketplace import CATALOG_PAGE from chock.plugin.marketplace import main as marketplace_main @@ -79,16 +80,24 @@ def test_catalog_page_tells_a_gate_from_a_guard(gate_dist: Path) -> None: assert "refuses rather than allowing one it never judged" in body -def test_catalog_page_says_when_a_client_cannot_judge_the_write(gate_dist: Path) -> None: - """Where the vendor records no write vocabulary, the gate runs at the turn's end only.""" - marketplace_main(["build", "--dist", str(gate_dist), "--tree", "codex"]) - body = (gate_dist / CATALOG_PAGE).read_text(encoding="utf-8") +def test_catalog_page_says_when_a_client_cannot_judge_the_write() -> None: + """A gate published at the turn's end only says so; no page tree is stop-only since agentseam 0.3.4.""" + body = catalog_page._explain("codex", 0, [], 1, ["Stop"]) assert "hooked at `Stop`, re-reading what the turn left on disk" in body assert "so the write itself is not judged" in body assert "judging the file a write would create" not in body +def test_catalog_page_says_codex_judges_the_write(gate_dist: Path) -> None: + """agentseam 0.3.4 records `apply_patch` as Codex's write tool, so its gate runs before the write too.""" + marketplace_main(["build", "--dist", str(gate_dist), "--tree", "codex"]) + body = (gate_dist / CATALOG_PAGE).read_text(encoding="utf-8") + + assert "hooked at `PreToolUse` and `Stop`, judging the file a write would create" in body + assert "so the write itself is not judged" not in body + + def test_catalog_page_names_each_client_s_own_events(gate_dist: Path) -> None: """The events on the page are the ones the published hooks wire, in that client's spelling.""" marketplace_main(["build", "--dist", str(gate_dist), "--tree", "cursor"]) diff --git a/tests/test_plugin_gate_stores.py b/tests/test_plugin_gate_stores.py index ab5fd3e..27a4dc9 100644 --- a/tests/test_plugin_gate_stores.py +++ b/tests/test_plugin_gate_stores.py @@ -1,8 +1,8 @@ """The packaged gate in every hook-carrying store, reaching exactly what agentseam records. Claude Code records a write-tool vocabulary and a blocking turn-end hook, so its package gates -both. Codex, Devin and Copilot record no write tools but block at the turn's end, so their -packages carry the gate at `Stop` alone and say so. Cursor records `Write` at its generic +both, as does Codex with `apply_patch`. Devin and Copilot record no write tools but block at +the turn's end, so their packages carry the gate at `Stop` alone and say so. Cursor records `Write` at its generic `preToolUse` and a turn-end hook that hands a refusal back as a follow-up message, so its package gates the write and reports at `stop`, in Cursor's own flat entry shape. None of that is typed here: the test asks agentseam the same question the emitter does. @@ -70,13 +70,13 @@ def test_the_gate_reaches_what_the_vendor_records(gate_policy, tmp_path: Path, s def test_which_vendors_the_gate_reaches_is_agentseam_s_answer() -> None: """Pinned so a change upstream surfaces here rather than silently widening or narrowing a package.""" assert gate_reach("claude_code") == ("Write|Edit|MultiEdit|NotebookEdit", True) - assert gate_reach("codex_cli") == (None, True) + assert gate_reach("codex_cli") == ("apply_patch", True) assert gate_reach("devin") == (None, True) assert gate_reach("vscode_copilot") == (None, True) assert gate_reach("cursor") == ("Write", True) -@pytest.mark.parametrize("store", ["codex", "devin", "copilot"]) +@pytest.mark.parametrize("store", ["devin", "copilot"]) def test_a_stop_only_package_says_the_write_is_not_judged(gate_policy, tmp_path: Path, store: str) -> None: vendor, build, _, _ = STORES[store] manifest = _manifest() From 431468ac50352acb5398880e214d98d78e59d523 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 12:48:41 -0400 Subject: [PATCH 5/9] Keep the exit code in Copilot's PowerShell hook entries `pwsh -Command` reports any failing native command as exit 1, so the launcher's exit 2 (no working Python: refuse) reached VS Code as a non-blocking error. The powershell/windows keys now carry `& ; exit $LASTEXITCODE`, agentseam 0.3.4's own Windows form. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Claude --- .../agent-hooks/agent-hooks.json | 4 ++-- .../agent-hooks/agent-hooks.json | 4 ++-- .../agent-hooks/agent-hooks.json | 4 ++-- .../agent-hooks/agent-hooks.json | 4 ++-- .github/hooks/chock.json | 16 ++++++++-------- chock.lock | 8 ++++---- src/chock/compile/emitters/in_agent.py | 8 +++++--- .../agent-hooks/agent-hooks.json | 4 ++-- tests/test_agent_hooks.py | 4 +++- tests/test_hook_launcher.py | 19 ++++++++++++++++--- 10 files changed, 46 insertions(+), 29 deletions(-) diff --git a/.chock/compiled/block-destructive-commands/agent-hooks/agent-hooks.json b/.chock/compiled/block-destructive-commands/agent-hooks/agent-hooks.json index 0faa317..a14f833 100644 --- a/.chock/compiled/block-destructive-commands/agent-hooks/agent-hooks.json +++ b/.chock/compiled/block-destructive-commands/agent-hooks/agent-hooks.json @@ -5,6 +5,6 @@ "timeoutSec": 30, "bash": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", - "powershell": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", - "windows": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + "powershell": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"; exit $LASTEXITCODE", + "windows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"; exit $LASTEXITCODE" } \ No newline at end of file diff --git a/.chock/compiled/block-no-verify/agent-hooks/agent-hooks.json b/.chock/compiled/block-no-verify/agent-hooks/agent-hooks.json index bc45cef..124b1be 100644 --- a/.chock/compiled/block-no-verify/agent-hooks/agent-hooks.json +++ b/.chock/compiled/block-no-verify/agent-hooks/agent-hooks.json @@ -5,6 +5,6 @@ "timeoutSec": 30, "bash": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", - "powershell": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", - "windows": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "powershell": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"; exit $LASTEXITCODE", + "windows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"; exit $LASTEXITCODE" } \ No newline at end of file diff --git a/.chock/compiled/protect-agent-config/agent-hooks/agent-hooks.json b/.chock/compiled/protect-agent-config/agent-hooks/agent-hooks.json index 82ce044..72f1006 100644 --- a/.chock/compiled/protect-agent-config/agent-hooks/agent-hooks.json +++ b/.chock/compiled/protect-agent-config/agent-hooks/agent-hooks.json @@ -5,6 +5,6 @@ "timeoutSec": 30, "bash": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", - "powershell": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", - "windows": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + "powershell": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"; exit $LASTEXITCODE", + "windows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"; exit $LASTEXITCODE" } \ No newline at end of file diff --git a/.chock/compiled/protect-commit-privacy/agent-hooks/agent-hooks.json b/.chock/compiled/protect-commit-privacy/agent-hooks/agent-hooks.json index 4fb4242..031c77d 100644 --- a/.chock/compiled/protect-commit-privacy/agent-hooks/agent-hooks.json +++ b/.chock/compiled/protect-commit-privacy/agent-hooks/agent-hooks.json @@ -5,6 +5,6 @@ "timeoutSec": 30, "bash": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", - "powershell": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", - "windows": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + "powershell": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"; exit $LASTEXITCODE", + "windows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"; exit $LASTEXITCODE" } \ No newline at end of file diff --git a/.github/hooks/chock.json b/.github/hooks/chock.json index ee93deb..7064ac3 100644 --- a/.github/hooks/chock.json +++ b/.github/hooks/chock.json @@ -9,8 +9,8 @@ "timeoutSec": 30, "bash": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", - "powershell": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", - "windows": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + "powershell": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"; exit $LASTEXITCODE", + "windows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"; exit $LASTEXITCODE" }, { "type": "command", @@ -19,8 +19,8 @@ "timeoutSec": 30, "bash": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", - "powershell": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", - "windows": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "powershell": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"; exit $LASTEXITCODE", + "windows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"; exit $LASTEXITCODE" }, { "type": "command", @@ -29,8 +29,8 @@ "timeoutSec": 30, "bash": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", - "powershell": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", - "windows": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + "powershell": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"; exit $LASTEXITCODE", + "windows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"; exit $LASTEXITCODE" }, { "type": "command", @@ -39,8 +39,8 @@ "timeoutSec": 30, "bash": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", - "powershell": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", - "windows": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + "powershell": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"; exit $LASTEXITCODE", + "windows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"; exit $LASTEXITCODE" } ] } diff --git a/chock.lock b/chock.lock index 6cb7a30..5aefca5 100644 --- a/chock.lock +++ b/chock.lock @@ -16,7 +16,7 @@ "managed": false, "sha256": "59fffb91c6f65710b461e87782d355c1403953f36700e05369fca065683821c0", "source": "local", - "artifacts_sha256": "05d2a6b526cb31fadf13b08da0a6211b27f2f4352f1247075702a0a9eb73147c" + "artifacts_sha256": "dd134cdcb3940394e4db5406fd7892bd460f7c331da5dc2fed0eed59325cb784" }, { "id": "block-invisible-unicode", @@ -32,7 +32,7 @@ "managed": false, "sha256": "ef0d729c413086bd4ce77c7191bcc1393f4e0cdfcfc959b33a1ddb771afdf2b0", "source": "local", - "artifacts_sha256": "9bb8ab646401b8a5fa586cbdd1b3654307ad1754dddb342952bbe27d7bdfef6a" + "artifacts_sha256": "c1cbffe0480aa553bba2b517f3448625b60460a599aa983f152789d374ee19e5" }, { "id": "block-wildcard-agent-permissions", @@ -112,7 +112,7 @@ "managed": false, "sha256": "b98d5472c534d381b87e253086642675c71ce3947e2cfe01ed9fec5ed8b66295", "source": "local", - "artifacts_sha256": "433cd778b6236ae31cbc05524e71c420d7dd1cce665d0272192c75a1785357b5" + "artifacts_sha256": "ede5a2cc400979c59689d16dfcf192791a88ca280c58326fb10de1dc590451da" }, { "id": "protect-commit-privacy", @@ -120,7 +120,7 @@ "managed": false, "sha256": "281522db2b259ea9a12d76b30d57ed9993882089b04a1fb4ce9932bc15a3388f", "source": "local", - "artifacts_sha256": "3c0acf064c6bf01714e0f53360ba786a0f800328225e91cee3b81258f7117686" + "artifacts_sha256": "de947766e8331492d417ddbd620a6b90daa554aae930e17169de48258c321c14" }, { "id": "protect-main-branch", diff --git a/src/chock/compile/emitters/in_agent.py b/src/chock/compile/emitters/in_agent.py index 3d4b6e6..ced1e38 100644 --- a/src/chock/compile/emitters/in_agent.py +++ b/src/chock/compile/emitters/in_agent.py @@ -43,6 +43,7 @@ def _guard_script(policy_dir: Path, policy_id: str) -> str | None: # stay here until upstream ingests the witnessed shape; tests/test_vendor_wire_facts.py # pins the disagreement so its resolution surfaces loudly. AGENT_HOOKS_EVENT = "preToolUse" +POWERSHELL_KEEP_EXIT = "; exit $LASTEXITCODE" AGENT_HOOKS_ENVELOPE = {"version": 1} SHELL_MATCHER = "bash|powershell|pwsh|sh|shell" @@ -221,11 +222,12 @@ def build_entry(policy_dir: Path, manifest: dict[str, Any]) -> dict[str, Any] | script = _guard_script(policy_dir, policy_id) if not script: return None - # One string for both keys: the launcher form reads the same under bash and PowerShell. - command = hook_command( + # The launcher form reads the same under bash and PowerShell; PowerShell also needs its exit + # code kept (`pwsh -Command` reports any failure as 1), as agentseam's own Windows form does. + bash = hook_command( _adapter_rel("vscode_copilot"), "--guard", f"{policy_rel_path(policy_dir)}/implementations/{script}" ) - bash = powershell = command + powershell = f"& {bash}{POWERSHELL_KEEP_EXIT}" return { "type": "command", "matcher": SHELL_MATCHER, diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/agent-hooks/agent-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/agent-hooks/agent-hooks.json index 3f6da5a..75a6b49 100644 --- a/tests/fixtures/emitter_stability/golden/stability-hook/agent-hooks/agent-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-hook/agent-hooks/agent-hooks.json @@ -5,6 +5,6 @@ "timeoutSec": 30, "bash": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", "command": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", - "powershell": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", - "windows": "git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" + "powershell": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"; exit $LASTEXITCODE", + "windows": "& git -c \"alias.chock-hook=!sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"; exit $LASTEXITCODE" } \ No newline at end of file diff --git a/tests/test_agent_hooks.py b/tests/test_agent_hooks.py index 538f92f..fccae36 100644 --- a/tests/test_agent_hooks.py +++ b/tests/test_agent_hooks.py @@ -52,7 +52,9 @@ def test_build_entry_has_all_four_command_fields(tmp_path): assert set(entry) >= {"bash", "command", "powershell", "windows", "matcher", "type"} assert entry["bash"] == entry["command"] assert entry["powershell"] == entry["windows"] - assert entry["bash"] == entry["powershell"], "the launcher form reads the same under both shells" + assert entry["powershell"] == f"& {entry['bash']}; exit $LASTEXITCODE", ( + "the same launcher command, keeping its exit code under pwsh -Command" + ) assert entry["matcher"] == SHELL_MATCHER diff --git a/tests/test_hook_launcher.py b/tests/test_hook_launcher.py index aa051ba..8545298 100644 --- a/tests/test_hook_launcher.py +++ b/tests/test_hook_launcher.py @@ -159,11 +159,24 @@ def test_hook_command_reads_the_same_under_every_shell(args: tuple[str, ...]) -> assert char not in command, f"{char!r} is read differently by bash, PowerShell or cmd.exe" +#: Keys a PowerShell-only host reads: there the launcher is called with `&` and keeps its exit code. +_POWERSHELL_KEYS = {"powershell", "windows", "commandWindows"} +_POWERSHELL_WRAP = ("& ", "; exit $LASTEXITCODE") + + +def _unwrapped(key: str, command: str) -> str: + """The launcher command inside a PowerShell-only field's `& ...; exit $LASTEXITCODE` wrapper.""" + head, tail = _POWERSHELL_WRAP + if key in _POWERSHELL_KEYS and command.startswith(head) and command.endswith(tail): + return command[len(head) : -len(tail)] + return command + + def _commands(node) -> list[str]: if isinstance(node, dict): - return [v for k, v in node.items() if k in {"command", "bash", "powershell"} and isinstance(v, str)] + [ - c for v in node.values() for c in _commands(v) - ] + keys = {"command", "bash", *_POWERSHELL_KEYS} + own = [_unwrapped(k, v) for k, v in node.items() if k in keys and isinstance(v, str)] + return own + [c for v in node.values() for c in _commands(v)] if isinstance(node, list): return [c for v in node for c in _commands(v)] return [] From 21ae319fa620ef853f548a802edde56e80f3fdf4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 12:48:51 -0400 Subject: [PATCH 6/9] Record the agentseam 0.3.4 adoption in the changelog Co-Authored-By: Claude Opus 5.5 Signed-off-by: Claude --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8a8458..0029987 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ `chock sync` elsewhere is a zero diff; entries in the old form are replaced at the next sync. `chock check` reports a missing or git-ignored launcher as a dangling hook target. +- **agentseam 0.3.4.** Claude Code's shell guards also match its `PowerShell` tool (Windows' default + shell tool, where `Bash`-only guards never fired); Codex gets a pre-write gate on `apply_patch`; + a policy handler's stray stdout can no longer turn a deny into an allow; Copilot CLI's camelCase + payloads are read. A chock helper no longer shadows agentseam's Copilot input reader inside the + single-file runtime (which made every Copilot guard allow), and a test now fails on any such clash. - **A gate judges an absolute path as the repository file it names.** Claude Code and Cursor send `file_path` absolute; scoped gates matched repo-relative globs against it, so `pin-github-actions` allowed `actions/checkout@v4` written by the agent. Paths are made repo-relative first (drive From 2f85287e9056847789b45929c9f7e684547a8c2f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 19:13:00 +0000 Subject: [PATCH 7/9] Refuse from a PowerShell hook entry when no command ran Copilot's powershell/windows entries end `; exit $LASTEXITCODE`. With git or sh missing no native command runs, $LASTEXITCODE is $null, and `exit $null` is 0 -- an allow. Exit 2 then, as agentseam 0.3.4 does. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Claude --- .../agent-hooks/agent-hooks.json | 4 ++-- .../block-no-verify/agent-hooks/agent-hooks.json | 4 ++-- .../agent-hooks/agent-hooks.json | 4 ++-- .../agent-hooks/agent-hooks.json | 4 ++-- .github/hooks/chock.json | 16 ++++++++-------- chock.lock | 8 ++++---- src/chock/compile/emitters/in_agent.py | 4 +++- .../stability-hook/agent-hooks/agent-hooks.json | 4 ++-- tests/test_agent_hooks.py | 4 ++-- 9 files changed, 27 insertions(+), 25 deletions(-) diff --git a/.chock/compiled/block-destructive-commands/agent-hooks/agent-hooks.json b/.chock/compiled/block-destructive-commands/agent-hooks/agent-hooks.json index a5d2f72..88f07ea 100644 --- a/.chock/compiled/block-destructive-commands/agent-hooks/agent-hooks.json +++ b/.chock/compiled/block-destructive-commands/agent-hooks/agent-hooks.json @@ -5,6 +5,6 @@ "timeoutSec": 30, "bash": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", - "powershell": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"; exit $LASTEXITCODE", - "windows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"; exit $LASTEXITCODE" + "powershell": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE", + "windows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE" } \ No newline at end of file diff --git a/.chock/compiled/block-no-verify/agent-hooks/agent-hooks.json b/.chock/compiled/block-no-verify/agent-hooks/agent-hooks.json index 3aadeb4..faff9da 100644 --- a/.chock/compiled/block-no-verify/agent-hooks/agent-hooks.json +++ b/.chock/compiled/block-no-verify/agent-hooks/agent-hooks.json @@ -5,6 +5,6 @@ "timeoutSec": 30, "bash": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", - "powershell": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"; exit $LASTEXITCODE", - "windows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"; exit $LASTEXITCODE" + "powershell": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE", + "windows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE" } \ No newline at end of file diff --git a/.chock/compiled/protect-agent-config/agent-hooks/agent-hooks.json b/.chock/compiled/protect-agent-config/agent-hooks/agent-hooks.json index 0a58e4d..a5c6fc5 100644 --- a/.chock/compiled/protect-agent-config/agent-hooks/agent-hooks.json +++ b/.chock/compiled/protect-agent-config/agent-hooks/agent-hooks.json @@ -5,6 +5,6 @@ "timeoutSec": 30, "bash": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", - "powershell": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"; exit $LASTEXITCODE", - "windows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"; exit $LASTEXITCODE" + "powershell": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE", + "windows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE" } \ No newline at end of file diff --git a/.chock/compiled/protect-commit-privacy/agent-hooks/agent-hooks.json b/.chock/compiled/protect-commit-privacy/agent-hooks/agent-hooks.json index f8c1d5e..4492888 100644 --- a/.chock/compiled/protect-commit-privacy/agent-hooks/agent-hooks.json +++ b/.chock/compiled/protect-commit-privacy/agent-hooks/agent-hooks.json @@ -5,6 +5,6 @@ "timeoutSec": 30, "bash": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", - "powershell": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"; exit $LASTEXITCODE", - "windows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"; exit $LASTEXITCODE" + "powershell": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE", + "windows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE" } \ No newline at end of file diff --git a/.github/hooks/chock.json b/.github/hooks/chock.json index 257e6ea..fa46089 100644 --- a/.github/hooks/chock.json +++ b/.github/hooks/chock.json @@ -9,8 +9,8 @@ "timeoutSec": 30, "bash": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", - "powershell": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"; exit $LASTEXITCODE", - "windows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"; exit $LASTEXITCODE" + "powershell": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE", + "windows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE" }, { "type": "command", @@ -19,8 +19,8 @@ "timeoutSec": 30, "bash": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", - "powershell": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"; exit $LASTEXITCODE", - "windows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"; exit $LASTEXITCODE" + "powershell": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE", + "windows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE" }, { "type": "command", @@ -29,8 +29,8 @@ "timeoutSec": 30, "bash": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", - "powershell": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"; exit $LASTEXITCODE", - "windows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"; exit $LASTEXITCODE" + "powershell": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE", + "windows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE" }, { "type": "command", @@ -39,8 +39,8 @@ "timeoutSec": 30, "bash": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", - "powershell": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"; exit $LASTEXITCODE", - "windows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"; exit $LASTEXITCODE" + "powershell": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE", + "windows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE" } ] } diff --git a/chock.lock b/chock.lock index 3d6a030..c916233 100644 --- a/chock.lock +++ b/chock.lock @@ -16,7 +16,7 @@ "managed": false, "sha256": "59fffb91c6f65710b461e87782d355c1403953f36700e05369fca065683821c0", "source": "local", - "artifacts_sha256": "069381c668e2558b2f3e15713952f96116b9d502bd4337b41002bf4568b0b9bd" + "artifacts_sha256": "bf8393543ef3e4e6e20fc79b607e456a62746c15fafbae83048e8dacaa117c4f" }, { "id": "block-invisible-unicode", @@ -32,7 +32,7 @@ "managed": false, "sha256": "ef0d729c413086bd4ce77c7191bcc1393f4e0cdfcfc959b33a1ddb771afdf2b0", "source": "local", - "artifacts_sha256": "b0d7a995a0d0158ca96b18bbcae01994a0ae66782711a9a351ee46b1016dc020" + "artifacts_sha256": "f0a832495520b6da8227626f832f182a1994fedf161c43ecaceec3827ac2d7aa" }, { "id": "block-wildcard-agent-permissions", @@ -112,7 +112,7 @@ "managed": false, "sha256": "b98d5472c534d381b87e253086642675c71ce3947e2cfe01ed9fec5ed8b66295", "source": "local", - "artifacts_sha256": "ce9fcc824937f3964e478fe6634cb620b5dea659bbe24a8c0c4721447f6c1ee3" + "artifacts_sha256": "1a7ca505926f51a8957ec3fc0667cc6c6cf07d1411670bd42699159690cb804b" }, { "id": "protect-commit-privacy", @@ -120,7 +120,7 @@ "managed": false, "sha256": "281522db2b259ea9a12d76b30d57ed9993882089b04a1fb4ce9932bc15a3388f", "source": "local", - "artifacts_sha256": "4a373ab985fb55a8cd74437492a2bdea5460d63ff3b5454f603a8a00c231e824" + "artifacts_sha256": "819bd42bceadbc7272fd28581de153329193161ad72f4ce41269a9b2e963d411" }, { "id": "protect-main-branch", diff --git a/src/chock/compile/emitters/in_agent.py b/src/chock/compile/emitters/in_agent.py index ced1e38..1d69bf3 100644 --- a/src/chock/compile/emitters/in_agent.py +++ b/src/chock/compile/emitters/in_agent.py @@ -43,7 +43,9 @@ def _guard_script(policy_dir: Path, policy_id: str) -> str | None: # stay here until upstream ingests the witnessed shape; tests/test_vendor_wire_facts.py # pins the disagreement so its resolution surfaces loudly. AGENT_HOOKS_EVENT = "preToolUse" -POWERSHELL_KEEP_EXIT = "; exit $LASTEXITCODE" +#: `exit $LASTEXITCODE` alone exits 0 when no native command ran (git or sh not on PATH): +#: $LASTEXITCODE is $null then, and 0 is an allow; nothing judged the call, so refuse (2). +POWERSHELL_KEEP_EXIT = "; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE" AGENT_HOOKS_ENVELOPE = {"version": 1} SHELL_MATCHER = "bash|powershell|pwsh|sh|shell" diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/agent-hooks/agent-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/agent-hooks/agent-hooks.json index 6ffd9f3..96b022d 100644 --- a/tests/fixtures/emitter_stability/golden/stability-hook/agent-hooks/agent-hooks.json +++ b/tests/fixtures/emitter_stability/golden/stability-hook/agent-hooks/agent-hooks.json @@ -5,6 +5,6 @@ "timeoutSec": 30, "bash": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", - "powershell": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"; exit $LASTEXITCODE", - "windows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"; exit $LASTEXITCODE" + "powershell": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE", + "windows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE" } \ No newline at end of file diff --git a/tests/test_agent_hooks.py b/tests/test_agent_hooks.py index fccae36..198f7e4 100644 --- a/tests/test_agent_hooks.py +++ b/tests/test_agent_hooks.py @@ -52,8 +52,8 @@ def test_build_entry_has_all_four_command_fields(tmp_path): assert set(entry) >= {"bash", "command", "powershell", "windows", "matcher", "type"} assert entry["bash"] == entry["command"] assert entry["powershell"] == entry["windows"] - assert entry["powershell"] == f"& {entry['bash']}; exit $LASTEXITCODE", ( - "the same launcher command, keeping its exit code under pwsh -Command" + assert entry["powershell"] == f"& {entry['bash']}; if ($null -eq $LASTEXITCODE) {{ exit 2 }}; exit $LASTEXITCODE", ( + "the same launcher command, keeping its exit code under pwsh -Command, and refusing when none ran" ) assert entry["matcher"] == SHELL_MATCHER From e06e472ea5ea133d1e9d5bfa333e78e9b7d599de Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 19:13:01 +0000 Subject: [PATCH 8/9] Record the PowerShell refusal in the agentseam 0.3.4 changelog entry Co-Authored-By: Claude Opus 5.5 Signed-off-by: Claude --- CHANGELOG.md | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0029987..7e2ae69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,11 @@ read identically by bash, PowerShell and cmd.exe; git runs it from the repository root, so a session started in a subdirectory is guarded too. The committed `.chock/bin/launch.sh` runs `git config chock.python` (written to the clone's local config by `chock sync`) or the first - `python3`/`python`/`py` that actually runs Python 3.11+, and with none refuses (exit 2) with a - fix-it message. Cursor's shell and pre-tool entries now set `failClosed`. Re-running + `python3`/`python`/`py` that actually runs Python 3.11+ (the recorded one is probed too: a venv + whose base Python is gone still exists), and with none refuses (exit 2) with a fix-it message. + With no launcher at git's top level (a nested repository, an unsynced clone) the command + refuses the same way; bash-as-`sh` used to exit 127 there, which agents let through. Sync + records its interpreter only when `--repo` is a repository's top level. Cursor's shell and pre-tool entries now set `failClosed`. Re-running `chock sync` elsewhere is a zero diff; entries in the old form are replaced at the next sync. `chock check` reports a missing or git-ignored launcher as a dangling hook target. @@ -20,10 +23,13 @@ a policy handler's stray stdout can no longer turn a deny into an allow; Copilot CLI's camelCase payloads are read. A chock helper no longer shadows agentseam's Copilot input reader inside the single-file runtime (which made every Copilot guard allow), and a test now fails on any such clash. + Copilot's PowerShell entries keep the hook's exit code, and refuse (exit 2) when no command ran at + all: a bare `exit $LASTEXITCODE` exits 0 then, which is an allow. - **A gate judges an absolute path as the repository file it names.** Claude Code and Cursor send `file_path` absolute; scoped gates matched repo-relative globs against it, so `pin-github-actions` allowed `actions/checkout@v4` written by the agent. Paths are made repo-relative first (drive - letters, backslashes and case folded on Windows; a path outside the repo stays out of scope). + letters, backslashes and case folded on Windows; a path outside the repo stays out of scope). A + write through a symlinked folder is judged under its target's path as well as the one named. - **A guard that cannot run asks instead of allowing.** No usable bash, or a command `shlex` cannot parse (`rm -rf / #'`), used to allow. Both now ask, naming what to install. Bash is found from `git` (Git for Windows' `bin\bash.exe` first, never System32's WSL launcher or a WindowsApps @@ -32,8 +38,8 @@ allowing silently. A re-entered Stop is still let through so a refusal cannot trap the turn. - **A gate never judges its own policy's files or the generated tree.** java-security refused the commit that adopted it (its own eval suite and setup page) and blocked every Stop until then. Each - gate now skips `.agents/policies//`, `.chock/compiled/` and `.chock/bin/`; `.chock/config.yaml` - and the dependency allowlist are still judged. + gate now skips `.agents/policies//` and `.chock/compiled//`, and nothing else: a file + planted anywhere else under `.chock/` is judged like any other. - **Git output is decoded as UTF-8 on every console**, so the Stop gate no longer skips non-ASCII paths on Windows, and a match printed to a cp1252 console no longer crashes the gate. - **The PowerShell pre-commit probe no longer blocks the commit** when a candidate interpreter is @@ -48,6 +54,12 @@ matching agentseam 0.3.4's hoisted imports, and every runtime failed to render. - **`chock check` is about 2.5x faster**: each YAML text is parsed once with the C loader, each bundled module is split once, and bash is probed once per process. +- **INDEX.md says where a gate runs.** The generated index headed its gates "enforced + automatically at commit/push", so an agent reading it could expect nothing until a commit -- + while a gate compiled for tool use refuses the write in the turn. The heading now says gates + run at commit/push and in the agent where noted, and each gate declared `on: tool_use` ends + with "Also checked in the agent: before a write, or at the end of the turn, depending on the + agent." Adopters pick it up on their next `chock sync`. ## 0.11.4 — An edit is judged as the file it would leave, and bytecode no longer fails a pack From b22fd50ea91b7c92a8da6168c7ae8ce237f71257 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 19:13:02 +0000 Subject: [PATCH 9/9] Pin agentseam 0.3.4 in the brand-assets requirements Recompiled with --upgrade-package agentseam; no other pin moves. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Claude --- requirements/brand-assets.in | 2 +- requirements/brand-assets.txt | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/brand-assets.in b/requirements/brand-assets.in index 04fc285..df401d7 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.3 +agentseam==0.3.4 cairosvg==2.9.0 diff --git a/requirements/brand-assets.txt b/requirements/brand-assets.txt index 952c2bc..3778e6d 100644 --- a/requirements/brand-assets.txt +++ b/requirements/brand-assets.txt @@ -2,11 +2,11 @@ # This file is autogenerated by pip-compile with Python 3.12 # by the following command: # -# pip-compile --generate-hashes --output-file=requirements/brand-assets.txt --strip-extras requirements/brand-assets.in +# pip-compile --generate-hashes --no-index --output-file=requirements/brand-assets.txt --strip-extras requirements/brand-assets.in # -agentseam==0.3.3 \ - --hash=sha256:2e7c832988711bf183955a6e77d570f783d99cc394e57f456eb520a8243b334d \ - --hash=sha256:cf8e8c5aad79ba1521c564fcab4dc03b688d91b12307db5ca7173f961ad15e1e +agentseam==0.3.4 \ + --hash=sha256:1f3f3cb31f8aa88056e7c28011a9ffe776206babce134d55747db5f9dbd35d72 \ + --hash=sha256:55bc3ab58f60d90083964cfab0e35d85bb8b9b6c2a41e4247e7ad4259a537d2e # via -r requirements/brand-assets.in attrs==26.1.0 \ --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \