Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 65 additions & 9 deletions .chock/bin/claude_code.py
Original file line number Diff line number Diff line change
@@ -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.
#
Expand All @@ -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
Expand All @@ -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."""

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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():
Expand All @@ -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
Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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] == '{':
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
Loading
Loading