diff --git a/.chock/bin/claude_code.py b/.chock/bin/claude_code.py index 6f61546..ff62ec2 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.""" @@ -202,7 +205,7 @@ def tool_input_of(raw): """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" if isinstance(raw, dict): return raw - if isinstance(raw, str) and raw[:1] == "{": + if isinstance(raw, str) and raw.lstrip()[:1] == "{": try: parsed = _json.loads(raw) except (ValueError, RecursionError): @@ -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): @@ -893,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] == '{': @@ -913,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: @@ -1459,6 +1464,55 @@ def _report(text): return +_STDERR_FD = 2 + + +def _divert_fd1(): + """Point fd 1 at stderr (devnull if there is none); the saved fd 1, or None if it could not.""" + try: + saved = os.dup(1) + except OSError: + return None + # With fd 2 closed, dup() hands back 2 itself, and "stderr" would be stdout again. + if saved > _STDERR_FD: + with contextlib.suppress(OSError): + os.dup2(_STDERR_FD, 1) + return saved + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +def _flush(streams): + """Flush each stream that exists; a broken one is no reason to lose the verdict.""" + for stream in streams: + if stream is not None: + with contextlib.suppress(Exception): + stream.flush() + + +@contextlib.contextmanager +def _stdout_to_stderr(): + # Stdout is the verdict channel. A handler's stray print (or a child process it runs) + # ahead of the JSON makes the host fail to parse it, and Claude Code and Gemini CLI then + # treat the hook as a non-blocking error: a deny became an allow, witnessed live. + sink = sys.stderr if sys.stderr is not None else io.StringIO() + held = (sys.stdout, sys.__stdout__) + _flush(held) + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + # A stream the handler held on to (sys.__stdout__, a reference cached at import) + # buffers past redirect_stdout; flushed now it lands on stderr, not after the verdict. + _flush((sink, *held)) + os.dup2(saved, 1) + os.close(saved) + + def _decide(raw): """(stdout_text, exit_code) for one decoded payload: agentseam.dispatch.handle(), inlined.""" event = parse(raw) @@ -1468,7 +1522,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 901a187..0aa8109 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.""" @@ -202,7 +205,7 @@ def tool_input_of(raw): """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" if isinstance(raw, dict): return raw - if isinstance(raw, str) and raw[:1] == "{": + if isinstance(raw, str) and raw.lstrip()[:1] == "{": try: parsed = _json.loads(raw) except (ValueError, RecursionError): @@ -217,9 +220,19 @@ def tool_input_of(raw): # ------------------------------------------------------------------------------ # hook_json family engine (trimmed to what this entry uses) +_KEEP_EXIT = "; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE" + +_BARE_EXIT = "; exit $LASTEXITCODE" + def powershell_command(command): - """`command` rewritten so PowerShell will actually run it.""" - return command if command.lstrip().startswith("&") else "& " + command + """`command` rewritten so PowerShell will actually run it and keep its exit code.""" + body = command if command.lstrip().startswith("&") else "& " + command + body = body.rstrip() + if body.endswith(_KEEP_EXIT): + return body + if body.endswith(_BARE_EXIT): + body = body[: -len(_BARE_EXIT)] + return body + _KEEP_EXIT UNREADABLE_NAME = "" @@ -550,6 +563,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 +581,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 +620,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 +650,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): @@ -880,7 +895,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] == '{': @@ -900,7 +915,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: @@ -1378,6 +1393,55 @@ def _report(text): return +_STDERR_FD = 2 + + +def _divert_fd1(): + """Point fd 1 at stderr (devnull if there is none); the saved fd 1, or None if it could not.""" + try: + saved = os.dup(1) + except OSError: + return None + # With fd 2 closed, dup() hands back 2 itself, and "stderr" would be stdout again. + if saved > _STDERR_FD: + with contextlib.suppress(OSError): + os.dup2(_STDERR_FD, 1) + return saved + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +def _flush(streams): + """Flush each stream that exists; a broken one is no reason to lose the verdict.""" + for stream in streams: + if stream is not None: + with contextlib.suppress(Exception): + stream.flush() + + +@contextlib.contextmanager +def _stdout_to_stderr(): + # Stdout is the verdict channel. A handler's stray print (or a child process it runs) + # ahead of the JSON makes the host fail to parse it, and Claude Code and Gemini CLI then + # treat the hook as a non-blocking error: a deny became an allow, witnessed live. + sink = sys.stderr if sys.stderr is not None else io.StringIO() + held = (sys.stdout, sys.__stdout__) + _flush(held) + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + # A stream the handler held on to (sys.__stdout__, a reference cached at import) + # buffers past redirect_stdout; flushed now it lands on stderr, not after the verdict. + _flush((sink, *held)) + os.dup2(saved, 1) + os.close(saved) + + def _decide(raw): """(stdout_text, exit_code) for one decoded payload: agentseam.dispatch.handle(), inlined.""" event = parse(raw) @@ -1387,7 +1451,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 0f92770..e477f38 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.""" @@ -202,7 +205,7 @@ def tool_input_of(raw): """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" if isinstance(raw, dict): return raw - if isinstance(raw, str) and raw[:1] == "{": + if isinstance(raw, str) and raw.lstrip()[:1] == "{": try: parsed = _json.loads(raw) except (ValueError, RecursionError): @@ -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"): @@ -808,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] == '{': @@ -828,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: @@ -1306,6 +1311,55 @@ def _report(text): return +_STDERR_FD = 2 + + +def _divert_fd1(): + """Point fd 1 at stderr (devnull if there is none); the saved fd 1, or None if it could not.""" + try: + saved = os.dup(1) + except OSError: + return None + # With fd 2 closed, dup() hands back 2 itself, and "stderr" would be stdout again. + if saved > _STDERR_FD: + with contextlib.suppress(OSError): + os.dup2(_STDERR_FD, 1) + return saved + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +def _flush(streams): + """Flush each stream that exists; a broken one is no reason to lose the verdict.""" + for stream in streams: + if stream is not None: + with contextlib.suppress(Exception): + stream.flush() + + +@contextlib.contextmanager +def _stdout_to_stderr(): + # Stdout is the verdict channel. A handler's stray print (or a child process it runs) + # ahead of the JSON makes the host fail to parse it, and Claude Code and Gemini CLI then + # treat the hook as a non-blocking error: a deny became an allow, witnessed live. + sink = sys.stderr if sys.stderr is not None else io.StringIO() + held = (sys.stdout, sys.__stdout__) + _flush(held) + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + # A stream the handler held on to (sys.__stdout__, a reference cached at import) + # buffers past redirect_stdout; flushed now it lands on stderr, not after the verdict. + _flush((sink, *held)) + os.dup2(saved, 1) + os.close(saved) + + def _decide(raw): """(stdout_text, exit_code) for one decoded payload: agentseam.dispatch.handle(), inlined.""" event = parse(raw) @@ -1315,7 +1369,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 588369c..c2038e2 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.""" @@ -202,7 +205,7 @@ def tool_input_of(raw): """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" if isinstance(raw, dict): return raw - if isinstance(raw, str) and raw[:1] == "{": + if isinstance(raw, str) and raw.lstrip()[:1] == "{": try: parsed = _json.loads(raw) except (ValueError, RecursionError): @@ -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): @@ -862,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] == '{': @@ -882,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: @@ -1360,6 +1365,55 @@ def _report(text): return +_STDERR_FD = 2 + + +def _divert_fd1(): + """Point fd 1 at stderr (devnull if there is none); the saved fd 1, or None if it could not.""" + try: + saved = os.dup(1) + except OSError: + return None + # With fd 2 closed, dup() hands back 2 itself, and "stderr" would be stdout again. + if saved > _STDERR_FD: + with contextlib.suppress(OSError): + os.dup2(_STDERR_FD, 1) + return saved + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +def _flush(streams): + """Flush each stream that exists; a broken one is no reason to lose the verdict.""" + for stream in streams: + if stream is not None: + with contextlib.suppress(Exception): + stream.flush() + + +@contextlib.contextmanager +def _stdout_to_stderr(): + # Stdout is the verdict channel. A handler's stray print (or a child process it runs) + # ahead of the JSON makes the host fail to parse it, and Claude Code and Gemini CLI then + # treat the hook as a non-blocking error: a deny became an allow, witnessed live. + sink = sys.stderr if sys.stderr is not None else io.StringIO() + held = (sys.stdout, sys.__stdout__) + _flush(held) + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + # A stream the handler held on to (sys.__stdout__, a reference cached at import) + # buffers past redirect_stdout; flushed now it lands on stderr, not after the verdict. + _flush((sink, *held)) + os.dup2(saved, 1) + os.close(saved) + + def _decide(raw): """(stdout_text, exit_code) for one decoded payload: agentseam.dispatch.handle(), inlined.""" event = parse(raw) @@ -1369,7 +1423,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 ad40aa4..98e4e24 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.""" @@ -202,7 +205,7 @@ def tool_input_of(raw): """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" if isinstance(raw, dict): return raw - if isinstance(raw, str) and raw[:1] == "{": + if isinstance(raw, str) and raw.lstrip()[:1] == "{": try: parsed = _json.loads(raw) except (ValueError, RecursionError): @@ -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): @@ -862,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] == '{': @@ -882,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: @@ -1360,6 +1365,55 @@ def _report(text): return +_STDERR_FD = 2 + + +def _divert_fd1(): + """Point fd 1 at stderr (devnull if there is none); the saved fd 1, or None if it could not.""" + try: + saved = os.dup(1) + except OSError: + return None + # With fd 2 closed, dup() hands back 2 itself, and "stderr" would be stdout again. + if saved > _STDERR_FD: + with contextlib.suppress(OSError): + os.dup2(_STDERR_FD, 1) + return saved + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +def _flush(streams): + """Flush each stream that exists; a broken one is no reason to lose the verdict.""" + for stream in streams: + if stream is not None: + with contextlib.suppress(Exception): + stream.flush() + + +@contextlib.contextmanager +def _stdout_to_stderr(): + # Stdout is the verdict channel. A handler's stray print (or a child process it runs) + # ahead of the JSON makes the host fail to parse it, and Claude Code and Gemini CLI then + # treat the hook as a non-blocking error: a deny became an allow, witnessed live. + sink = sys.stderr if sys.stderr is not None else io.StringIO() + held = (sys.stdout, sys.__stdout__) + _flush(held) + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + # A stream the handler held on to (sys.__stdout__, a reference cached at import) + # buffers past redirect_stdout; flushed now it lands on stderr, not after the verdict. + _flush((sink, *held)) + os.dup2(saved, 1) + os.close(saved) + + def _decide(raw): """(stdout_text, exit_code) for one decoded payload: agentseam.dispatch.handle(), inlined.""" event = parse(raw) @@ -1369,7 +1423,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 30f2d89..7f4b826 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.""" @@ -202,7 +205,7 @@ def tool_input_of(raw): """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" if isinstance(raw, dict): return raw - if isinstance(raw, str) and raw[:1] == "{": + if isinstance(raw, str) and raw.lstrip()[:1] == "{": try: parsed = _json.loads(raw) except (ValueError, RecursionError): @@ -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"): @@ -845,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] == '{': @@ -865,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: @@ -1343,6 +1348,55 @@ def _report(text): return +_STDERR_FD = 2 + + +def _divert_fd1(): + """Point fd 1 at stderr (devnull if there is none); the saved fd 1, or None if it could not.""" + try: + saved = os.dup(1) + except OSError: + return None + # With fd 2 closed, dup() hands back 2 itself, and "stderr" would be stdout again. + if saved > _STDERR_FD: + with contextlib.suppress(OSError): + os.dup2(_STDERR_FD, 1) + return saved + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +def _flush(streams): + """Flush each stream that exists; a broken one is no reason to lose the verdict.""" + for stream in streams: + if stream is not None: + with contextlib.suppress(Exception): + stream.flush() + + +@contextlib.contextmanager +def _stdout_to_stderr(): + # Stdout is the verdict channel. A handler's stray print (or a child process it runs) + # ahead of the JSON makes the host fail to parse it, and Claude Code and Gemini CLI then + # treat the hook as a non-blocking error: a deny became an allow, witnessed live. + sink = sys.stderr if sys.stderr is not None else io.StringIO() + held = (sys.stdout, sys.__stdout__) + _flush(held) + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + # A stream the handler held on to (sys.__stdout__, a reference cached at import) + # buffers past redirect_stdout; flushed now it lands on stderr, not after the verdict. + _flush((sink, *held)) + os.dup2(saved, 1) + os.close(saved) + + def _decide(raw): """(stdout_text, exit_code) for one decoded payload: agentseam.dispatch.handle(), inlined.""" event = parse(raw) @@ -1352,7 +1406,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 5042525..aa8e501 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.""" @@ -202,7 +205,7 @@ def tool_input_of(raw): """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" if isinstance(raw, dict): return raw - if isinstance(raw, str) and raw[:1] == "{": + if isinstance(raw, str) and raw.lstrip()[:1] == "{": try: parsed = _json.loads(raw) except (ValueError, RecursionError): @@ -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"): @@ -845,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] == '{': @@ -865,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: @@ -1343,6 +1348,55 @@ def _report(text): return +_STDERR_FD = 2 + + +def _divert_fd1(): + """Point fd 1 at stderr (devnull if there is none); the saved fd 1, or None if it could not.""" + try: + saved = os.dup(1) + except OSError: + return None + # With fd 2 closed, dup() hands back 2 itself, and "stderr" would be stdout again. + if saved > _STDERR_FD: + with contextlib.suppress(OSError): + os.dup2(_STDERR_FD, 1) + return saved + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +def _flush(streams): + """Flush each stream that exists; a broken one is no reason to lose the verdict.""" + for stream in streams: + if stream is not None: + with contextlib.suppress(Exception): + stream.flush() + + +@contextlib.contextmanager +def _stdout_to_stderr(): + # Stdout is the verdict channel. A handler's stray print (or a child process it runs) + # ahead of the JSON makes the host fail to parse it, and Claude Code and Gemini CLI then + # treat the hook as a non-blocking error: a deny became an allow, witnessed live. + sink = sys.stderr if sys.stderr is not None else io.StringIO() + held = (sys.stdout, sys.__stdout__) + _flush(held) + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + # A stream the handler held on to (sys.__stdout__, a reference cached at import) + # buffers past redirect_stdout; flushed now it lands on stderr, not after the verdict. + _flush((sink, *held)) + os.dup2(saved, 1) + os.close(saved) + + def _decide(raw): """(stdout_text, exit_code) for one decoded payload: agentseam.dispatch.handle(), inlined.""" event = parse(raw) @@ -1352,7 +1406,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 51f6f53..ae39e61 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.""" @@ -202,7 +205,7 @@ def tool_input_of(raw): """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" if isinstance(raw, dict): return raw - if isinstance(raw, str) and raw[:1] == "{": + if isinstance(raw, str) and raw.lstrip()[:1] == "{": try: parsed = _json.loads(raw) except (ValueError, RecursionError): @@ -220,10 +223,25 @@ def tool_input_of(raw): """PowerShell's one rule that breaks hook commands, shared by the vendors it affects.""" +#: `pwsh -Command` exits 1 whenever the last native command failed, whatever its code, so a +#: hook's exit 2 (block) reached the host as 1 (a non-blocking error): openai/codex#48183. +#: When no native command ran at all (the interpreter is not on PATH), $LASTEXITCODE is $null +#: and `exit $null` is 0 -- an allow; nothing judged the call, so that refuses (2) instead. +_KEEP_EXIT = "; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE" + +#: The suffix before the $null guard; an entry carrying it is upgraded, not suffixed twice. +_BARE_EXIT = "; exit $LASTEXITCODE" + def powershell_command(command): - """`command` rewritten so PowerShell will actually run it.""" - return command if command.lstrip().startswith("&") else "& " + command + """`command` rewritten so PowerShell will actually run it and keep its exit code.""" + body = command if command.lstrip().startswith("&") else "& " + command + body = body.rstrip() + if body.endswith(_KEEP_EXIT): + return body + if body.endswith(_BARE_EXIT): + body = body[: -len(_BARE_EXIT)] + return body + _KEEP_EXIT # ------------------------------------------------------------------------------ @@ -268,6 +286,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 +317,38 @@ def claims(raw): return False if name in _CLAIMABLE: return True + if name is None and (_CLI_ARGS in raw or ("toolName" in raw and ("sessionId" in raw or _VSCODE_ENVELOPE in raw))): + # A CLI call may omit toolArgs; unclaimed, it would pass unseen as "unrecognized". + return True ti = raw.get("tool_input") return raw.get("tool_name") in MEMORY_TOOLS and isinstance(ti, dict) and "command" in ti +def _tool_input(raw): + """The tool's arguments: VS Code's `tool_input`, else the CLI's `toolArgs` (object or JSON text).""" + ti = raw.get("tool_input") + return tool_input_of(raw.get(_CLI_ARGS) if ti is None else ti) + + +def is_cli_native(raw): + """True for Copilot CLI's camelCase payloads, which get its top-level permission answer.""" + if not isinstance(raw, dict): + return False + name = _name(raw) + if name is None: + return any(k in raw for k in _CLI_KEYS) + return name in _CLAIMABLE + + +def _cli_output(raw): + result = raw.get(_CLI_RESULT) + return result.get("textResultForLlm") if isinstance(result, dict) else None + + def 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 +360,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 +369,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 +379,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 +443,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 +473,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 @@ -669,7 +732,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] == '{': @@ -689,7 +752,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: @@ -1167,6 +1230,55 @@ def _report(text): return +_STDERR_FD = 2 + + +def _divert_fd1(): + """Point fd 1 at stderr (devnull if there is none); the saved fd 1, or None if it could not.""" + try: + saved = os.dup(1) + except OSError: + return None + # With fd 2 closed, dup() hands back 2 itself, and "stderr" would be stdout again. + if saved > _STDERR_FD: + with contextlib.suppress(OSError): + os.dup2(_STDERR_FD, 1) + return saved + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +def _flush(streams): + """Flush each stream that exists; a broken one is no reason to lose the verdict.""" + for stream in streams: + if stream is not None: + with contextlib.suppress(Exception): + stream.flush() + + +@contextlib.contextmanager +def _stdout_to_stderr(): + # Stdout is the verdict channel. A handler's stray print (or a child process it runs) + # ahead of the JSON makes the host fail to parse it, and Claude Code and Gemini CLI then + # treat the hook as a non-blocking error: a deny became an allow, witnessed live. + sink = sys.stderr if sys.stderr is not None else io.StringIO() + held = (sys.stdout, sys.__stdout__) + _flush(held) + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + # A stream the handler held on to (sys.__stdout__, a reference cached at import) + # buffers past redirect_stdout; flushed now it lands on stderr, not after the verdict. + _flush((sink, *held)) + os.dup2(saved, 1) + os.close(saved) + + def _decide(raw): """(stdout_text, exit_code) for one decoded payload: agentseam.dispatch.handle(), inlined.""" event = parse(raw) @@ -1176,7 +1288,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 aa68437..a68a9bd 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.""" @@ -202,7 +205,7 @@ def tool_input_of(raw): """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" if isinstance(raw, dict): return raw - if isinstance(raw, str) and raw[:1] == "{": + if isinstance(raw, str) and raw.lstrip()[:1] == "{": try: parsed = _json.loads(raw) except (ValueError, RecursionError): @@ -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"): @@ -759,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] == '{': @@ -779,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: @@ -1257,6 +1262,55 @@ def _report(text): return +_STDERR_FD = 2 + + +def _divert_fd1(): + """Point fd 1 at stderr (devnull if there is none); the saved fd 1, or None if it could not.""" + try: + saved = os.dup(1) + except OSError: + return None + # With fd 2 closed, dup() hands back 2 itself, and "stderr" would be stdout again. + if saved > _STDERR_FD: + with contextlib.suppress(OSError): + os.dup2(_STDERR_FD, 1) + return saved + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +def _flush(streams): + """Flush each stream that exists; a broken one is no reason to lose the verdict.""" + for stream in streams: + if stream is not None: + with contextlib.suppress(Exception): + stream.flush() + + +@contextlib.contextmanager +def _stdout_to_stderr(): + # Stdout is the verdict channel. A handler's stray print (or a child process it runs) + # ahead of the JSON makes the host fail to parse it, and Claude Code and Gemini CLI then + # treat the hook as a non-blocking error: a deny became an allow, witnessed live. + sink = sys.stderr if sys.stderr is not None else io.StringIO() + held = (sys.stdout, sys.__stdout__) + _flush(held) + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + # A stream the handler held on to (sys.__stdout__, a reference cached at import) + # buffers past redirect_stdout; flushed now it lands on stderr, not after the verdict. + _flush((sink, *held)) + os.dup2(saved, 1) + os.close(saved) + + def _decide(raw): """(stdout_text, exit_code) for one decoded payload: agentseam.dispatch.handle(), inlined.""" event = parse(raw) @@ -1266,7 +1320,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/agent-hooks/agent-hooks.json b/.chock/compiled/block-destructive-commands/agent-hooks/agent-hooks.json index 728b6cb..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\"", - "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\"" + "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-destructive-commands/pre-tool-use/codex_cli-hooks.json b/.chock/compiled/block-destructive-commands/pre-tool-use/codex_cli-hooks.json index 3acb065..873d4a2 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=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", - "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"; if ($null -eq $LASTEXITCODE) { exit 2 }; 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 61bdbef..3db670c 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/agent-hooks/agent-hooks.json b/.chock/compiled/block-no-verify/agent-hooks/agent-hooks.json index b448955..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\"", - "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\"" + "powershell": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE", + "windows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/vscode_copilot.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE" } \ No newline at end of file diff --git a/.chock/compiled/block-no-verify/pre-tool-use/codex_cli-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/codex_cli-hooks.json index 4550569..34dd3e8 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=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", - "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE" } ], "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 cc4e582..24f8069 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..9680cdf --- /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=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", + "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/pin-github-actions/pre-tool-use/gate.json\"; if ($null -eq $LASTEXITCODE) { exit 2 }; 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 6f39c1e..77772aa 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=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", - "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" + "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE" } ] } 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 ef10568..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\"", - "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\"" + "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-agent-config/pre-tool-use/codex_cli-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/codex_cli-hooks.json index ec92171..de200ba 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=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", - "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"; if ($null -eq $LASTEXITCODE) { exit 2 }; 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 752b21f..fd6004a 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/agent-hooks/agent-hooks.json b/.chock/compiled/protect-commit-privacy/agent-hooks/agent-hooks.json index 794a11e..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\"", - "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\"" + "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/.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 bbd5488..993ed3a 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=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", - "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"; if ($null -eq $LASTEXITCODE) { exit 2 }; 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 ccbaba8..478c3d2 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 cb0a148..12c72c5 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 ef496ae..961c047 100644 --- a/.codex/hooks.json +++ b/.codex/hooks.json @@ -6,7 +6,7 @@ { "type": "command", "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", - "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE" } ], "matcher": "Bash" @@ -16,17 +16,27 @@ { "type": "command", "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", - "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE" } ], "matcher": "Bash" }, + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", + "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/pin-github-actions/pre-tool-use/gate.json\"; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE" + } + ], + "matcher": "apply_patch" + }, { "hooks": [ { "type": "command", "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", - "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE" } ], "matcher": "Bash" @@ -36,7 +46,7 @@ { "type": "command", "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", - "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE" } ], "matcher": "Bash" @@ -48,7 +58,7 @@ { "type": "command", "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", - "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" + "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE" } ] } diff --git a/.github/hooks/chock.json b/.github/hooks/chock.json index 0530805..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\"", - "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\"" + "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\"", - "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\"" + "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\"", - "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\"" + "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\"", - "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\"" + "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/CHANGELOG.md b/CHANGELOG.md index 9d5599a..7e2ae69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,13 @@ `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. + 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 diff --git a/chock.lock b/chock.lock index df9720b..c916233 100644 --- a/chock.lock +++ b/chock.lock @@ -16,7 +16,7 @@ "managed": false, "sha256": "59fffb91c6f65710b461e87782d355c1403953f36700e05369fca065683821c0", "source": "local", - "artifacts_sha256": "819eea7a56cb1be3ab58229132509a98be3c4ca54c9de0b258b61be48dd47543" + "artifacts_sha256": "bf8393543ef3e4e6e20fc79b607e456a62746c15fafbae83048e8dacaa117c4f" }, { "id": "block-invisible-unicode", @@ -32,7 +32,7 @@ "managed": false, "sha256": "ef0d729c413086bd4ce77c7191bcc1393f4e0cdfcfc959b33a1ddb771afdf2b0", "source": "local", - "artifacts_sha256": "2916a7f196917545c7cdc36b49aa8bf10b91622d17790158124417696ba9b9db" + "artifacts_sha256": "f0a832495520b6da8227626f832f182a1994fedf161c43ecaceec3827ac2d7aa" }, { "id": "block-wildcard-agent-permissions", @@ -96,7 +96,7 @@ "managed": false, "sha256": "e73dbc039cdcbf137049a1f5035e4b595d12afe74a12a0f78e2e102d20d29479", "source": "local", - "artifacts_sha256": "ee361e81033a87fa3d3c4b514108fb2dc850239d5e7563eddc80be8fff097081" + "artifacts_sha256": "9c2403fe00cda415804de0a76f02c0071534803025915653a892eb4715f9a457" }, { "id": "pre-generated-scripts", @@ -112,7 +112,7 @@ "managed": false, "sha256": "b98d5472c534d381b87e253086642675c71ce3947e2cfe01ed9fec5ed8b66295", "source": "local", - "artifacts_sha256": "31cde9edcf9065e64d2a0f172fb706e6221a25e5804480fd5e608335f5680ca3" + "artifacts_sha256": "1a7ca505926f51a8957ec3fc0667cc6c6cf07d1411670bd42699159690cb804b" }, { "id": "protect-commit-privacy", @@ -120,7 +120,7 @@ "managed": false, "sha256": "281522db2b259ea9a12d76b30d57ed9993882089b04a1fb4ce9932bc15a3388f", "source": "local", - "artifacts_sha256": "24609dd393f340ebaaaa68be652432f536e778a7069656032d2a00e10f7c3739" + "artifacts_sha256": "819bd42bceadbc7272fd28581de153329193161ad72f4ce41269a9b2e963d411" }, { "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/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 \ diff --git a/src/chock/compile/emitters/in_agent.py b/src/chock/compile/emitters/in_agent.py index 3d4b6e6..1d69bf3 100644 --- a/src/chock/compile/emitters/in_agent.py +++ b/src/chock/compile/emitters/in_agent.py @@ -43,6 +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" +#: `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" @@ -221,11 +224,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/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/emitter_stability/golden/stability-hook/agent-hooks/agent-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/agent-hooks/agent-hooks.json index 5ea8977..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\"", - "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\"" + "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/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 d64b3da..a235dee 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=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", - "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" + "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"; if ($null -eq $LASTEXITCODE) { exit 2 }; 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 c9d48b1..eb4e4e8 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..d3ad839 --- /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=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/stability-script/pre-tool-use/gate.json\"", + "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/stability-script/pre-tool-use/gate.json\"; if ($null -eq $LASTEXITCODE) { exit 2 }; 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 1599a6e..50934b1 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=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/stability-script/stop/gate.json\"", - "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/stability-script/stop/gate.json\"" + "commandWindows": "& git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/codex_cli.py --gate \".chock/compiled/stability-script/stop/gate.json\"; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE" } ] } diff --git a/tests/fixtures/runtime_goldens/antigravity.py b/tests/fixtures/runtime_goldens/antigravity.py index 58773b0..240a1b2 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.""" @@ -202,7 +205,7 @@ def tool_input_of(raw): """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" if isinstance(raw, dict): return raw - if isinstance(raw, str) and raw[:1] == "{": + if isinstance(raw, str) and raw.lstrip()[:1] == "{": try: parsed = _json.loads(raw) except (ValueError, RecursionError): @@ -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"): @@ -828,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] == '{': @@ -848,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: @@ -1326,6 +1331,55 @@ def _report(text): return +_STDERR_FD = 2 + + +def _divert_fd1(): + """Point fd 1 at stderr (devnull if there is none); the saved fd 1, or None if it could not.""" + try: + saved = os.dup(1) + except OSError: + return None + # With fd 2 closed, dup() hands back 2 itself, and "stderr" would be stdout again. + if saved > _STDERR_FD: + with contextlib.suppress(OSError): + os.dup2(_STDERR_FD, 1) + return saved + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +def _flush(streams): + """Flush each stream that exists; a broken one is no reason to lose the verdict.""" + for stream in streams: + if stream is not None: + with contextlib.suppress(Exception): + stream.flush() + + +@contextlib.contextmanager +def _stdout_to_stderr(): + # Stdout is the verdict channel. A handler's stray print (or a child process it runs) + # ahead of the JSON makes the host fail to parse it, and Claude Code and Gemini CLI then + # treat the hook as a non-blocking error: a deny became an allow, witnessed live. + sink = sys.stderr if sys.stderr is not None else io.StringIO() + held = (sys.stdout, sys.__stdout__) + _flush(held) + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + # A stream the handler held on to (sys.__stdout__, a reference cached at import) + # buffers past redirect_stdout; flushed now it lands on stderr, not after the verdict. + _flush((sink, *held)) + os.dup2(saved, 1) + os.close(saved) + + def _decide(raw): """(stdout_text, exit_code) for one decoded payload: agentseam.dispatch.handle(), inlined.""" event = parse(raw) @@ -1335,7 +1389,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 6f61546..ff62ec2 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.""" @@ -202,7 +205,7 @@ def tool_input_of(raw): """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" if isinstance(raw, dict): return raw - if isinstance(raw, str) and raw[:1] == "{": + if isinstance(raw, str) and raw.lstrip()[:1] == "{": try: parsed = _json.loads(raw) except (ValueError, RecursionError): @@ -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): @@ -893,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] == '{': @@ -913,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: @@ -1459,6 +1464,55 @@ def _report(text): return +_STDERR_FD = 2 + + +def _divert_fd1(): + """Point fd 1 at stderr (devnull if there is none); the saved fd 1, or None if it could not.""" + try: + saved = os.dup(1) + except OSError: + return None + # With fd 2 closed, dup() hands back 2 itself, and "stderr" would be stdout again. + if saved > _STDERR_FD: + with contextlib.suppress(OSError): + os.dup2(_STDERR_FD, 1) + return saved + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +def _flush(streams): + """Flush each stream that exists; a broken one is no reason to lose the verdict.""" + for stream in streams: + if stream is not None: + with contextlib.suppress(Exception): + stream.flush() + + +@contextlib.contextmanager +def _stdout_to_stderr(): + # Stdout is the verdict channel. A handler's stray print (or a child process it runs) + # ahead of the JSON makes the host fail to parse it, and Claude Code and Gemini CLI then + # treat the hook as a non-blocking error: a deny became an allow, witnessed live. + sink = sys.stderr if sys.stderr is not None else io.StringIO() + held = (sys.stdout, sys.__stdout__) + _flush(held) + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + # A stream the handler held on to (sys.__stdout__, a reference cached at import) + # buffers past redirect_stdout; flushed now it lands on stderr, not after the verdict. + _flush((sink, *held)) + os.dup2(saved, 1) + os.close(saved) + + def _decide(raw): """(stdout_text, exit_code) for one decoded payload: agentseam.dispatch.handle(), inlined.""" event = parse(raw) @@ -1468,7 +1522,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 901a187..0aa8109 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.""" @@ -202,7 +205,7 @@ def tool_input_of(raw): """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" if isinstance(raw, dict): return raw - if isinstance(raw, str) and raw[:1] == "{": + if isinstance(raw, str) and raw.lstrip()[:1] == "{": try: parsed = _json.loads(raw) except (ValueError, RecursionError): @@ -217,9 +220,19 @@ def tool_input_of(raw): # ------------------------------------------------------------------------------ # hook_json family engine (trimmed to what this entry uses) +_KEEP_EXIT = "; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE" + +_BARE_EXIT = "; exit $LASTEXITCODE" + def powershell_command(command): - """`command` rewritten so PowerShell will actually run it.""" - return command if command.lstrip().startswith("&") else "& " + command + """`command` rewritten so PowerShell will actually run it and keep its exit code.""" + body = command if command.lstrip().startswith("&") else "& " + command + body = body.rstrip() + if body.endswith(_KEEP_EXIT): + return body + if body.endswith(_BARE_EXIT): + body = body[: -len(_BARE_EXIT)] + return body + _KEEP_EXIT UNREADABLE_NAME = "" @@ -550,6 +563,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 +581,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 +620,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 +650,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): @@ -880,7 +895,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] == '{': @@ -900,7 +915,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: @@ -1378,6 +1393,55 @@ def _report(text): return +_STDERR_FD = 2 + + +def _divert_fd1(): + """Point fd 1 at stderr (devnull if there is none); the saved fd 1, or None if it could not.""" + try: + saved = os.dup(1) + except OSError: + return None + # With fd 2 closed, dup() hands back 2 itself, and "stderr" would be stdout again. + if saved > _STDERR_FD: + with contextlib.suppress(OSError): + os.dup2(_STDERR_FD, 1) + return saved + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +def _flush(streams): + """Flush each stream that exists; a broken one is no reason to lose the verdict.""" + for stream in streams: + if stream is not None: + with contextlib.suppress(Exception): + stream.flush() + + +@contextlib.contextmanager +def _stdout_to_stderr(): + # Stdout is the verdict channel. A handler's stray print (or a child process it runs) + # ahead of the JSON makes the host fail to parse it, and Claude Code and Gemini CLI then + # treat the hook as a non-blocking error: a deny became an allow, witnessed live. + sink = sys.stderr if sys.stderr is not None else io.StringIO() + held = (sys.stdout, sys.__stdout__) + _flush(held) + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + # A stream the handler held on to (sys.__stdout__, a reference cached at import) + # buffers past redirect_stdout; flushed now it lands on stderr, not after the verdict. + _flush((sink, *held)) + os.dup2(saved, 1) + os.close(saved) + + def _decide(raw): """(stdout_text, exit_code) for one decoded payload: agentseam.dispatch.handle(), inlined.""" event = parse(raw) @@ -1387,7 +1451,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 0f92770..e477f38 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.""" @@ -202,7 +205,7 @@ def tool_input_of(raw): """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" if isinstance(raw, dict): return raw - if isinstance(raw, str) and raw[:1] == "{": + if isinstance(raw, str) and raw.lstrip()[:1] == "{": try: parsed = _json.loads(raw) except (ValueError, RecursionError): @@ -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"): @@ -808,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] == '{': @@ -828,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: @@ -1306,6 +1311,55 @@ def _report(text): return +_STDERR_FD = 2 + + +def _divert_fd1(): + """Point fd 1 at stderr (devnull if there is none); the saved fd 1, or None if it could not.""" + try: + saved = os.dup(1) + except OSError: + return None + # With fd 2 closed, dup() hands back 2 itself, and "stderr" would be stdout again. + if saved > _STDERR_FD: + with contextlib.suppress(OSError): + os.dup2(_STDERR_FD, 1) + return saved + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +def _flush(streams): + """Flush each stream that exists; a broken one is no reason to lose the verdict.""" + for stream in streams: + if stream is not None: + with contextlib.suppress(Exception): + stream.flush() + + +@contextlib.contextmanager +def _stdout_to_stderr(): + # Stdout is the verdict channel. A handler's stray print (or a child process it runs) + # ahead of the JSON makes the host fail to parse it, and Claude Code and Gemini CLI then + # treat the hook as a non-blocking error: a deny became an allow, witnessed live. + sink = sys.stderr if sys.stderr is not None else io.StringIO() + held = (sys.stdout, sys.__stdout__) + _flush(held) + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + # A stream the handler held on to (sys.__stdout__, a reference cached at import) + # buffers past redirect_stdout; flushed now it lands on stderr, not after the verdict. + _flush((sink, *held)) + os.dup2(saved, 1) + os.close(saved) + + def _decide(raw): """(stdout_text, exit_code) for one decoded payload: agentseam.dispatch.handle(), inlined.""" event = parse(raw) @@ -1315,7 +1369,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 588369c..c2038e2 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.""" @@ -202,7 +205,7 @@ def tool_input_of(raw): """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" if isinstance(raw, dict): return raw - if isinstance(raw, str) and raw[:1] == "{": + if isinstance(raw, str) and raw.lstrip()[:1] == "{": try: parsed = _json.loads(raw) except (ValueError, RecursionError): @@ -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): @@ -862,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] == '{': @@ -882,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: @@ -1360,6 +1365,55 @@ def _report(text): return +_STDERR_FD = 2 + + +def _divert_fd1(): + """Point fd 1 at stderr (devnull if there is none); the saved fd 1, or None if it could not.""" + try: + saved = os.dup(1) + except OSError: + return None + # With fd 2 closed, dup() hands back 2 itself, and "stderr" would be stdout again. + if saved > _STDERR_FD: + with contextlib.suppress(OSError): + os.dup2(_STDERR_FD, 1) + return saved + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +def _flush(streams): + """Flush each stream that exists; a broken one is no reason to lose the verdict.""" + for stream in streams: + if stream is not None: + with contextlib.suppress(Exception): + stream.flush() + + +@contextlib.contextmanager +def _stdout_to_stderr(): + # Stdout is the verdict channel. A handler's stray print (or a child process it runs) + # ahead of the JSON makes the host fail to parse it, and Claude Code and Gemini CLI then + # treat the hook as a non-blocking error: a deny became an allow, witnessed live. + sink = sys.stderr if sys.stderr is not None else io.StringIO() + held = (sys.stdout, sys.__stdout__) + _flush(held) + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + # A stream the handler held on to (sys.__stdout__, a reference cached at import) + # buffers past redirect_stdout; flushed now it lands on stderr, not after the verdict. + _flush((sink, *held)) + os.dup2(saved, 1) + os.close(saved) + + def _decide(raw): """(stdout_text, exit_code) for one decoded payload: agentseam.dispatch.handle(), inlined.""" event = parse(raw) @@ -1369,7 +1423,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 ad40aa4..98e4e24 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.""" @@ -202,7 +205,7 @@ def tool_input_of(raw): """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" if isinstance(raw, dict): return raw - if isinstance(raw, str) and raw[:1] == "{": + if isinstance(raw, str) and raw.lstrip()[:1] == "{": try: parsed = _json.loads(raw) except (ValueError, RecursionError): @@ -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): @@ -862,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] == '{': @@ -882,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: @@ -1360,6 +1365,55 @@ def _report(text): return +_STDERR_FD = 2 + + +def _divert_fd1(): + """Point fd 1 at stderr (devnull if there is none); the saved fd 1, or None if it could not.""" + try: + saved = os.dup(1) + except OSError: + return None + # With fd 2 closed, dup() hands back 2 itself, and "stderr" would be stdout again. + if saved > _STDERR_FD: + with contextlib.suppress(OSError): + os.dup2(_STDERR_FD, 1) + return saved + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +def _flush(streams): + """Flush each stream that exists; a broken one is no reason to lose the verdict.""" + for stream in streams: + if stream is not None: + with contextlib.suppress(Exception): + stream.flush() + + +@contextlib.contextmanager +def _stdout_to_stderr(): + # Stdout is the verdict channel. A handler's stray print (or a child process it runs) + # ahead of the JSON makes the host fail to parse it, and Claude Code and Gemini CLI then + # treat the hook as a non-blocking error: a deny became an allow, witnessed live. + sink = sys.stderr if sys.stderr is not None else io.StringIO() + held = (sys.stdout, sys.__stdout__) + _flush(held) + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + # A stream the handler held on to (sys.__stdout__, a reference cached at import) + # buffers past redirect_stdout; flushed now it lands on stderr, not after the verdict. + _flush((sink, *held)) + os.dup2(saved, 1) + os.close(saved) + + def _decide(raw): """(stdout_text, exit_code) for one decoded payload: agentseam.dispatch.handle(), inlined.""" event = parse(raw) @@ -1369,7 +1423,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 30f2d89..7f4b826 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.""" @@ -202,7 +205,7 @@ def tool_input_of(raw): """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" if isinstance(raw, dict): return raw - if isinstance(raw, str) and raw[:1] == "{": + if isinstance(raw, str) and raw.lstrip()[:1] == "{": try: parsed = _json.loads(raw) except (ValueError, RecursionError): @@ -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"): @@ -845,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] == '{': @@ -865,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: @@ -1343,6 +1348,55 @@ def _report(text): return +_STDERR_FD = 2 + + +def _divert_fd1(): + """Point fd 1 at stderr (devnull if there is none); the saved fd 1, or None if it could not.""" + try: + saved = os.dup(1) + except OSError: + return None + # With fd 2 closed, dup() hands back 2 itself, and "stderr" would be stdout again. + if saved > _STDERR_FD: + with contextlib.suppress(OSError): + os.dup2(_STDERR_FD, 1) + return saved + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +def _flush(streams): + """Flush each stream that exists; a broken one is no reason to lose the verdict.""" + for stream in streams: + if stream is not None: + with contextlib.suppress(Exception): + stream.flush() + + +@contextlib.contextmanager +def _stdout_to_stderr(): + # Stdout is the verdict channel. A handler's stray print (or a child process it runs) + # ahead of the JSON makes the host fail to parse it, and Claude Code and Gemini CLI then + # treat the hook as a non-blocking error: a deny became an allow, witnessed live. + sink = sys.stderr if sys.stderr is not None else io.StringIO() + held = (sys.stdout, sys.__stdout__) + _flush(held) + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + # A stream the handler held on to (sys.__stdout__, a reference cached at import) + # buffers past redirect_stdout; flushed now it lands on stderr, not after the verdict. + _flush((sink, *held)) + os.dup2(saved, 1) + os.close(saved) + + def _decide(raw): """(stdout_text, exit_code) for one decoded payload: agentseam.dispatch.handle(), inlined.""" event = parse(raw) @@ -1352,7 +1406,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 5042525..aa8e501 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.""" @@ -202,7 +205,7 @@ def tool_input_of(raw): """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" if isinstance(raw, dict): return raw - if isinstance(raw, str) and raw[:1] == "{": + if isinstance(raw, str) and raw.lstrip()[:1] == "{": try: parsed = _json.loads(raw) except (ValueError, RecursionError): @@ -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"): @@ -845,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] == '{': @@ -865,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: @@ -1343,6 +1348,55 @@ def _report(text): return +_STDERR_FD = 2 + + +def _divert_fd1(): + """Point fd 1 at stderr (devnull if there is none); the saved fd 1, or None if it could not.""" + try: + saved = os.dup(1) + except OSError: + return None + # With fd 2 closed, dup() hands back 2 itself, and "stderr" would be stdout again. + if saved > _STDERR_FD: + with contextlib.suppress(OSError): + os.dup2(_STDERR_FD, 1) + return saved + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +def _flush(streams): + """Flush each stream that exists; a broken one is no reason to lose the verdict.""" + for stream in streams: + if stream is not None: + with contextlib.suppress(Exception): + stream.flush() + + +@contextlib.contextmanager +def _stdout_to_stderr(): + # Stdout is the verdict channel. A handler's stray print (or a child process it runs) + # ahead of the JSON makes the host fail to parse it, and Claude Code and Gemini CLI then + # treat the hook as a non-blocking error: a deny became an allow, witnessed live. + sink = sys.stderr if sys.stderr is not None else io.StringIO() + held = (sys.stdout, sys.__stdout__) + _flush(held) + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + # A stream the handler held on to (sys.__stdout__, a reference cached at import) + # buffers past redirect_stdout; flushed now it lands on stderr, not after the verdict. + _flush((sink, *held)) + os.dup2(saved, 1) + os.close(saved) + + def _decide(raw): """(stdout_text, exit_code) for one decoded payload: agentseam.dispatch.handle(), inlined.""" event = parse(raw) @@ -1352,7 +1406,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 51f6f53..ae39e61 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.""" @@ -202,7 +205,7 @@ def tool_input_of(raw): """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" if isinstance(raw, dict): return raw - if isinstance(raw, str) and raw[:1] == "{": + if isinstance(raw, str) and raw.lstrip()[:1] == "{": try: parsed = _json.loads(raw) except (ValueError, RecursionError): @@ -220,10 +223,25 @@ def tool_input_of(raw): """PowerShell's one rule that breaks hook commands, shared by the vendors it affects.""" +#: `pwsh -Command` exits 1 whenever the last native command failed, whatever its code, so a +#: hook's exit 2 (block) reached the host as 1 (a non-blocking error): openai/codex#48183. +#: When no native command ran at all (the interpreter is not on PATH), $LASTEXITCODE is $null +#: and `exit $null` is 0 -- an allow; nothing judged the call, so that refuses (2) instead. +_KEEP_EXIT = "; if ($null -eq $LASTEXITCODE) { exit 2 }; exit $LASTEXITCODE" + +#: The suffix before the $null guard; an entry carrying it is upgraded, not suffixed twice. +_BARE_EXIT = "; exit $LASTEXITCODE" + def powershell_command(command): - """`command` rewritten so PowerShell will actually run it.""" - return command if command.lstrip().startswith("&") else "& " + command + """`command` rewritten so PowerShell will actually run it and keep its exit code.""" + body = command if command.lstrip().startswith("&") else "& " + command + body = body.rstrip() + if body.endswith(_KEEP_EXIT): + return body + if body.endswith(_BARE_EXIT): + body = body[: -len(_BARE_EXIT)] + return body + _KEEP_EXIT # ------------------------------------------------------------------------------ @@ -268,6 +286,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 +317,38 @@ def claims(raw): return False if name in _CLAIMABLE: return True + if name is None and (_CLI_ARGS in raw or ("toolName" in raw and ("sessionId" in raw or _VSCODE_ENVELOPE in raw))): + # A CLI call may omit toolArgs; unclaimed, it would pass unseen as "unrecognized". + return True ti = raw.get("tool_input") return raw.get("tool_name") in MEMORY_TOOLS and isinstance(ti, dict) and "command" in ti +def _tool_input(raw): + """The tool's arguments: VS Code's `tool_input`, else the CLI's `toolArgs` (object or JSON text).""" + ti = raw.get("tool_input") + return tool_input_of(raw.get(_CLI_ARGS) if ti is None else ti) + + +def is_cli_native(raw): + """True for Copilot CLI's camelCase payloads, which get its top-level permission answer.""" + if not isinstance(raw, dict): + return False + name = _name(raw) + if name is None: + return any(k in raw for k in _CLI_KEYS) + return name in _CLAIMABLE + + +def _cli_output(raw): + result = raw.get(_CLI_RESULT) + return result.get("textResultForLlm") if isinstance(result, dict) else None + + def 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 +360,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 +369,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 +379,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 +443,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 +473,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 @@ -669,7 +732,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] == '{': @@ -689,7 +752,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: @@ -1167,6 +1230,55 @@ def _report(text): return +_STDERR_FD = 2 + + +def _divert_fd1(): + """Point fd 1 at stderr (devnull if there is none); the saved fd 1, or None if it could not.""" + try: + saved = os.dup(1) + except OSError: + return None + # With fd 2 closed, dup() hands back 2 itself, and "stderr" would be stdout again. + if saved > _STDERR_FD: + with contextlib.suppress(OSError): + os.dup2(_STDERR_FD, 1) + return saved + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +def _flush(streams): + """Flush each stream that exists; a broken one is no reason to lose the verdict.""" + for stream in streams: + if stream is not None: + with contextlib.suppress(Exception): + stream.flush() + + +@contextlib.contextmanager +def _stdout_to_stderr(): + # Stdout is the verdict channel. A handler's stray print (or a child process it runs) + # ahead of the JSON makes the host fail to parse it, and Claude Code and Gemini CLI then + # treat the hook as a non-blocking error: a deny became an allow, witnessed live. + sink = sys.stderr if sys.stderr is not None else io.StringIO() + held = (sys.stdout, sys.__stdout__) + _flush(held) + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + # A stream the handler held on to (sys.__stdout__, a reference cached at import) + # buffers past redirect_stdout; flushed now it lands on stderr, not after the verdict. + _flush((sink, *held)) + os.dup2(saved, 1) + os.close(saved) + + def _decide(raw): """(stdout_text, exit_code) for one decoded payload: agentseam.dispatch.handle(), inlined.""" event = parse(raw) @@ -1176,7 +1288,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 aa68437..a68a9bd 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.""" @@ -202,7 +205,7 @@ def tool_input_of(raw): """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" if isinstance(raw, dict): return raw - if isinstance(raw, str) and raw[:1] == "{": + if isinstance(raw, str) and raw.lstrip()[:1] == "{": try: parsed = _json.loads(raw) except (ValueError, RecursionError): @@ -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"): @@ -759,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] == '{': @@ -779,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: @@ -1257,6 +1262,55 @@ def _report(text): return +_STDERR_FD = 2 + + +def _divert_fd1(): + """Point fd 1 at stderr (devnull if there is none); the saved fd 1, or None if it could not.""" + try: + saved = os.dup(1) + except OSError: + return None + # With fd 2 closed, dup() hands back 2 itself, and "stderr" would be stdout again. + if saved > _STDERR_FD: + with contextlib.suppress(OSError): + os.dup2(_STDERR_FD, 1) + return saved + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +def _flush(streams): + """Flush each stream that exists; a broken one is no reason to lose the verdict.""" + for stream in streams: + if stream is not None: + with contextlib.suppress(Exception): + stream.flush() + + +@contextlib.contextmanager +def _stdout_to_stderr(): + # Stdout is the verdict channel. A handler's stray print (or a child process it runs) + # ahead of the JSON makes the host fail to parse it, and Claude Code and Gemini CLI then + # treat the hook as a non-blocking error: a deny became an allow, witnessed live. + sink = sys.stderr if sys.stderr is not None else io.StringIO() + held = (sys.stdout, sys.__stdout__) + _flush(held) + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + # A stream the handler held on to (sys.__stdout__, a reference cached at import) + # buffers past redirect_stdout; flushed now it lands on stderr, not after the verdict. + _flush((sink, *held)) + os.dup2(saved, 1) + os.close(saved) + + def _decide(raw): """(stdout_text, exit_code) for one decoded payload: agentseam.dispatch.handle(), inlined.""" event = parse(raw) @@ -1266,7 +1320,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/test_agent_hooks.py b/tests/test_agent_hooks.py index 538f92f..198f7e4 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']}; 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 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_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_hook_launcher.py b/tests/test_hook_launcher.py index 026664a..b7d4efb 100644 --- a/tests/test_hook_launcher.py +++ b/tests/test_hook_launcher.py @@ -14,6 +14,7 @@ from conftest import FRAMEWORK_ROOT, baseline_policy, bash_executable, init_repo from chock.compile.compiler import compile_policy +from chock.compile.emitters.in_agent import POWERSHELL_KEEP_EXIT from chock.compile.surfaces import Surface from chock.hooks import launch from chock.hooks.launch import LAUNCHER_REL, PYTHON_CONFIG_KEY, hook_command, record_interpreter, write_launcher @@ -224,11 +225,24 @@ def test_hook_command_runs_under_every_available_shell( assert proc.returncode == 2, f"{shell} lost the blocking exit code: {proc.stderr}" +#: Keys a PowerShell-only host reads: there the launcher is called with `&` and keeps its exit code. +_POWERSHELL_KEYS = {"powershell", "windows", "commandWindows"} +_POWERSHELL_WRAP = ("& ", POWERSHELL_KEEP_EXIT) + + +def _unwrapped(key: str, command: str) -> str: + """The launcher command inside a PowerShell-only field's `& ...` exit-keeping 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 [] diff --git a/tests/test_plugin_gate_stores.py b/tests/test_plugin_gate_stores.py index 735028b..1b3fd7a 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() diff --git a/tests/test_pretooluse.py b/tests/test_pretooluse.py index 6d4b6d8..508d0cd 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( 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 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"