From eb0be8bea475b266e8eee8e8d3deffdeea37b0b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 10:55:38 +0000 Subject: [PATCH 1/2] Bump agentseam to 0.2.1, derive shell matcher and repo-root token agentseam 0.2.1 now records a shell vocabulary for codex_cli and vscode_copilot and a repo_root_token field on claude_code's vendor entry. Close both gaps chock's tripwire tests were pinned on: - hooks_map_file derives each vendor's own matcher via vendors.shell_matcher instead of borrowing claude_code's MATCHER for codex_cli/vscode_copilot's claude-format plugin hooks. - in_agent.PROJECT_DIR_TOKEN reads vendors.repo_root_token("claude_code") instead of chock's own hardcoded copy of ${CLAUDE_PROJECT_DIR}. Retired the two tripwire tests into derivation checks that fail again if either field disappears upstream, and updated two hook-runtime tests that had asserted the old borrowed-matcher behavior as correct. Regenerated emitter/runtime goldens and this repo's own compiled output against the new pin. Signed-off-by: Claude --- .chock/bin/antigravity.py | 185 ++++++++------ .chock/bin/claude_code.py | 232 ++++++++++-------- .chock/bin/codex_cli.py | 232 ++++++++++-------- .chock/bin/cursor.py | 181 +++++++------- .chock/bin/devin.py | 218 +++++++++------- .chock/bin/gemini_cli.py | 218 +++++++++------- .chock/bin/grok.py | 218 +++++++++------- .chock/bin/tabnine.py | 218 +++++++++------- .chock/bin/vscode_copilot.py | 86 ++++--- .chock/bin/windsurf.py | 127 +++++----- .../pre-tool-use/codex_cli-hooks.json | 3 +- .../pre-tool-use/codex_cli-hooks.json | 3 +- .../pre-tool-use/codex_cli-hooks.json | 3 +- .../pre-tool-use/codex_cli-hooks.json | 3 +- .codex/hooks.json | 12 +- chock.lock | 8 +- pyproject.toml | 2 +- src/chock/compile/emitters/in_agent.py | 15 +- src/chock/vendors.py | 6 + .../pre-tool-use/codex_cli-hooks.json | 3 +- tests/fixtures/runtime_goldens/antigravity.py | 185 ++++++++------ tests/fixtures/runtime_goldens/claude_code.py | 232 ++++++++++-------- tests/fixtures/runtime_goldens/codex_cli.py | 232 ++++++++++-------- tests/fixtures/runtime_goldens/cursor.py | 181 +++++++------- tests/fixtures/runtime_goldens/devin.py | 218 +++++++++------- tests/fixtures/runtime_goldens/gemini_cli.py | 218 +++++++++------- tests/fixtures/runtime_goldens/grok.py | 218 +++++++++------- tests/fixtures/runtime_goldens/tabnine.py | 218 +++++++++------- .../runtime_goldens/vscode_copilot.py | 86 ++++--- tests/fixtures/runtime_goldens/windsurf.py | 127 +++++----- tests/test_copilot_hook_runtime.py | 6 +- tests/test_copilot_plugin.py | 3 +- tests/test_vendor_wire_facts.py | 42 +++- 33 files changed, 2251 insertions(+), 1688 deletions(-) diff --git a/.chock/bin/antigravity.py b/.chock/bin/antigravity.py index a0d4f13..b320995 100755 --- a/.chock/bin/antigravity.py +++ b/.chock/bin/antigravity.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.2.0 -- bundle("antigravity"). Do not hand-edit, except the +# Generated by agentseam 0.2.1 -- 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. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.2.0) +# contract (agentseam 0.2.1) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -64,17 +64,17 @@ class Event: __slots__ = ( "agent", - "event", - "tool", "command", - "path", "content", + "cwd", + "event", "output", + "path", "prompt", + "raw", "session_id", + "tool", "tool_use_id", - "cwd", - "raw", ) def __init__( @@ -133,7 +133,7 @@ def __repr__(self): # pragma: no cover - debugging aid class Decision: """What a handler wants to happen. Adapters translate this to vendor dialect.""" - __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + __slots__ = ("context", "evidence", "outcome", "reason", "updated_input") #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) @@ -203,7 +203,7 @@ def tool_input_of(raw): if isinstance(raw, str) and raw[:1] == "{": try: parsed = _json.loads(raw) - except Exception: + except _json.JSONDecodeError: return {} if isinstance(parsed, dict): return parsed @@ -305,6 +305,10 @@ def hj_parse(cfg, raw, wire=None): raw=raw, ) +_ESCALATE_FROM_TRANSFORM = "escalate_from_transform" + +_TRANSFORM_MISSING_INPUT = "transform_missing_input" + def hj_reverse(cfg): """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" reverse = {} @@ -327,14 +331,14 @@ def _context_body(name, value): def _note_for(v, decision, at_gate, missing_input): notes = v.get("degrade_notes", {}) if decision.outcome == ESCALATE: - if degraded_from(decision) == TRANSFORM and "escalate_from_transform" in notes: - return notes["escalate_from_transform"] + if degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in notes: + return notes[_ESCALATE_FROM_TRANSFORM] if at_gate and "escalate_gate" in notes: return notes["escalate_gate"] return notes.get("escalate") if decision.outcome == TRANSFORM: - if missing_input and "transform_missing_input" in notes: - return notes["transform_missing_input"] + if missing_input and _TRANSFORM_MISSING_INPUT in notes: + return notes[_TRANSFORM_MISSING_INPUT] return notes.get("transform") return None @@ -361,46 +365,54 @@ def _refusal_text(v, decision, at_gate, wire=None): text = "%s (%s)" % (reason, note) if reason and note else (note or reason) return text or default +def _g1_allow(v, decision, wire, name, words): + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + if wire in v.get("allow_silent_events", ()): + return "", 0 + if "allow" in words: + out = {"decision": words["allow"]} + if v.get("allow_context_key") and decision.outcome == ALLOW and value: + out[v["allow_context_key"]] = value + return _json.dumps(out), 0 + return "", 0 + +def _g1_transform(v, decision, name, words): + """None when the transform isn't representable here; `_g1` falls through to the block path.""" + if v.get("transform_grammar") == "hook_specific_tool_input": + return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0 + if decision.updated_input is None: + return None + if v.get("transform_grammar") == "top_level_updated_input": + out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input} + if decision.reason: + out["reason"] = decision.reason + return _json.dumps(out), 0 + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}}), 0 + def _g1(v, gate, decision, wire, name): """Block dialect: a top-level decision word, or silence/context where nothing is read.""" words = dict(v.get("words", {})) words.update(v.get("words_at", {}).get(wire, {})) at_context_event = wire in v.get("context_events", ()) if decision.outcome in (ALLOW, VOUCH, WARN): - value = _context_value(v, decision) - if at_context_event and value: - return _context_body(name, value) - if wire in v.get("allow_silent_events", ()): - return "", 0 - if "allow" in words: - out = {"decision": words["allow"]} - if v.get("allow_context_key") and decision.outcome == ALLOW and value: - out[v["allow_context_key"]] = value - return _json.dumps(out), 0 - return "", 0 + return _g1_allow(v, decision, wire, name, words) if decision.outcome == TRANSFORM and gate["honours_transform"]: - if v.get("transform_grammar") == "hook_specific_tool_input": - return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0 - if decision.updated_input is not None: - if v.get("transform_grammar") == "top_level_updated_input": - out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input} - if decision.reason: - out["reason"] = decision.reason - return _json.dumps(out), 0 - return _json.dumps( - {"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}} - ), 0 + transformed = _g1_transform(v, decision, name, words) + if transformed is not None: + return transformed if ( decision.outcome == ESCALATE and gate["honours_escalate"] and "escalate" in words # An escalate the dispatcher degraded a transform into is a block where the entry # names that degradation (antigravity): prompting would offer the unmodified call. - and not (degraded_from(decision) == TRANSFORM and "escalate_from_transform" in v.get("degrade_notes", {})) + and not (degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in v.get("degrade_notes", {})) ): - reason = decision.reason or _default_for(v, decision, True, wire) + reason = decision.reason or _default_for(v, decision, at_gate=True, wire=wire) return _json.dumps({"decision": words["escalate"], "reason": reason}), 0 - out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, False, wire)} + out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, at_gate=False, wire=wire)} if at_context_event and v.get("context_source") == "context" and decision.context: out["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": decision.context} return _json.dumps(out), 0 @@ -442,49 +454,45 @@ def _hook_dict(cfg, command): entry[key] = value return entry -def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed=True): - """The vendor's hooks-config fragment wiring `command` for these canonical events. +def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): + rules = [] + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + rule = {"event": name, "command": command} + if matcher and hook_entry["matcher"]: + rule["matcher"] = matcher + rules.append(rule) + return rules - `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the - entry says otherwise; a False installs an observer, not a gate. - """ +def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): + gates = cfg["verdicts"]["answer_events"] + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + entry = {"command": command} + if fail_closed and name in gates: + entry["failClosed"] = True + hooks.setdefault(name, []).append(entry) + return {"version": 1, "hooks": hooks} + +def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + hooks.setdefault(name, []).append({"command": command}) + extra = hook_entry.get("also_wires", {}).get(ev) + if extra: + hooks.setdefault(extra, []).append({"command": command}) + return {"hooks": hooks} + +def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] - reverse = hj_reverse(cfg) - wrapper = hook_entry["wrapper"] - if wrapper == "flat_list": - rules = [] - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: - rule["matcher"] = matcher - rules.append(rule) - return rules - if wrapper == "cursor": - gates = cfg["verdicts"]["answer_events"] - hooks = {} - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - entry = {"command": command} - if fail_closed and name in gates: - entry["failClosed"] = True - hooks.setdefault(name, []).append(entry) - return {"version": 1, "hooks": hooks} - if wrapper == "flat_entries": - hooks = {} - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - hooks.setdefault(name, []).append({"command": command}) - extra = hook_entry.get("also_wires", {}).get(ev) - if extra: - hooks.setdefault(extra, []).append({"command": command}) - return {"hooks": hooks} hooks = {} for ev in canonical_events: name = reverse.get(ev) @@ -498,6 +506,23 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed= return {hook_entry["group"]: hooks} return hooks if hook_entry.get("bare") else {"hooks": hooks} +def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_closed=True): + """The vendor's hooks-config fragment wiring `command` for these canonical events. + + `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the + entry says otherwise; a False installs an observer, not a gate. + """ + hook_entry = cfg["hook_entry"] + reverse = hj_reverse(cfg) + wrapper = hook_entry["wrapper"] + if wrapper == "flat_list": + return _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher) + if wrapper == "cursor": + return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) + if wrapper == "flat_entries": + return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + return _default_wrapper(cfg, reverse, canonical_events, command, matcher) + def antigravity_wire(raw): """Name the event from shape; ties go to PreToolUse so the gate stays a gate.""" if "terminationReason" in raw or "fullyIdle" in raw: @@ -506,7 +531,7 @@ def antigravity_wire(raw): return "PostToolUse" if "error" in raw else "PreToolUse" return None -def antigravity_claims(cfg, raw): +def antigravity_claims(_cfg, raw): """Structural: `conversationId` with `workspacePaths` is Antigravity's own envelope.""" if not isinstance(raw, dict): return False diff --git a/.chock/bin/claude_code.py b/.chock/bin/claude_code.py index 3e94a8b..b395abd 100755 --- a/.chock/bin/claude_code.py +++ b/.chock/bin/claude_code.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.2.0 -- bundle("claude_code"). Do not hand-edit, except the +# Generated by agentseam 0.2.1 -- 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. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.2.0) +# contract (agentseam 0.2.1) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -64,17 +64,17 @@ class Event: __slots__ = ( "agent", - "event", - "tool", "command", - "path", "content", + "cwd", + "event", "output", + "path", "prompt", + "raw", "session_id", + "tool", "tool_use_id", - "cwd", - "raw", ) def __init__( @@ -133,7 +133,7 @@ def __repr__(self): # pragma: no cover - debugging aid class Decision: """What a handler wants to happen. Adapters translate this to vendor dialect.""" - __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + __slots__ = ("context", "evidence", "outcome", "reason", "updated_input") #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) @@ -203,7 +203,7 @@ def tool_input_of(raw): if isinstance(raw, str) and raw[:1] == "{": try: parsed = _json.loads(raw) - except Exception: + except _json.JSONDecodeError: return {} if isinstance(parsed, dict): return parsed @@ -237,6 +237,24 @@ def _wire_name(cfg, raw): return name return None +def _rejected_by_markers(c, raw): + for marker in c.get("reject_markers", ()): + if marker in raw: + return True + for probe, markers in c.get("reject_markers_unless_probe", {}).items(): + if any(marker in raw for marker in markers) and not PROBES[probe](raw): + return True + return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) + +def _accepted_by_markers(c, raw, name): + accept = c.get("accept_markers", ()) + if not accept or any(marker in raw for marker in accept): + return True + return any( + name == event_name and all(key in raw for key in required) + for event_name, required in c.get("accept_when_all", {}).items() + ) + def hj_claims(cfg, raw): """True when this payload matches the entry's marker discipline.""" if not isinstance(raw, dict): @@ -249,22 +267,9 @@ def hj_claims(cfg, raw): return False if "client_types" in c and raw.get("client_type") not in c["client_types"]: return False - for marker in c.get("reject_markers", ()): - if marker in raw: - return False - for probe, markers in c.get("reject_markers_unless_probe", {}).items(): - if any(marker in raw for marker in markers) and not PROBES[probe](raw): - return False - for probe in c.get("reject_probes", ()): - if PROBES[probe](raw): - return False - accept = c.get("accept_markers", ()) - if accept and not any(marker in raw for marker in accept): - for event_name, required in c.get("accept_when_all", {}).items(): - if name == event_name and all(key in raw for key in required): - return True + if _rejected_by_markers(c, raw): return False - return True + return _accepted_by_markers(c, raw, name) def _segment(node, part): """One path segment: a dict key, or `key[N]` indexing the list under it.""" @@ -351,6 +356,10 @@ def hj_parse(cfg, raw, wire=None): raw=raw, ) +_ESCALATE_FROM_TRANSFORM = "escalate_from_transform" + +_TRANSFORM_MISSING_INPUT = "transform_missing_input" + def hj_reverse(cfg): """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" reverse = {} @@ -373,14 +382,14 @@ def _context_body(name, value): def _note_for(v, decision, at_gate, missing_input): notes = v.get("degrade_notes", {}) if decision.outcome == ESCALATE: - if degraded_from(decision) == TRANSFORM and "escalate_from_transform" in notes: - return notes["escalate_from_transform"] + if degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in notes: + return notes[_ESCALATE_FROM_TRANSFORM] if at_gate and "escalate_gate" in notes: return notes["escalate_gate"] return notes.get("escalate") if decision.outcome == TRANSFORM: - if missing_input and "transform_missing_input" in notes: - return notes["transform_missing_input"] + if missing_input and _TRANSFORM_MISSING_INPUT in notes: + return notes[_TRANSFORM_MISSING_INPUT] return notes.get("transform") return None @@ -407,50 +416,60 @@ def _refusal_text(v, decision, at_gate, wire=None): text = "%s (%s)" % (reason, note) if reason and note else (note or reason) return text or default +def _g1_allow(v, decision, wire, name, words): + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + if wire in v.get("allow_silent_events", ()): + return "", 0 + if "allow" in words: + out = {"decision": words["allow"]} + if v.get("allow_context_key") and decision.outcome == ALLOW and value: + out[v["allow_context_key"]] = value + return _json.dumps(out), 0 + return "", 0 + +def _g1_transform(v, decision, name, words): + """None when the transform isn't representable here; `_g1` falls through to the block path.""" + if v.get("transform_grammar") == "hook_specific_tool_input": + return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0 + if decision.updated_input is None: + return None + if v.get("transform_grammar") == "top_level_updated_input": + out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input} + if decision.reason: + out["reason"] = decision.reason + return _json.dumps(out), 0 + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}}), 0 + def _g1(v, gate, decision, wire, name): """Block dialect: a top-level decision word, or silence/context where nothing is read.""" words = dict(v.get("words", {})) words.update(v.get("words_at", {}).get(wire, {})) at_context_event = wire in v.get("context_events", ()) if decision.outcome in (ALLOW, VOUCH, WARN): - value = _context_value(v, decision) - if at_context_event and value: - return _context_body(name, value) - if wire in v.get("allow_silent_events", ()): - return "", 0 - if "allow" in words: - out = {"decision": words["allow"]} - if v.get("allow_context_key") and decision.outcome == ALLOW and value: - out[v["allow_context_key"]] = value - return _json.dumps(out), 0 - return "", 0 + return _g1_allow(v, decision, wire, name, words) if decision.outcome == TRANSFORM and gate["honours_transform"]: - if v.get("transform_grammar") == "hook_specific_tool_input": - return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0 - if decision.updated_input is not None: - if v.get("transform_grammar") == "top_level_updated_input": - out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input} - if decision.reason: - out["reason"] = decision.reason - return _json.dumps(out), 0 - return _json.dumps( - {"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}} - ), 0 + transformed = _g1_transform(v, decision, name, words) + if transformed is not None: + return transformed if ( decision.outcome == ESCALATE and gate["honours_escalate"] and "escalate" in words # An escalate the dispatcher degraded a transform into is a block where the entry # names that degradation (antigravity): prompting would offer the unmodified call. - and not (degraded_from(decision) == TRANSFORM and "escalate_from_transform" in v.get("degrade_notes", {})) + and not (degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in v.get("degrade_notes", {})) ): - reason = decision.reason or _default_for(v, decision, True, wire) + reason = decision.reason or _default_for(v, decision, at_gate=True, wire=wire) return _json.dumps({"decision": words["escalate"], "reason": reason}), 0 - out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, False, wire)} + out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, at_gate=False, wire=wire)} if at_context_event and v.get("context_source") == "context" and decision.context: out["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": decision.context} return _json.dumps(out), 0 +_PERMISSION_DECISION_REASON = "permissionDecisionReason" + def _g2(v, gate, decision, name): """Permission gate: `hookSpecificOutput.permissionDecision`, filled from the word table.""" if decision.outcome in (ALLOW, WARN): @@ -462,22 +481,22 @@ def _g2(v, gate, decision, name): return "", 0 out["permissionDecision"] = words["vouch"] if decision.reason: - out["permissionDecisionReason"] = decision.reason + out[_PERMISSION_DECISION_REASON] = decision.reason elif ( decision.outcome == TRANSFORM and gate["honours_transform"] - and not (decision.updated_input is None and "transform_missing_input" in v.get("degrade_notes", {})) + and not (decision.updated_input is None and _TRANSFORM_MISSING_INPUT in v.get("degrade_notes", {})) ): out["permissionDecision"] = words.get("transform", "allow") out["updatedInput"] = decision.updated_input if decision.reason: - out["permissionDecisionReason"] = decision.reason + out[_PERMISSION_DECISION_REASON] = decision.reason elif decision.outcome == ESCALATE and gate["honours_escalate"] and "escalate" in words: out["permissionDecision"] = words["escalate"] - out["permissionDecisionReason"] = decision.reason or _default_for(v, decision, True) + out[_PERMISSION_DECISION_REASON] = decision.reason or _default_for(v, decision, at_gate=True) else: out["permissionDecision"] = words.get("deny", "deny") - out["permissionDecisionReason"] = _refusal_text(v, decision, True) + out[_PERMISSION_DECISION_REASON] = _refusal_text(v, decision, at_gate=True) return _json.dumps({"hookSpecificOutput": out}), 0 def hj_respond(cfg, decision, event, wire=None): @@ -517,49 +536,45 @@ def _hook_dict(cfg, command): entry[key] = value return entry -def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed=True): - """The vendor's hooks-config fragment wiring `command` for these canonical events. +def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): + rules = [] + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + rule = {"event": name, "command": command} + if matcher and hook_entry["matcher"]: + rule["matcher"] = matcher + rules.append(rule) + return rules - `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the - entry says otherwise; a False installs an observer, not a gate. - """ +def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): + gates = cfg["verdicts"]["answer_events"] + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + entry = {"command": command} + if fail_closed and name in gates: + entry["failClosed"] = True + hooks.setdefault(name, []).append(entry) + return {"version": 1, "hooks": hooks} + +def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + hooks.setdefault(name, []).append({"command": command}) + extra = hook_entry.get("also_wires", {}).get(ev) + if extra: + hooks.setdefault(extra, []).append({"command": command}) + return {"hooks": hooks} + +def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] - reverse = hj_reverse(cfg) - wrapper = hook_entry["wrapper"] - if wrapper == "flat_list": - rules = [] - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: - rule["matcher"] = matcher - rules.append(rule) - return rules - if wrapper == "cursor": - gates = cfg["verdicts"]["answer_events"] - hooks = {} - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - entry = {"command": command} - if fail_closed and name in gates: - entry["failClosed"] = True - hooks.setdefault(name, []).append(entry) - return {"version": 1, "hooks": hooks} - if wrapper == "flat_entries": - hooks = {} - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - hooks.setdefault(name, []).append({"command": command}) - extra = hook_entry.get("also_wires", {}).get(ev) - if extra: - hooks.setdefault(extra, []).append({"command": command}) - return {"hooks": hooks} hooks = {} for ev in canonical_events: name = reverse.get(ev) @@ -573,13 +588,30 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed= return {hook_entry["group"]: hooks} return hooks if hook_entry.get("bare") else {"hooks": hooks} +def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_closed=True): + """The vendor's hooks-config fragment wiring `command` for these canonical events. + + `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the + entry says otherwise; a False installs an observer, not a gate. + """ + hook_entry = cfg["hook_entry"] + reverse = hj_reverse(cfg) + wrapper = hook_entry["wrapper"] + if wrapper == "flat_list": + return _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher) + if wrapper == "cursor": + return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) + if wrapper == "flat_entries": + return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + return _default_wrapper(cfg, reverse, canonical_events, command, matcher) + # ------------------------------------------------------------------------------ # claude_code vendor config + engine binding 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-08-28', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'live-run', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'live-run', 'date': '2026-08-28', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'live-run', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'live-run', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'live-run', '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', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'live-run', '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', '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, '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-08-28', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'live-run', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'live-run', 'date': '2026-08-28', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'live-run', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'live-run', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'live-run', 'date': '2026-08-28', '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-08-28', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'live-run', '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', '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'}}} def claims(raw): diff --git a/.chock/bin/codex_cli.py b/.chock/bin/codex_cli.py index 3d67533..52d2ce9 100755 --- a/.chock/bin/codex_cli.py +++ b/.chock/bin/codex_cli.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.2.0 -- bundle("codex_cli"). Do not hand-edit, except the +# Generated by agentseam 0.2.1 -- 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. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.2.0) +# contract (agentseam 0.2.1) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -64,17 +64,17 @@ class Event: __slots__ = ( "agent", - "event", - "tool", "command", - "path", "content", + "cwd", + "event", "output", + "path", "prompt", + "raw", "session_id", + "tool", "tool_use_id", - "cwd", - "raw", ) def __init__( @@ -133,7 +133,7 @@ def __repr__(self): # pragma: no cover - debugging aid class Decision: """What a handler wants to happen. Adapters translate this to vendor dialect.""" - __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + __slots__ = ("context", "evidence", "outcome", "reason", "updated_input") #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) @@ -203,7 +203,7 @@ def tool_input_of(raw): if isinstance(raw, str) and raw[:1] == "{": try: parsed = _json.loads(raw) - except Exception: + except _json.JSONDecodeError: return {} if isinstance(parsed, dict): return parsed @@ -224,6 +224,24 @@ def _wire_name(cfg, raw): return name return None +def _rejected_by_markers(c, raw): + for marker in c.get("reject_markers", ()): + if marker in raw: + return True + for probe, markers in c.get("reject_markers_unless_probe", {}).items(): + if any(marker in raw for marker in markers) and not PROBES[probe](raw): + return True + return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) + +def _accepted_by_markers(c, raw, name): + accept = c.get("accept_markers", ()) + if not accept or any(marker in raw for marker in accept): + return True + return any( + name == event_name and all(key in raw for key in required) + for event_name, required in c.get("accept_when_all", {}).items() + ) + def hj_claims(cfg, raw): """True when this payload matches the entry's marker discipline.""" if not isinstance(raw, dict): @@ -236,22 +254,9 @@ def hj_claims(cfg, raw): return False if "client_types" in c and raw.get("client_type") not in c["client_types"]: return False - for marker in c.get("reject_markers", ()): - if marker in raw: - return False - for probe, markers in c.get("reject_markers_unless_probe", {}).items(): - if any(marker in raw for marker in markers) and not PROBES[probe](raw): - return False - for probe in c.get("reject_probes", ()): - if PROBES[probe](raw): - return False - accept = c.get("accept_markers", ()) - if accept and not any(marker in raw for marker in accept): - for event_name, required in c.get("accept_when_all", {}).items(): - if name == event_name and all(key in raw for key in required): - return True + if _rejected_by_markers(c, raw): return False - return True + return _accepted_by_markers(c, raw, name) def _segment(node, part): """One path segment: a dict key, or `key[N]` indexing the list under it.""" @@ -338,6 +343,10 @@ def hj_parse(cfg, raw, wire=None): raw=raw, ) +_ESCALATE_FROM_TRANSFORM = "escalate_from_transform" + +_TRANSFORM_MISSING_INPUT = "transform_missing_input" + def hj_reverse(cfg): """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" reverse = {} @@ -360,14 +369,14 @@ def _context_body(name, value): def _note_for(v, decision, at_gate, missing_input): notes = v.get("degrade_notes", {}) if decision.outcome == ESCALATE: - if degraded_from(decision) == TRANSFORM and "escalate_from_transform" in notes: - return notes["escalate_from_transform"] + if degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in notes: + return notes[_ESCALATE_FROM_TRANSFORM] if at_gate and "escalate_gate" in notes: return notes["escalate_gate"] return notes.get("escalate") if decision.outcome == TRANSFORM: - if missing_input and "transform_missing_input" in notes: - return notes["transform_missing_input"] + if missing_input and _TRANSFORM_MISSING_INPUT in notes: + return notes[_TRANSFORM_MISSING_INPUT] return notes.get("transform") return None @@ -394,50 +403,60 @@ def _refusal_text(v, decision, at_gate, wire=None): text = "%s (%s)" % (reason, note) if reason and note else (note or reason) return text or default +def _g1_allow(v, decision, wire, name, words): + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + if wire in v.get("allow_silent_events", ()): + return "", 0 + if "allow" in words: + out = {"decision": words["allow"]} + if v.get("allow_context_key") and decision.outcome == ALLOW and value: + out[v["allow_context_key"]] = value + return _json.dumps(out), 0 + return "", 0 + +def _g1_transform(v, decision, name, words): + """None when the transform isn't representable here; `_g1` falls through to the block path.""" + if v.get("transform_grammar") == "hook_specific_tool_input": + return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0 + if decision.updated_input is None: + return None + if v.get("transform_grammar") == "top_level_updated_input": + out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input} + if decision.reason: + out["reason"] = decision.reason + return _json.dumps(out), 0 + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}}), 0 + def _g1(v, gate, decision, wire, name): """Block dialect: a top-level decision word, or silence/context where nothing is read.""" words = dict(v.get("words", {})) words.update(v.get("words_at", {}).get(wire, {})) at_context_event = wire in v.get("context_events", ()) if decision.outcome in (ALLOW, VOUCH, WARN): - value = _context_value(v, decision) - if at_context_event and value: - return _context_body(name, value) - if wire in v.get("allow_silent_events", ()): - return "", 0 - if "allow" in words: - out = {"decision": words["allow"]} - if v.get("allow_context_key") and decision.outcome == ALLOW and value: - out[v["allow_context_key"]] = value - return _json.dumps(out), 0 - return "", 0 + return _g1_allow(v, decision, wire, name, words) if decision.outcome == TRANSFORM and gate["honours_transform"]: - if v.get("transform_grammar") == "hook_specific_tool_input": - return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0 - if decision.updated_input is not None: - if v.get("transform_grammar") == "top_level_updated_input": - out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input} - if decision.reason: - out["reason"] = decision.reason - return _json.dumps(out), 0 - return _json.dumps( - {"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}} - ), 0 + transformed = _g1_transform(v, decision, name, words) + if transformed is not None: + return transformed if ( decision.outcome == ESCALATE and gate["honours_escalate"] and "escalate" in words # An escalate the dispatcher degraded a transform into is a block where the entry # names that degradation (antigravity): prompting would offer the unmodified call. - and not (degraded_from(decision) == TRANSFORM and "escalate_from_transform" in v.get("degrade_notes", {})) + and not (degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in v.get("degrade_notes", {})) ): - reason = decision.reason or _default_for(v, decision, True, wire) + reason = decision.reason or _default_for(v, decision, at_gate=True, wire=wire) return _json.dumps({"decision": words["escalate"], "reason": reason}), 0 - out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, False, wire)} + out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, at_gate=False, wire=wire)} if at_context_event and v.get("context_source") == "context" and decision.context: out["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": decision.context} return _json.dumps(out), 0 +_PERMISSION_DECISION_REASON = "permissionDecisionReason" + def _g2(v, gate, decision, name): """Permission gate: `hookSpecificOutput.permissionDecision`, filled from the word table.""" if decision.outcome in (ALLOW, WARN): @@ -449,22 +468,22 @@ def _g2(v, gate, decision, name): return "", 0 out["permissionDecision"] = words["vouch"] if decision.reason: - out["permissionDecisionReason"] = decision.reason + out[_PERMISSION_DECISION_REASON] = decision.reason elif ( decision.outcome == TRANSFORM and gate["honours_transform"] - and not (decision.updated_input is None and "transform_missing_input" in v.get("degrade_notes", {})) + and not (decision.updated_input is None and _TRANSFORM_MISSING_INPUT in v.get("degrade_notes", {})) ): out["permissionDecision"] = words.get("transform", "allow") out["updatedInput"] = decision.updated_input if decision.reason: - out["permissionDecisionReason"] = decision.reason + out[_PERMISSION_DECISION_REASON] = decision.reason elif decision.outcome == ESCALATE and gate["honours_escalate"] and "escalate" in words: out["permissionDecision"] = words["escalate"] - out["permissionDecisionReason"] = decision.reason or _default_for(v, decision, True) + out[_PERMISSION_DECISION_REASON] = decision.reason or _default_for(v, decision, at_gate=True) else: out["permissionDecision"] = words.get("deny", "deny") - out["permissionDecisionReason"] = _refusal_text(v, decision, True) + out[_PERMISSION_DECISION_REASON] = _refusal_text(v, decision, at_gate=True) return _json.dumps({"hookSpecificOutput": out}), 0 def hj_respond(cfg, decision, event, wire=None): @@ -504,49 +523,45 @@ def _hook_dict(cfg, command): entry[key] = value return entry -def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed=True): - """The vendor's hooks-config fragment wiring `command` for these canonical events. +def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): + rules = [] + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + rule = {"event": name, "command": command} + if matcher and hook_entry["matcher"]: + rule["matcher"] = matcher + rules.append(rule) + return rules - `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the - entry says otherwise; a False installs an observer, not a gate. - """ +def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): + gates = cfg["verdicts"]["answer_events"] + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + entry = {"command": command} + if fail_closed and name in gates: + entry["failClosed"] = True + hooks.setdefault(name, []).append(entry) + return {"version": 1, "hooks": hooks} + +def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + hooks.setdefault(name, []).append({"command": command}) + extra = hook_entry.get("also_wires", {}).get(ev) + if extra: + hooks.setdefault(extra, []).append({"command": command}) + return {"hooks": hooks} + +def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] - reverse = hj_reverse(cfg) - wrapper = hook_entry["wrapper"] - if wrapper == "flat_list": - rules = [] - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: - rule["matcher"] = matcher - rules.append(rule) - return rules - if wrapper == "cursor": - gates = cfg["verdicts"]["answer_events"] - hooks = {} - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - entry = {"command": command} - if fail_closed and name in gates: - entry["failClosed"] = True - hooks.setdefault(name, []).append(entry) - return {"version": 1, "hooks": hooks} - if wrapper == "flat_entries": - hooks = {} - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - hooks.setdefault(name, []).append({"command": command}) - extra = hook_entry.get("also_wires", {}).get(ev) - if extra: - hooks.setdefault(extra, []).append({"command": command}) - return {"hooks": hooks} hooks = {} for ev in canonical_events: name = reverse.get(ev) @@ -560,13 +575,30 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed= return {hook_entry["group"]: hooks} return hooks if hook_entry.get("bare") else {"hooks": hooks} +def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_closed=True): + """The vendor's hooks-config fragment wiring `command` for these canonical events. + + `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the + entry says otherwise; a False installs an observer, not a gate. + """ + hook_entry = cfg["hook_entry"] + reverse = hj_reverse(cfg) + wrapper = hook_entry["wrapper"] + if wrapper == "flat_list": + return _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher) + if wrapper == "cursor": + return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) + if wrapper == "flat_entries": + return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + return _default_wrapper(cfg, reverse, canonical_events, command, matcher) + # ------------------------------------------------------------------------------ # codex_cli vendor config + engine binding 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_config.py::test_entries_match_recount'}, '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': {}, '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'}, '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'}}} def claims(raw): diff --git a/.chock/bin/cursor.py b/.chock/bin/cursor.py index 767e653..7e38d7f 100755 --- a/.chock/bin/cursor.py +++ b/.chock/bin/cursor.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.2.0 -- bundle("cursor"). Do not hand-edit, except the +# Generated by agentseam 0.2.1 -- 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. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.2.0) +# contract (agentseam 0.2.1) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -64,17 +64,17 @@ class Event: __slots__ = ( "agent", - "event", - "tool", "command", - "path", "content", + "cwd", + "event", "output", + "path", "prompt", + "raw", "session_id", + "tool", "tool_use_id", - "cwd", - "raw", ) def __init__( @@ -133,7 +133,7 @@ def __repr__(self): # pragma: no cover - debugging aid class Decision: """What a handler wants to happen. Adapters translate this to vendor dialect.""" - __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + __slots__ = ("context", "evidence", "outcome", "reason", "updated_input") #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) @@ -203,7 +203,7 @@ def tool_input_of(raw): if isinstance(raw, str) and raw[:1] == "{": try: parsed = _json.loads(raw) - except Exception: + except _json.JSONDecodeError: return {} if isinstance(parsed, dict): return parsed @@ -305,6 +305,8 @@ def hj_parse(cfg, raw, wire=None): raw=raw, ) +_ESCALATE_FROM_TRANSFORM = "escalate_from_transform" + def hj_reverse(cfg): """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" reverse = {} @@ -325,49 +327,45 @@ def _hook_dict(cfg, command): entry[key] = value return entry -def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed=True): - """The vendor's hooks-config fragment wiring `command` for these canonical events. +def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): + rules = [] + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + rule = {"event": name, "command": command} + if matcher and hook_entry["matcher"]: + rule["matcher"] = matcher + rules.append(rule) + return rules - `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the - entry says otherwise; a False installs an observer, not a gate. - """ +def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): + gates = cfg["verdicts"]["answer_events"] + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + entry = {"command": command} + if fail_closed and name in gates: + entry["failClosed"] = True + hooks.setdefault(name, []).append(entry) + return {"version": 1, "hooks": hooks} + +def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + hooks.setdefault(name, []).append({"command": command}) + extra = hook_entry.get("also_wires", {}).get(ev) + if extra: + hooks.setdefault(extra, []).append({"command": command}) + return {"hooks": hooks} + +def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] - reverse = hj_reverse(cfg) - wrapper = hook_entry["wrapper"] - if wrapper == "flat_list": - rules = [] - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: - rule["matcher"] = matcher - rules.append(rule) - return rules - if wrapper == "cursor": - gates = cfg["verdicts"]["answer_events"] - hooks = {} - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - entry = {"command": command} - if fail_closed and name in gates: - entry["failClosed"] = True - hooks.setdefault(name, []).append(entry) - return {"version": 1, "hooks": hooks} - if wrapper == "flat_entries": - hooks = {} - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - hooks.setdefault(name, []).append({"command": command}) - extra = hook_entry.get("also_wires", {}).get(ev) - if extra: - hooks.setdefault(extra, []).append({"command": command}) - return {"hooks": hooks} hooks = {} for ev in canonical_events: name = reverse.get(ev) @@ -381,6 +379,23 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed= return {hook_entry["group"]: hooks} return hooks if hook_entry.get("bare") else {"hooks": hooks} +def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_closed=True): + """The vendor's hooks-config fragment wiring `command` for these canonical events. + + `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the + entry says otherwise; a False installs an observer, not a gate. + """ + hook_entry = cfg["hook_entry"] + reverse = hj_reverse(cfg) + wrapper = hook_entry["wrapper"] + if wrapper == "flat_list": + return _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher) + if wrapper == "cursor": + return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) + if wrapper == "flat_entries": + return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + return _default_wrapper(cfg, reverse, canonical_events, command, matcher) + _AMBIGUOUS_NAMES = ( "preToolUse", "postToolUse", @@ -432,6 +447,35 @@ def _wire_of(cfg, event): return name return event.tool if event.tool in cfg["events"] else cfg["verdicts"].get("default_wire_event") +def _flag_payload(v, decision, name): + """POST_TOOL/TOOL_FAILURE: a flag can only be raised as additional context, never blocked.""" + if decision.outcome not in (DENY, ESCALATE): + return "", 0 + note = v["flag_note"] % (name, decision.reason or v["flag_note_default"]) + return _json.dumps({"additional_context": note}), 0 + +def _prompt_submit_payload(decision): + payload = {"continue": decision.outcome not in (DENY, ESCALATE, TRANSFORM)} + if decision.reason: + payload["user_message"] = decision.reason + return _json.dumps(payload), 0 + +def _gate_payload(words, notes, gate, decision, name): + """The PRE_TOOL gate's (permission, reason) pair, before the shared trailing message rule.""" + reason = decision.reason + if decision.outcome == TRANSFORM: + if gate["honours_transform"] and decision.updated_input is not None: + return {"permission": words["allow"], "updated_input": decision.updated_input}, reason + return {"permission": words["block"]}, _because(reason, notes["transform"]) + if decision.outcome == DENY: + return {"permission": words["block"]}, reason + if decision.outcome == ESCALATE: + if gate["honours_escalate"]: + return {"permission": words["escalate"]}, reason + note = notes[_ESCALATE_FROM_TRANSFORM] if degraded_from(decision) == TRANSFORM else notes["escalate"] + return {"permission": words["block"]}, _because(reason, note % name) + return {"permission": words["allow"]}, reason + def cursor_respond(cfg, decision, event): v = cfg["verdicts"] name = _wire_of(cfg, event) @@ -439,46 +483,17 @@ def cursor_respond(cfg, decision, event): if canonical == FILE_CHANGED: return "", 0 - if canonical in (POST_TOOL, TOOL_FAILURE): - if decision.outcome in (DENY, ESCALATE): - note = v["flag_note"] % (name, decision.reason or v["flag_note_default"]) - return _json.dumps({"additional_context": note}), 0 - return "", 0 - + return _flag_payload(v, decision, name) if canonical == PROMPT_SUBMIT: - payload = {"continue": decision.outcome not in (DENY, ESCALATE, TRANSFORM)} - if decision.reason: - payload["user_message"] = decision.reason - return _json.dumps(payload), 0 + return _prompt_submit_payload(decision) gate = v["gates"].get(name) if gate is None or canonical != PRE_TOOL: return "", 0 - words = v["words"] - notes = v["degrade_notes"] - reason = decision.reason - - if decision.outcome == TRANSFORM: - if gate["honours_transform"] and decision.updated_input is not None: - payload = {"permission": words["allow"], "updated_input": decision.updated_input} - else: - payload = {"permission": words["block"]} - reason = _because(reason, notes["transform"]) - elif decision.outcome == DENY: - payload = {"permission": words["block"]} - elif decision.outcome == ESCALATE: - if gate["honours_escalate"]: - payload = {"permission": words["escalate"]} - else: - note = notes["escalate_from_transform"] if degraded_from(decision) == TRANSFORM else notes["escalate"] - payload = {"permission": words["block"]} - reason = _because(reason, note % name) - else: - payload = {"permission": words["allow"]} - - if reason and payload["permission"] != words["allow"]: + payload, reason = _gate_payload(v["words"], v["degrade_notes"], gate, decision, name) + if reason and payload["permission"] != v["words"]["allow"]: payload["user_message"] = reason payload["agent_message"] = reason return _json.dumps(payload), 0 diff --git a/.chock/bin/devin.py b/.chock/bin/devin.py index 24a7ffd..c2e7a06 100755 --- a/.chock/bin/devin.py +++ b/.chock/bin/devin.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.2.0 -- bundle("devin"). Do not hand-edit, except the +# Generated by agentseam 0.2.1 -- 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. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.2.0) +# contract (agentseam 0.2.1) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -64,17 +64,17 @@ class Event: __slots__ = ( "agent", - "event", - "tool", "command", - "path", "content", + "cwd", + "event", "output", + "path", "prompt", + "raw", "session_id", + "tool", "tool_use_id", - "cwd", - "raw", ) def __init__( @@ -133,7 +133,7 @@ def __repr__(self): # pragma: no cover - debugging aid class Decision: """What a handler wants to happen. Adapters translate this to vendor dialect.""" - __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + __slots__ = ("context", "evidence", "outcome", "reason", "updated_input") #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) @@ -203,7 +203,7 @@ def tool_input_of(raw): if isinstance(raw, str) and raw[:1] == "{": try: parsed = _json.loads(raw) - except Exception: + except _json.JSONDecodeError: return {} if isinstance(parsed, dict): return parsed @@ -237,6 +237,24 @@ def _wire_name(cfg, raw): return name return None +def _rejected_by_markers(c, raw): + for marker in c.get("reject_markers", ()): + if marker in raw: + return True + for probe, markers in c.get("reject_markers_unless_probe", {}).items(): + if any(marker in raw for marker in markers) and not PROBES[probe](raw): + return True + return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) + +def _accepted_by_markers(c, raw, name): + accept = c.get("accept_markers", ()) + if not accept or any(marker in raw for marker in accept): + return True + return any( + name == event_name and all(key in raw for key in required) + for event_name, required in c.get("accept_when_all", {}).items() + ) + def hj_claims(cfg, raw): """True when this payload matches the entry's marker discipline.""" if not isinstance(raw, dict): @@ -249,22 +267,9 @@ def hj_claims(cfg, raw): return False if "client_types" in c and raw.get("client_type") not in c["client_types"]: return False - for marker in c.get("reject_markers", ()): - if marker in raw: - return False - for probe, markers in c.get("reject_markers_unless_probe", {}).items(): - if any(marker in raw for marker in markers) and not PROBES[probe](raw): - return False - for probe in c.get("reject_probes", ()): - if PROBES[probe](raw): - return False - accept = c.get("accept_markers", ()) - if accept and not any(marker in raw for marker in accept): - for event_name, required in c.get("accept_when_all", {}).items(): - if name == event_name and all(key in raw for key in required): - return True + if _rejected_by_markers(c, raw): return False - return True + return _accepted_by_markers(c, raw, name) def _segment(node, part): """One path segment: a dict key, or `key[N]` indexing the list under it.""" @@ -351,6 +356,10 @@ def hj_parse(cfg, raw, wire=None): raw=raw, ) +_ESCALATE_FROM_TRANSFORM = "escalate_from_transform" + +_TRANSFORM_MISSING_INPUT = "transform_missing_input" + def hj_reverse(cfg): """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" reverse = {} @@ -373,14 +382,14 @@ def _context_body(name, value): def _note_for(v, decision, at_gate, missing_input): notes = v.get("degrade_notes", {}) if decision.outcome == ESCALATE: - if degraded_from(decision) == TRANSFORM and "escalate_from_transform" in notes: - return notes["escalate_from_transform"] + if degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in notes: + return notes[_ESCALATE_FROM_TRANSFORM] if at_gate and "escalate_gate" in notes: return notes["escalate_gate"] return notes.get("escalate") if decision.outcome == TRANSFORM: - if missing_input and "transform_missing_input" in notes: - return notes["transform_missing_input"] + if missing_input and _TRANSFORM_MISSING_INPUT in notes: + return notes[_TRANSFORM_MISSING_INPUT] return notes.get("transform") return None @@ -407,46 +416,54 @@ def _refusal_text(v, decision, at_gate, wire=None): text = "%s (%s)" % (reason, note) if reason and note else (note or reason) return text or default +def _g1_allow(v, decision, wire, name, words): + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + if wire in v.get("allow_silent_events", ()): + return "", 0 + if "allow" in words: + out = {"decision": words["allow"]} + if v.get("allow_context_key") and decision.outcome == ALLOW and value: + out[v["allow_context_key"]] = value + return _json.dumps(out), 0 + return "", 0 + +def _g1_transform(v, decision, name, words): + """None when the transform isn't representable here; `_g1` falls through to the block path.""" + if v.get("transform_grammar") == "hook_specific_tool_input": + return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0 + if decision.updated_input is None: + return None + if v.get("transform_grammar") == "top_level_updated_input": + out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input} + if decision.reason: + out["reason"] = decision.reason + return _json.dumps(out), 0 + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}}), 0 + def _g1(v, gate, decision, wire, name): """Block dialect: a top-level decision word, or silence/context where nothing is read.""" words = dict(v.get("words", {})) words.update(v.get("words_at", {}).get(wire, {})) at_context_event = wire in v.get("context_events", ()) if decision.outcome in (ALLOW, VOUCH, WARN): - value = _context_value(v, decision) - if at_context_event and value: - return _context_body(name, value) - if wire in v.get("allow_silent_events", ()): - return "", 0 - if "allow" in words: - out = {"decision": words["allow"]} - if v.get("allow_context_key") and decision.outcome == ALLOW and value: - out[v["allow_context_key"]] = value - return _json.dumps(out), 0 - return "", 0 + return _g1_allow(v, decision, wire, name, words) if decision.outcome == TRANSFORM and gate["honours_transform"]: - if v.get("transform_grammar") == "hook_specific_tool_input": - return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0 - if decision.updated_input is not None: - if v.get("transform_grammar") == "top_level_updated_input": - out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input} - if decision.reason: - out["reason"] = decision.reason - return _json.dumps(out), 0 - return _json.dumps( - {"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}} - ), 0 + transformed = _g1_transform(v, decision, name, words) + if transformed is not None: + return transformed if ( decision.outcome == ESCALATE and gate["honours_escalate"] and "escalate" in words # An escalate the dispatcher degraded a transform into is a block where the entry # names that degradation (antigravity): prompting would offer the unmodified call. - and not (degraded_from(decision) == TRANSFORM and "escalate_from_transform" in v.get("degrade_notes", {})) + and not (degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in v.get("degrade_notes", {})) ): - reason = decision.reason or _default_for(v, decision, True, wire) + reason = decision.reason or _default_for(v, decision, at_gate=True, wire=wire) return _json.dumps({"decision": words["escalate"], "reason": reason}), 0 - out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, False, wire)} + out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, at_gate=False, wire=wire)} if at_context_event and v.get("context_source") == "context" and decision.context: out["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": decision.context} return _json.dumps(out), 0 @@ -488,49 +505,45 @@ def _hook_dict(cfg, command): entry[key] = value return entry -def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed=True): - """The vendor's hooks-config fragment wiring `command` for these canonical events. +def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): + rules = [] + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + rule = {"event": name, "command": command} + if matcher and hook_entry["matcher"]: + rule["matcher"] = matcher + rules.append(rule) + return rules - `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the - entry says otherwise; a False installs an observer, not a gate. - """ +def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): + gates = cfg["verdicts"]["answer_events"] + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + entry = {"command": command} + if fail_closed and name in gates: + entry["failClosed"] = True + hooks.setdefault(name, []).append(entry) + return {"version": 1, "hooks": hooks} + +def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + hooks.setdefault(name, []).append({"command": command}) + extra = hook_entry.get("also_wires", {}).get(ev) + if extra: + hooks.setdefault(extra, []).append({"command": command}) + return {"hooks": hooks} + +def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] - reverse = hj_reverse(cfg) - wrapper = hook_entry["wrapper"] - if wrapper == "flat_list": - rules = [] - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: - rule["matcher"] = matcher - rules.append(rule) - return rules - if wrapper == "cursor": - gates = cfg["verdicts"]["answer_events"] - hooks = {} - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - entry = {"command": command} - if fail_closed and name in gates: - entry["failClosed"] = True - hooks.setdefault(name, []).append(entry) - return {"version": 1, "hooks": hooks} - if wrapper == "flat_entries": - hooks = {} - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - hooks.setdefault(name, []).append({"command": command}) - extra = hook_entry.get("also_wires", {}).get(ev) - if extra: - hooks.setdefault(extra, []).append({"command": command}) - return {"hooks": hooks} hooks = {} for ev in canonical_events: name = reverse.get(ev) @@ -544,6 +557,23 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed= return {hook_entry["group"]: hooks} return hooks if hook_entry.get("bare") else {"hooks": hooks} +def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_closed=True): + """The vendor's hooks-config fragment wiring `command` for these canonical events. + + `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the + entry says otherwise; a False installs an observer, not a gate. + """ + hook_entry = cfg["hook_entry"] + reverse = hj_reverse(cfg) + wrapper = hook_entry["wrapper"] + if wrapper == "flat_list": + return _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher) + if wrapper == "cursor": + return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) + if wrapper == "flat_entries": + return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + return _default_wrapper(cfg, reverse, canonical_events, command, matcher) + # ------------------------------------------------------------------------------ # devin vendor config + engine binding diff --git a/.chock/bin/gemini_cli.py b/.chock/bin/gemini_cli.py index b95eeb1..c66a9e1 100755 --- a/.chock/bin/gemini_cli.py +++ b/.chock/bin/gemini_cli.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.2.0 -- bundle("gemini_cli"). Do not hand-edit, except the +# Generated by agentseam 0.2.1 -- 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. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.2.0) +# contract (agentseam 0.2.1) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -64,17 +64,17 @@ class Event: __slots__ = ( "agent", - "event", - "tool", "command", - "path", "content", + "cwd", + "event", "output", + "path", "prompt", + "raw", "session_id", + "tool", "tool_use_id", - "cwd", - "raw", ) def __init__( @@ -133,7 +133,7 @@ def __repr__(self): # pragma: no cover - debugging aid class Decision: """What a handler wants to happen. Adapters translate this to vendor dialect.""" - __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + __slots__ = ("context", "evidence", "outcome", "reason", "updated_input") #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) @@ -203,7 +203,7 @@ def tool_input_of(raw): if isinstance(raw, str) and raw[:1] == "{": try: parsed = _json.loads(raw) - except Exception: + except _json.JSONDecodeError: return {} if isinstance(parsed, dict): return parsed @@ -237,6 +237,24 @@ def _wire_name(cfg, raw): return name return None +def _rejected_by_markers(c, raw): + for marker in c.get("reject_markers", ()): + if marker in raw: + return True + for probe, markers in c.get("reject_markers_unless_probe", {}).items(): + if any(marker in raw for marker in markers) and not PROBES[probe](raw): + return True + return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) + +def _accepted_by_markers(c, raw, name): + accept = c.get("accept_markers", ()) + if not accept or any(marker in raw for marker in accept): + return True + return any( + name == event_name and all(key in raw for key in required) + for event_name, required in c.get("accept_when_all", {}).items() + ) + def hj_claims(cfg, raw): """True when this payload matches the entry's marker discipline.""" if not isinstance(raw, dict): @@ -249,22 +267,9 @@ def hj_claims(cfg, raw): return False if "client_types" in c and raw.get("client_type") not in c["client_types"]: return False - for marker in c.get("reject_markers", ()): - if marker in raw: - return False - for probe, markers in c.get("reject_markers_unless_probe", {}).items(): - if any(marker in raw for marker in markers) and not PROBES[probe](raw): - return False - for probe in c.get("reject_probes", ()): - if PROBES[probe](raw): - return False - accept = c.get("accept_markers", ()) - if accept and not any(marker in raw for marker in accept): - for event_name, required in c.get("accept_when_all", {}).items(): - if name == event_name and all(key in raw for key in required): - return True + if _rejected_by_markers(c, raw): return False - return True + return _accepted_by_markers(c, raw, name) def _segment(node, part): """One path segment: a dict key, or `key[N]` indexing the list under it.""" @@ -351,6 +356,10 @@ def hj_parse(cfg, raw, wire=None): raw=raw, ) +_ESCALATE_FROM_TRANSFORM = "escalate_from_transform" + +_TRANSFORM_MISSING_INPUT = "transform_missing_input" + def hj_reverse(cfg): """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" reverse = {} @@ -373,14 +382,14 @@ def _context_body(name, value): def _note_for(v, decision, at_gate, missing_input): notes = v.get("degrade_notes", {}) if decision.outcome == ESCALATE: - if degraded_from(decision) == TRANSFORM and "escalate_from_transform" in notes: - return notes["escalate_from_transform"] + if degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in notes: + return notes[_ESCALATE_FROM_TRANSFORM] if at_gate and "escalate_gate" in notes: return notes["escalate_gate"] return notes.get("escalate") if decision.outcome == TRANSFORM: - if missing_input and "transform_missing_input" in notes: - return notes["transform_missing_input"] + if missing_input and _TRANSFORM_MISSING_INPUT in notes: + return notes[_TRANSFORM_MISSING_INPUT] return notes.get("transform") return None @@ -407,46 +416,54 @@ def _refusal_text(v, decision, at_gate, wire=None): text = "%s (%s)" % (reason, note) if reason and note else (note or reason) return text or default +def _g1_allow(v, decision, wire, name, words): + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + if wire in v.get("allow_silent_events", ()): + return "", 0 + if "allow" in words: + out = {"decision": words["allow"]} + if v.get("allow_context_key") and decision.outcome == ALLOW and value: + out[v["allow_context_key"]] = value + return _json.dumps(out), 0 + return "", 0 + +def _g1_transform(v, decision, name, words): + """None when the transform isn't representable here; `_g1` falls through to the block path.""" + if v.get("transform_grammar") == "hook_specific_tool_input": + return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0 + if decision.updated_input is None: + return None + if v.get("transform_grammar") == "top_level_updated_input": + out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input} + if decision.reason: + out["reason"] = decision.reason + return _json.dumps(out), 0 + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}}), 0 + def _g1(v, gate, decision, wire, name): """Block dialect: a top-level decision word, or silence/context where nothing is read.""" words = dict(v.get("words", {})) words.update(v.get("words_at", {}).get(wire, {})) at_context_event = wire in v.get("context_events", ()) if decision.outcome in (ALLOW, VOUCH, WARN): - value = _context_value(v, decision) - if at_context_event and value: - return _context_body(name, value) - if wire in v.get("allow_silent_events", ()): - return "", 0 - if "allow" in words: - out = {"decision": words["allow"]} - if v.get("allow_context_key") and decision.outcome == ALLOW and value: - out[v["allow_context_key"]] = value - return _json.dumps(out), 0 - return "", 0 + return _g1_allow(v, decision, wire, name, words) if decision.outcome == TRANSFORM and gate["honours_transform"]: - if v.get("transform_grammar") == "hook_specific_tool_input": - return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0 - if decision.updated_input is not None: - if v.get("transform_grammar") == "top_level_updated_input": - out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input} - if decision.reason: - out["reason"] = decision.reason - return _json.dumps(out), 0 - return _json.dumps( - {"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}} - ), 0 + transformed = _g1_transform(v, decision, name, words) + if transformed is not None: + return transformed if ( decision.outcome == ESCALATE and gate["honours_escalate"] and "escalate" in words # An escalate the dispatcher degraded a transform into is a block where the entry # names that degradation (antigravity): prompting would offer the unmodified call. - and not (degraded_from(decision) == TRANSFORM and "escalate_from_transform" in v.get("degrade_notes", {})) + and not (degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in v.get("degrade_notes", {})) ): - reason = decision.reason or _default_for(v, decision, True, wire) + reason = decision.reason or _default_for(v, decision, at_gate=True, wire=wire) return _json.dumps({"decision": words["escalate"], "reason": reason}), 0 - out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, False, wire)} + out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, at_gate=False, wire=wire)} if at_context_event and v.get("context_source") == "context" and decision.context: out["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": decision.context} return _json.dumps(out), 0 @@ -488,49 +505,45 @@ def _hook_dict(cfg, command): entry[key] = value return entry -def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed=True): - """The vendor's hooks-config fragment wiring `command` for these canonical events. +def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): + rules = [] + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + rule = {"event": name, "command": command} + if matcher and hook_entry["matcher"]: + rule["matcher"] = matcher + rules.append(rule) + return rules - `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the - entry says otherwise; a False installs an observer, not a gate. - """ +def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): + gates = cfg["verdicts"]["answer_events"] + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + entry = {"command": command} + if fail_closed and name in gates: + entry["failClosed"] = True + hooks.setdefault(name, []).append(entry) + return {"version": 1, "hooks": hooks} + +def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + hooks.setdefault(name, []).append({"command": command}) + extra = hook_entry.get("also_wires", {}).get(ev) + if extra: + hooks.setdefault(extra, []).append({"command": command}) + return {"hooks": hooks} + +def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] - reverse = hj_reverse(cfg) - wrapper = hook_entry["wrapper"] - if wrapper == "flat_list": - rules = [] - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: - rule["matcher"] = matcher - rules.append(rule) - return rules - if wrapper == "cursor": - gates = cfg["verdicts"]["answer_events"] - hooks = {} - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - entry = {"command": command} - if fail_closed and name in gates: - entry["failClosed"] = True - hooks.setdefault(name, []).append(entry) - return {"version": 1, "hooks": hooks} - if wrapper == "flat_entries": - hooks = {} - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - hooks.setdefault(name, []).append({"command": command}) - extra = hook_entry.get("also_wires", {}).get(ev) - if extra: - hooks.setdefault(extra, []).append({"command": command}) - return {"hooks": hooks} hooks = {} for ev in canonical_events: name = reverse.get(ev) @@ -544,6 +557,23 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed= return {hook_entry["group"]: hooks} return hooks if hook_entry.get("bare") else {"hooks": hooks} +def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_closed=True): + """The vendor's hooks-config fragment wiring `command` for these canonical events. + + `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the + entry says otherwise; a False installs an observer, not a gate. + """ + hook_entry = cfg["hook_entry"] + reverse = hj_reverse(cfg) + wrapper = hook_entry["wrapper"] + if wrapper == "flat_list": + return _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher) + if wrapper == "cursor": + return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) + if wrapper == "flat_entries": + return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + return _default_wrapper(cfg, reverse, canonical_events, command, matcher) + # ------------------------------------------------------------------------------ # gemini_cli vendor config + engine binding diff --git a/.chock/bin/grok.py b/.chock/bin/grok.py index 76db409..3c5454a 100755 --- a/.chock/bin/grok.py +++ b/.chock/bin/grok.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.2.0 -- bundle("grok"). Do not hand-edit, except the +# Generated by agentseam 0.2.1 -- 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. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.2.0) +# contract (agentseam 0.2.1) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -64,17 +64,17 @@ class Event: __slots__ = ( "agent", - "event", - "tool", "command", - "path", "content", + "cwd", + "event", "output", + "path", "prompt", + "raw", "session_id", + "tool", "tool_use_id", - "cwd", - "raw", ) def __init__( @@ -133,7 +133,7 @@ def __repr__(self): # pragma: no cover - debugging aid class Decision: """What a handler wants to happen. Adapters translate this to vendor dialect.""" - __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + __slots__ = ("context", "evidence", "outcome", "reason", "updated_input") #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) @@ -203,7 +203,7 @@ def tool_input_of(raw): if isinstance(raw, str) and raw[:1] == "{": try: parsed = _json.loads(raw) - except Exception: + except _json.JSONDecodeError: return {} if isinstance(parsed, dict): return parsed @@ -220,6 +220,24 @@ def _wire_name(cfg, raw): return name return None +def _rejected_by_markers(c, raw): + for marker in c.get("reject_markers", ()): + if marker in raw: + return True + for probe, markers in c.get("reject_markers_unless_probe", {}).items(): + if any(marker in raw for marker in markers) and not PROBES[probe](raw): + return True + return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) + +def _accepted_by_markers(c, raw, name): + accept = c.get("accept_markers", ()) + if not accept or any(marker in raw for marker in accept): + return True + return any( + name == event_name and all(key in raw for key in required) + for event_name, required in c.get("accept_when_all", {}).items() + ) + def hj_claims(cfg, raw): """True when this payload matches the entry's marker discipline.""" if not isinstance(raw, dict): @@ -232,22 +250,9 @@ def hj_claims(cfg, raw): return False if "client_types" in c and raw.get("client_type") not in c["client_types"]: return False - for marker in c.get("reject_markers", ()): - if marker in raw: - return False - for probe, markers in c.get("reject_markers_unless_probe", {}).items(): - if any(marker in raw for marker in markers) and not PROBES[probe](raw): - return False - for probe in c.get("reject_probes", ()): - if PROBES[probe](raw): - return False - accept = c.get("accept_markers", ()) - if accept and not any(marker in raw for marker in accept): - for event_name, required in c.get("accept_when_all", {}).items(): - if name == event_name and all(key in raw for key in required): - return True + if _rejected_by_markers(c, raw): return False - return True + return _accepted_by_markers(c, raw, name) def _segment(node, part): """One path segment: a dict key, or `key[N]` indexing the list under it.""" @@ -334,6 +339,10 @@ def hj_parse(cfg, raw, wire=None): raw=raw, ) +_ESCALATE_FROM_TRANSFORM = "escalate_from_transform" + +_TRANSFORM_MISSING_INPUT = "transform_missing_input" + def hj_reverse(cfg): """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" reverse = {} @@ -356,14 +365,14 @@ def _context_body(name, value): def _note_for(v, decision, at_gate, missing_input): notes = v.get("degrade_notes", {}) if decision.outcome == ESCALATE: - if degraded_from(decision) == TRANSFORM and "escalate_from_transform" in notes: - return notes["escalate_from_transform"] + if degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in notes: + return notes[_ESCALATE_FROM_TRANSFORM] if at_gate and "escalate_gate" in notes: return notes["escalate_gate"] return notes.get("escalate") if decision.outcome == TRANSFORM: - if missing_input and "transform_missing_input" in notes: - return notes["transform_missing_input"] + if missing_input and _TRANSFORM_MISSING_INPUT in notes: + return notes[_TRANSFORM_MISSING_INPUT] return notes.get("transform") return None @@ -390,46 +399,54 @@ def _refusal_text(v, decision, at_gate, wire=None): text = "%s (%s)" % (reason, note) if reason and note else (note or reason) return text or default +def _g1_allow(v, decision, wire, name, words): + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + if wire in v.get("allow_silent_events", ()): + return "", 0 + if "allow" in words: + out = {"decision": words["allow"]} + if v.get("allow_context_key") and decision.outcome == ALLOW and value: + out[v["allow_context_key"]] = value + return _json.dumps(out), 0 + return "", 0 + +def _g1_transform(v, decision, name, words): + """None when the transform isn't representable here; `_g1` falls through to the block path.""" + if v.get("transform_grammar") == "hook_specific_tool_input": + return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0 + if decision.updated_input is None: + return None + if v.get("transform_grammar") == "top_level_updated_input": + out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input} + if decision.reason: + out["reason"] = decision.reason + return _json.dumps(out), 0 + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}}), 0 + def _g1(v, gate, decision, wire, name): """Block dialect: a top-level decision word, or silence/context where nothing is read.""" words = dict(v.get("words", {})) words.update(v.get("words_at", {}).get(wire, {})) at_context_event = wire in v.get("context_events", ()) if decision.outcome in (ALLOW, VOUCH, WARN): - value = _context_value(v, decision) - if at_context_event and value: - return _context_body(name, value) - if wire in v.get("allow_silent_events", ()): - return "", 0 - if "allow" in words: - out = {"decision": words["allow"]} - if v.get("allow_context_key") and decision.outcome == ALLOW and value: - out[v["allow_context_key"]] = value - return _json.dumps(out), 0 - return "", 0 + return _g1_allow(v, decision, wire, name, words) if decision.outcome == TRANSFORM and gate["honours_transform"]: - if v.get("transform_grammar") == "hook_specific_tool_input": - return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0 - if decision.updated_input is not None: - if v.get("transform_grammar") == "top_level_updated_input": - out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input} - if decision.reason: - out["reason"] = decision.reason - return _json.dumps(out), 0 - return _json.dumps( - {"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}} - ), 0 + transformed = _g1_transform(v, decision, name, words) + if transformed is not None: + return transformed if ( decision.outcome == ESCALATE and gate["honours_escalate"] and "escalate" in words # An escalate the dispatcher degraded a transform into is a block where the entry # names that degradation (antigravity): prompting would offer the unmodified call. - and not (degraded_from(decision) == TRANSFORM and "escalate_from_transform" in v.get("degrade_notes", {})) + and not (degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in v.get("degrade_notes", {})) ): - reason = decision.reason or _default_for(v, decision, True, wire) + reason = decision.reason or _default_for(v, decision, at_gate=True, wire=wire) return _json.dumps({"decision": words["escalate"], "reason": reason}), 0 - out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, False, wire)} + out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, at_gate=False, wire=wire)} if at_context_event and v.get("context_source") == "context" and decision.context: out["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": decision.context} return _json.dumps(out), 0 @@ -471,49 +488,45 @@ def _hook_dict(cfg, command): entry[key] = value return entry -def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed=True): - """The vendor's hooks-config fragment wiring `command` for these canonical events. +def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): + rules = [] + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + rule = {"event": name, "command": command} + if matcher and hook_entry["matcher"]: + rule["matcher"] = matcher + rules.append(rule) + return rules - `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the - entry says otherwise; a False installs an observer, not a gate. - """ +def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): + gates = cfg["verdicts"]["answer_events"] + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + entry = {"command": command} + if fail_closed and name in gates: + entry["failClosed"] = True + hooks.setdefault(name, []).append(entry) + return {"version": 1, "hooks": hooks} + +def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + hooks.setdefault(name, []).append({"command": command}) + extra = hook_entry.get("also_wires", {}).get(ev) + if extra: + hooks.setdefault(extra, []).append({"command": command}) + return {"hooks": hooks} + +def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] - reverse = hj_reverse(cfg) - wrapper = hook_entry["wrapper"] - if wrapper == "flat_list": - rules = [] - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: - rule["matcher"] = matcher - rules.append(rule) - return rules - if wrapper == "cursor": - gates = cfg["verdicts"]["answer_events"] - hooks = {} - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - entry = {"command": command} - if fail_closed and name in gates: - entry["failClosed"] = True - hooks.setdefault(name, []).append(entry) - return {"version": 1, "hooks": hooks} - if wrapper == "flat_entries": - hooks = {} - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - hooks.setdefault(name, []).append({"command": command}) - extra = hook_entry.get("also_wires", {}).get(ev) - if extra: - hooks.setdefault(extra, []).append({"command": command}) - return {"hooks": hooks} hooks = {} for ev in canonical_events: name = reverse.get(ev) @@ -527,6 +540,23 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed= return {hook_entry["group"]: hooks} return hooks if hook_entry.get("bare") else {"hooks": hooks} +def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_closed=True): + """The vendor's hooks-config fragment wiring `command` for these canonical events. + + `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the + entry says otherwise; a False installs an observer, not a gate. + """ + hook_entry = cfg["hook_entry"] + reverse = hj_reverse(cfg) + wrapper = hook_entry["wrapper"] + if wrapper == "flat_list": + return _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher) + if wrapper == "cursor": + return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) + if wrapper == "flat_entries": + return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + return _default_wrapper(cfg, reverse, canonical_events, command, matcher) + # ------------------------------------------------------------------------------ # grok vendor config + engine binding diff --git a/.chock/bin/tabnine.py b/.chock/bin/tabnine.py index 91fa650..9670808 100755 --- a/.chock/bin/tabnine.py +++ b/.chock/bin/tabnine.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.2.0 -- bundle("tabnine"). Do not hand-edit, except the +# Generated by agentseam 0.2.1 -- 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. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.2.0) +# contract (agentseam 0.2.1) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -64,17 +64,17 @@ class Event: __slots__ = ( "agent", - "event", - "tool", "command", - "path", "content", + "cwd", + "event", "output", + "path", "prompt", + "raw", "session_id", + "tool", "tool_use_id", - "cwd", - "raw", ) def __init__( @@ -133,7 +133,7 @@ def __repr__(self): # pragma: no cover - debugging aid class Decision: """What a handler wants to happen. Adapters translate this to vendor dialect.""" - __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + __slots__ = ("context", "evidence", "outcome", "reason", "updated_input") #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) @@ -203,7 +203,7 @@ def tool_input_of(raw): if isinstance(raw, str) and raw[:1] == "{": try: parsed = _json.loads(raw) - except Exception: + except _json.JSONDecodeError: return {} if isinstance(parsed, dict): return parsed @@ -220,6 +220,24 @@ def _wire_name(cfg, raw): return name return None +def _rejected_by_markers(c, raw): + for marker in c.get("reject_markers", ()): + if marker in raw: + return True + for probe, markers in c.get("reject_markers_unless_probe", {}).items(): + if any(marker in raw for marker in markers) and not PROBES[probe](raw): + return True + return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) + +def _accepted_by_markers(c, raw, name): + accept = c.get("accept_markers", ()) + if not accept or any(marker in raw for marker in accept): + return True + return any( + name == event_name and all(key in raw for key in required) + for event_name, required in c.get("accept_when_all", {}).items() + ) + def hj_claims(cfg, raw): """True when this payload matches the entry's marker discipline.""" if not isinstance(raw, dict): @@ -232,22 +250,9 @@ def hj_claims(cfg, raw): return False if "client_types" in c and raw.get("client_type") not in c["client_types"]: return False - for marker in c.get("reject_markers", ()): - if marker in raw: - return False - for probe, markers in c.get("reject_markers_unless_probe", {}).items(): - if any(marker in raw for marker in markers) and not PROBES[probe](raw): - return False - for probe in c.get("reject_probes", ()): - if PROBES[probe](raw): - return False - accept = c.get("accept_markers", ()) - if accept and not any(marker in raw for marker in accept): - for event_name, required in c.get("accept_when_all", {}).items(): - if name == event_name and all(key in raw for key in required): - return True + if _rejected_by_markers(c, raw): return False - return True + return _accepted_by_markers(c, raw, name) def _segment(node, part): """One path segment: a dict key, or `key[N]` indexing the list under it.""" @@ -334,6 +339,10 @@ def hj_parse(cfg, raw, wire=None): raw=raw, ) +_ESCALATE_FROM_TRANSFORM = "escalate_from_transform" + +_TRANSFORM_MISSING_INPUT = "transform_missing_input" + def hj_reverse(cfg): """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" reverse = {} @@ -356,14 +365,14 @@ def _context_body(name, value): def _note_for(v, decision, at_gate, missing_input): notes = v.get("degrade_notes", {}) if decision.outcome == ESCALATE: - if degraded_from(decision) == TRANSFORM and "escalate_from_transform" in notes: - return notes["escalate_from_transform"] + if degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in notes: + return notes[_ESCALATE_FROM_TRANSFORM] if at_gate and "escalate_gate" in notes: return notes["escalate_gate"] return notes.get("escalate") if decision.outcome == TRANSFORM: - if missing_input and "transform_missing_input" in notes: - return notes["transform_missing_input"] + if missing_input and _TRANSFORM_MISSING_INPUT in notes: + return notes[_TRANSFORM_MISSING_INPUT] return notes.get("transform") return None @@ -390,46 +399,54 @@ def _refusal_text(v, decision, at_gate, wire=None): text = "%s (%s)" % (reason, note) if reason and note else (note or reason) return text or default +def _g1_allow(v, decision, wire, name, words): + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + if wire in v.get("allow_silent_events", ()): + return "", 0 + if "allow" in words: + out = {"decision": words["allow"]} + if v.get("allow_context_key") and decision.outcome == ALLOW and value: + out[v["allow_context_key"]] = value + return _json.dumps(out), 0 + return "", 0 + +def _g1_transform(v, decision, name, words): + """None when the transform isn't representable here; `_g1` falls through to the block path.""" + if v.get("transform_grammar") == "hook_specific_tool_input": + return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0 + if decision.updated_input is None: + return None + if v.get("transform_grammar") == "top_level_updated_input": + out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input} + if decision.reason: + out["reason"] = decision.reason + return _json.dumps(out), 0 + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}}), 0 + def _g1(v, gate, decision, wire, name): """Block dialect: a top-level decision word, or silence/context where nothing is read.""" words = dict(v.get("words", {})) words.update(v.get("words_at", {}).get(wire, {})) at_context_event = wire in v.get("context_events", ()) if decision.outcome in (ALLOW, VOUCH, WARN): - value = _context_value(v, decision) - if at_context_event and value: - return _context_body(name, value) - if wire in v.get("allow_silent_events", ()): - return "", 0 - if "allow" in words: - out = {"decision": words["allow"]} - if v.get("allow_context_key") and decision.outcome == ALLOW and value: - out[v["allow_context_key"]] = value - return _json.dumps(out), 0 - return "", 0 + return _g1_allow(v, decision, wire, name, words) if decision.outcome == TRANSFORM and gate["honours_transform"]: - if v.get("transform_grammar") == "hook_specific_tool_input": - return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0 - if decision.updated_input is not None: - if v.get("transform_grammar") == "top_level_updated_input": - out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input} - if decision.reason: - out["reason"] = decision.reason - return _json.dumps(out), 0 - return _json.dumps( - {"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}} - ), 0 + transformed = _g1_transform(v, decision, name, words) + if transformed is not None: + return transformed if ( decision.outcome == ESCALATE and gate["honours_escalate"] and "escalate" in words # An escalate the dispatcher degraded a transform into is a block where the entry # names that degradation (antigravity): prompting would offer the unmodified call. - and not (degraded_from(decision) == TRANSFORM and "escalate_from_transform" in v.get("degrade_notes", {})) + and not (degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in v.get("degrade_notes", {})) ): - reason = decision.reason or _default_for(v, decision, True, wire) + reason = decision.reason or _default_for(v, decision, at_gate=True, wire=wire) return _json.dumps({"decision": words["escalate"], "reason": reason}), 0 - out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, False, wire)} + out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, at_gate=False, wire=wire)} if at_context_event and v.get("context_source") == "context" and decision.context: out["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": decision.context} return _json.dumps(out), 0 @@ -471,49 +488,45 @@ def _hook_dict(cfg, command): entry[key] = value return entry -def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed=True): - """The vendor's hooks-config fragment wiring `command` for these canonical events. +def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): + rules = [] + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + rule = {"event": name, "command": command} + if matcher and hook_entry["matcher"]: + rule["matcher"] = matcher + rules.append(rule) + return rules - `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the - entry says otherwise; a False installs an observer, not a gate. - """ +def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): + gates = cfg["verdicts"]["answer_events"] + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + entry = {"command": command} + if fail_closed and name in gates: + entry["failClosed"] = True + hooks.setdefault(name, []).append(entry) + return {"version": 1, "hooks": hooks} + +def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + hooks.setdefault(name, []).append({"command": command}) + extra = hook_entry.get("also_wires", {}).get(ev) + if extra: + hooks.setdefault(extra, []).append({"command": command}) + return {"hooks": hooks} + +def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] - reverse = hj_reverse(cfg) - wrapper = hook_entry["wrapper"] - if wrapper == "flat_list": - rules = [] - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: - rule["matcher"] = matcher - rules.append(rule) - return rules - if wrapper == "cursor": - gates = cfg["verdicts"]["answer_events"] - hooks = {} - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - entry = {"command": command} - if fail_closed and name in gates: - entry["failClosed"] = True - hooks.setdefault(name, []).append(entry) - return {"version": 1, "hooks": hooks} - if wrapper == "flat_entries": - hooks = {} - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - hooks.setdefault(name, []).append({"command": command}) - extra = hook_entry.get("also_wires", {}).get(ev) - if extra: - hooks.setdefault(extra, []).append({"command": command}) - return {"hooks": hooks} hooks = {} for ev in canonical_events: name = reverse.get(ev) @@ -527,6 +540,23 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed= return {hook_entry["group"]: hooks} return hooks if hook_entry.get("bare") else {"hooks": hooks} +def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_closed=True): + """The vendor's hooks-config fragment wiring `command` for these canonical events. + + `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the + entry says otherwise; a False installs an observer, not a gate. + """ + hook_entry = cfg["hook_entry"] + reverse = hj_reverse(cfg) + wrapper = hook_entry["wrapper"] + if wrapper == "flat_list": + return _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher) + if wrapper == "cursor": + return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) + if wrapper == "flat_entries": + return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + return _default_wrapper(cfg, reverse, canonical_events, command, matcher) + # ------------------------------------------------------------------------------ # tabnine vendor config + engine binding diff --git a/.chock/bin/vscode_copilot.py b/.chock/bin/vscode_copilot.py index b3000b6..b27f465 100755 --- a/.chock/bin/vscode_copilot.py +++ b/.chock/bin/vscode_copilot.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.2.0 -- bundle("vscode_copilot"). Do not hand-edit, except the +# Generated by agentseam 0.2.1 -- 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. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.2.0) +# contract (agentseam 0.2.1) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -64,17 +64,17 @@ class Event: __slots__ = ( "agent", - "event", - "tool", "command", - "path", "content", + "cwd", + "event", "output", + "path", "prompt", + "raw", "session_id", + "tool", "tool_use_id", - "cwd", - "raw", ) def __init__( @@ -133,7 +133,7 @@ def __repr__(self): # pragma: no cover - debugging aid class Decision: """What a handler wants to happen. Adapters translate this to vendor dialect.""" - __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + __slots__ = ("context", "evidence", "outcome", "reason", "updated_input") #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) @@ -203,7 +203,7 @@ def tool_input_of(raw): if isinstance(raw, str) and raw[:1] == "{": try: parsed = _json.loads(raw) - except Exception: + except _json.JSONDecodeError: return {} if isinstance(parsed, dict): return parsed @@ -251,6 +251,10 @@ def powershell_command(command): MEMORY_TOOLS = ("memory", "copilot_memory") MEMORY_WRITE_COMMANDS = ("create", "str_replace", "insert") +#: Copilot's hooks reference ("Tool names for hook matching", read 2026-09-01): runtime +#: names bash (Unix) / powershell (Windows), reported as Claude's Bash in PascalCase payloads. +SHELL_TOOLS = ("bash", "powershell", "Bash") + _CODEX_MARKERS = ("turn_id", "permission_mode") @@ -337,45 +341,63 @@ def _refusal_reason(decision): DECISION_VOCABULARY = frozenset({"allow", "deny", "ask", "block"}) +#: The permission-object dialect's one reason field, named four times below. +_PERMISSION_DECISION_REASON = "permissionDecisionReason" -def respond(decision, event): - """Three dialects, one per event group -- not one gate shape everywhere.""" - import json as _json - if event.event in _TOP_LEVEL_BLOCK: - if decision.outcome in (ALLOW, VOUCH): - return "", 0 - return _json.dumps({"decision": "block", "reason": _refusal_reason(decision)}), 0 +def _top_level_block_body(decision): + """The dict to serialize, or None where nothing blocks -- always exit 0.""" + if decision.outcome in (ALLOW, VOUCH): + return None + return {"decision": "block", "reason": _refusal_reason(decision)} - if event.event in _NESTED_BLOCK: - if decision.outcome in (ALLOW, VOUCH): - return "", 0 - out = {"hookEventName": _echoed_name(event), "decision": "block", "reason": _refusal_reason(decision)} - return _json.dumps({"hookSpecificOutput": out}), 0 - if event.event != PRE_TOOL: - return "", 0 +def _nested_block_body(decision, event): + if decision.outcome in (ALLOW, VOUCH): + return None + out = {"hookEventName": _echoed_name(event), "decision": "block", "reason": _refusal_reason(decision)} + return {"hookSpecificOutput": out} - if decision.outcome == ALLOW: - return "", 0 +def _pre_tool_out(decision, event): out = {"hookEventName": _echoed_name(event)} if decision.outcome == VOUCH: out["permissionDecision"] = "allow" if decision.reason: - out["permissionDecisionReason"] = decision.reason + out[_PERMISSION_DECISION_REASON] = decision.reason elif decision.outcome == DENY: out["permissionDecision"] = "deny" - out["permissionDecisionReason"] = decision.reason or "blocked" + out[_PERMISSION_DECISION_REASON] = decision.reason or "blocked" elif decision.outcome == ASK: out["permissionDecision"] = "ask" - out["permissionDecisionReason"] = decision.reason or "confirmation required" + out[_PERMISSION_DECISION_REASON] = decision.reason or "confirmation required" elif decision.outcome == REWRITE: out["permissionDecision"] = "allow" out["updatedInput"] = decision.updated_input if decision.reason: - out["permissionDecisionReason"] = decision.reason - return _json.dumps({"hookSpecificOutput": out}), 0 + out[_PERMISSION_DECISION_REASON] = decision.reason + return out + + +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 + # imports untouched -- only top-level imports get hoisted into a bundle; see + # test_function_local_imports_are_left_alone -- so hoisting this would only move it, not + # remove it, while touching every response line for no behavioral gain) + + if event.event in _TOP_LEVEL_BLOCK: + body = _top_level_block_body(decision) + return ("", 0) if body is None else (_json.dumps(body), 0) + + if event.event in _NESTED_BLOCK: + body = _nested_block_body(decision, event) + return ("", 0) if body is None else (_json.dumps(body), 0) + + if event.event != PRE_TOOL or decision.outcome == ALLOW: + return "", 0 + + return _json.dumps({"hookSpecificOutput": _pre_tool_out(decision, event)}), 0 REVERSE_EVENT_MAP = { @@ -389,7 +411,9 @@ def respond(decision, event): } -def hook_config(canonical_events, command, matcher=None): +def hook_config(canonical_events, command, matcher=None): # noqa: ARG001 (every adapter's + # hook_config(..., matcher=) is called uniformly by install.py/install_identity.py; + # VS Code has no per-tool matcher to honour, but the parameter stays for interface parity) """The hooks file VS Code actually parses: an object keyed by event name.""" hooks = {} for ev in canonical_events: diff --git a/.chock/bin/windsurf.py b/.chock/bin/windsurf.py index a3c13c1..4dd37fe 100755 --- a/.chock/bin/windsurf.py +++ b/.chock/bin/windsurf.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.2.0 -- bundle("windsurf"). Do not hand-edit, except the +# Generated by agentseam 0.2.1 -- 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. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.2.0) +# contract (agentseam 0.2.1) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -64,17 +64,17 @@ class Event: __slots__ = ( "agent", - "event", - "tool", "command", - "path", "content", + "cwd", + "event", "output", + "path", "prompt", + "raw", "session_id", + "tool", "tool_use_id", - "cwd", - "raw", ) def __init__( @@ -133,7 +133,7 @@ def __repr__(self): # pragma: no cover - debugging aid class Decision: """What a handler wants to happen. Adapters translate this to vendor dialect.""" - __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + __slots__ = ("context", "evidence", "outcome", "reason", "updated_input") #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) @@ -203,7 +203,7 @@ def tool_input_of(raw): if isinstance(raw, str) and raw[:1] == "{": try: parsed = _json.loads(raw) - except Exception: + except _json.JSONDecodeError: return {} if isinstance(parsed, dict): return parsed @@ -305,6 +305,10 @@ def hj_parse(cfg, raw, wire=None): raw=raw, ) +_ESCALATE_FROM_TRANSFORM = "escalate_from_transform" + +_TRANSFORM_MISSING_INPUT = "transform_missing_input" + def hj_reverse(cfg): """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" reverse = {} @@ -317,14 +321,14 @@ def hj_reverse(cfg): def _note_for(v, decision, at_gate, missing_input): notes = v.get("degrade_notes", {}) if decision.outcome == ESCALATE: - if degraded_from(decision) == TRANSFORM and "escalate_from_transform" in notes: - return notes["escalate_from_transform"] + if degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in notes: + return notes[_ESCALATE_FROM_TRANSFORM] if at_gate and "escalate_gate" in notes: return notes["escalate_gate"] return notes.get("escalate") if decision.outcome == TRANSFORM: - if missing_input and "transform_missing_input" in notes: - return notes["transform_missing_input"] + if missing_input and _TRANSFORM_MISSING_INPUT in notes: + return notes[_TRANSFORM_MISSING_INPUT] return notes.get("transform") return None @@ -362,49 +366,45 @@ def _hook_dict(cfg, command): entry[key] = value return entry -def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed=True): - """The vendor's hooks-config fragment wiring `command` for these canonical events. +def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): + rules = [] + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + rule = {"event": name, "command": command} + if matcher and hook_entry["matcher"]: + rule["matcher"] = matcher + rules.append(rule) + return rules - `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the - entry says otherwise; a False installs an observer, not a gate. - """ +def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): + gates = cfg["verdicts"]["answer_events"] + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + entry = {"command": command} + if fail_closed and name in gates: + entry["failClosed"] = True + hooks.setdefault(name, []).append(entry) + return {"version": 1, "hooks": hooks} + +def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + hooks.setdefault(name, []).append({"command": command}) + extra = hook_entry.get("also_wires", {}).get(ev) + if extra: + hooks.setdefault(extra, []).append({"command": command}) + return {"hooks": hooks} + +def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] - reverse = hj_reverse(cfg) - wrapper = hook_entry["wrapper"] - if wrapper == "flat_list": - rules = [] - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: - rule["matcher"] = matcher - rules.append(rule) - return rules - if wrapper == "cursor": - gates = cfg["verdicts"]["answer_events"] - hooks = {} - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - entry = {"command": command} - if fail_closed and name in gates: - entry["failClosed"] = True - hooks.setdefault(name, []).append(entry) - return {"version": 1, "hooks": hooks} - if wrapper == "flat_entries": - hooks = {} - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - hooks.setdefault(name, []).append({"command": command}) - extra = hook_entry.get("also_wires", {}).get(ev) - if extra: - hooks.setdefault(extra, []).append({"command": command}) - return {"hooks": hooks} hooks = {} for ev in canonical_events: name = reverse.get(ev) @@ -418,6 +418,23 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed= return {hook_entry["group"]: hooks} return hooks if hook_entry.get("bare") else {"hooks": hooks} +def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_closed=True): + """The vendor's hooks-config fragment wiring `command` for these canonical events. + + `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the + entry says otherwise; a False installs an observer, not a gate. + """ + hook_entry = cfg["hook_entry"] + reverse = hj_reverse(cfg) + wrapper = hook_entry["wrapper"] + if wrapper == "flat_list": + return _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher) + if wrapper == "cursor": + return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) + if wrapper == "flat_entries": + return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + return _default_wrapper(cfg, reverse, canonical_events, command, matcher) + _MCP_EVENTS = ("pre_mcp_tool_use", "post_mcp_tool_use") def windsurf_wire(raw): @@ -454,7 +471,7 @@ def windsurf_respond(cfg, decision, event): wire = windsurf_wire(event.raw) if event.raw else "" if wire not in v["gates"]: return v["flag_note"] % (wire, decision.reason or v["flag_note_default"]), 0 - return _refusal_text(v, decision, False, wire), 2 + return _refusal_text(v, decision, at_gate=False, wire=wire), 2 # ------------------------------------------------------------------------------ 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 74e9cfb..f0b695b 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 @@ -8,7 +8,8 @@ "command": "@CHOCK_PYTHON@ \".chock/bin/codex_cli.py\" --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", "commandWindows": "& @CHOCK_PYTHON@ \".chock/bin/codex_cli.py\" --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" } - ] + ], + "matcher": "Bash" } ] } 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 0935a85..ef9b11d 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 @@ -8,7 +8,8 @@ "command": "@CHOCK_PYTHON@ \".chock/bin/codex_cli.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", "commandWindows": "& @CHOCK_PYTHON@ \".chock/bin/codex_cli.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" } - ] + ], + "matcher": "Bash" } ] } 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 e719fa7..1c37a96 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 @@ -8,7 +8,8 @@ "command": "@CHOCK_PYTHON@ \".chock/bin/codex_cli.py\" --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", "commandWindows": "& @CHOCK_PYTHON@ \".chock/bin/codex_cli.py\" --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" } - ] + ], + "matcher": "Bash" } ] } 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 e907d1a..463a0a4 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 @@ -8,7 +8,8 @@ "command": "@CHOCK_PYTHON@ \".chock/bin/codex_cli.py\" --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", "commandWindows": "& @CHOCK_PYTHON@ \".chock/bin/codex_cli.py\" --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" } - ] + ], + "matcher": "Bash" } ] } diff --git a/.codex/hooks.json b/.codex/hooks.json index f3a0583..629b97c 100644 --- a/.codex/hooks.json +++ b/.codex/hooks.json @@ -8,7 +8,8 @@ "command": "\"/usr/local/bin/python\" \".chock/bin/codex_cli.py\" --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", "commandWindows": "& \"/usr/local/bin/python\" \".chock/bin/codex_cli.py\" --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" } - ] + ], + "matcher": "Bash" }, { "hooks": [ @@ -17,7 +18,8 @@ "command": "\"/usr/local/bin/python\" \".chock/bin/codex_cli.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", "commandWindows": "& \"/usr/local/bin/python\" \".chock/bin/codex_cli.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" } - ] + ], + "matcher": "Bash" }, { "hooks": [ @@ -26,7 +28,8 @@ "command": "\"/usr/local/bin/python\" \".chock/bin/codex_cli.py\" --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", "commandWindows": "& \"/usr/local/bin/python\" \".chock/bin/codex_cli.py\" --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" } - ] + ], + "matcher": "Bash" }, { "hooks": [ @@ -35,7 +38,8 @@ "command": "\"/usr/local/bin/python\" \".chock/bin/codex_cli.py\" --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", "commandWindows": "& \"/usr/local/bin/python\" \".chock/bin/codex_cli.py\" --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" } - ] + ], + "matcher": "Bash" } ] } diff --git a/chock.lock b/chock.lock index dc7dba1..d1af309 100644 --- a/chock.lock +++ b/chock.lock @@ -16,7 +16,7 @@ "managed": false, "sha256": "59fffb91c6f65710b461e87782d355c1403953f36700e05369fca065683821c0", "source": "local", - "artifacts_sha256": "62845a8986c3feb8b100891a62e528a91d5158b8e59e500f525e83e702ed7190" + "artifacts_sha256": "f5ce8291003c254099de2b7b0bcd30b3bd749ebfd97ed1071ad37e51237c10cd" }, { "id": "block-invisible-unicode", @@ -32,7 +32,7 @@ "managed": false, "sha256": "ef0d729c413086bd4ce77c7191bcc1393f4e0cdfcfc959b33a1ddb771afdf2b0", "source": "local", - "artifacts_sha256": "7660929f39693e3e05b1612d42432a4c6abebe7ba742eb7579a68ecf2be476f7" + "artifacts_sha256": "fc65a16e8d3d84f2735a86922e16fa21324fcb63553c17cdd32bb87ef5a6d906" }, { "id": "block-wildcard-agent-permissions", @@ -104,7 +104,7 @@ "managed": false, "sha256": "b98d5472c534d381b87e253086642675c71ce3947e2cfe01ed9fec5ed8b66295", "source": "local", - "artifacts_sha256": "28d9b17c15b12698125832b7674f4840714413756eebe66e53c60f508a77989f" + "artifacts_sha256": "1d695d9651e43d3ea6aaea6d74d4d1fa9cf2661ed99b5438f1d25cbc56e08470" }, { "id": "protect-commit-privacy", @@ -112,7 +112,7 @@ "managed": false, "sha256": "281522db2b259ea9a12d76b30d57ed9993882089b04a1fb4ce9932bc15a3388f", "source": "local", - "artifacts_sha256": "e06649fe5879f5b97c347d99eebc5663cd0cc43a07eb0ccf5a9d3e5dd1fecb8b" + "artifacts_sha256": "e27d75a558ff05e9c5a3858d68ecbbfa4ec7a9d3243b85fa8ce76aa0da7c72ba" }, { "id": "protect-main-branch", diff --git a/pyproject.toml b/pyproject.toml index baa1ea9..f463cc4 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.2.0", + "agentseam==0.2.1", ] [project.optional-dependencies] diff --git a/src/chock/compile/emitters/in_agent.py b/src/chock/compile/emitters/in_agent.py index 067c7f6..ae310fa 100644 --- a/src/chock/compile/emitters/in_agent.py +++ b/src/chock/compile/emitters/in_agent.py @@ -32,16 +32,14 @@ def _guard_script(policy_dir: Path, policy_id: str) -> str | None: TIMEOUT_SECONDS = 30 -#: claude_code is the only vendor whose shell vocabulary agentseam 0.2.0 records -#: (`tools.shell`); codex_cli and vscode_copilot record none, so their claude-format -#: plugin hooks borrow this matcher exactly as the hand-written emitters did -#: (tests/test_vendor_wire_facts.py trips when upstream closes the gap). +#: claude_code's own recorded shell vocabulary, used for its claude-plugin hooks file. MATCHER = vendors.shell_matcher("claude_code") assert MATCHER is not None # noqa: S101 -- import-time upstream-data invariant, not request handling -#: Wire token Claude Code substitutes for the repo root; agentseam 0.2.0's vendor-config -#: schema carries no repo-root-token field yet, so the fact still lives here. -PROJECT_DIR_TOKEN = "${CLAUDE_PROJECT_DIR}" # noqa: S105 -- a shell variable reference, not a credential +#: Wire token Claude Code substitutes for the repo root, read from agentseam's vendor +#: config (`repo_root_token`) instead of chock's own hardcoded copy. +PROJECT_DIR_TOKEN = vendors.repo_root_token("claude_code") +assert PROJECT_DIR_TOKEN is not None # noqa: S101 -- import-time upstream-data invariant, not request handling # Witnessed overrides: chock's agent-hooks file speaks `preToolUse` with bash/powershell/ # timeoutSec entry keys (live deny, data/witnesses.json: vscode_copilot x agent-hooks); @@ -100,7 +98,8 @@ def hook_entry(command: str, *, matcher: str | None = None) -> dict[str, Any]: def hooks_map_file(vendor: str, command: str) -> dict[str, Any]: """A claude-plugin-format hooks file under `vendor`'s own pre-tool event spelling.""" - return {"hooks": {vendors.pre_tool_event(vendor): [hook_entry(command, matcher=MATCHER)]}} + matcher = vendors.shell_matcher(vendor) + return {"hooks": {vendors.pre_tool_event(vendor): [hook_entry(command, matcher=matcher)]}} def cursor_entry(command: str) -> dict[str, Any]: diff --git a/src/chock/vendors.py b/src/chock/vendors.py index cc998ad..606f064 100644 --- a/src/chock/vendors.py +++ b/src/chock/vendors.py @@ -58,6 +58,12 @@ def config_path(vendor: str) -> str: return str(entry(vendor)["config_path"]) +def repo_root_token(vendor: str) -> str | None: + """The vendor's own wire token for the repo root, or None where agentseam records none.""" + token = entry(vendor).get("repo_root_token") + return str(token) if token else None + + def wire_event(vendor: str, canonical: str) -> str: """The vendor's wire spelling of one of agentseam's canonical events.""" return str(_adapters.get(vendor).REVERSE_EVENT_MAP[canonical]) 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 3a6d88a..73cab23 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 @@ -8,7 +8,8 @@ "command": "@CHOCK_PYTHON@ \".chock/bin/codex_cli.py\" --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", "commandWindows": "& @CHOCK_PYTHON@ \".chock/bin/codex_cli.py\" --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" } - ] + ], + "matcher": "Bash" } ] } diff --git a/tests/fixtures/runtime_goldens/antigravity.py b/tests/fixtures/runtime_goldens/antigravity.py index a0d4f13..b320995 100644 --- a/tests/fixtures/runtime_goldens/antigravity.py +++ b/tests/fixtures/runtime_goldens/antigravity.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.2.0 -- bundle("antigravity"). Do not hand-edit, except the +# Generated by agentseam 0.2.1 -- 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. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.2.0) +# contract (agentseam 0.2.1) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -64,17 +64,17 @@ class Event: __slots__ = ( "agent", - "event", - "tool", "command", - "path", "content", + "cwd", + "event", "output", + "path", "prompt", + "raw", "session_id", + "tool", "tool_use_id", - "cwd", - "raw", ) def __init__( @@ -133,7 +133,7 @@ def __repr__(self): # pragma: no cover - debugging aid class Decision: """What a handler wants to happen. Adapters translate this to vendor dialect.""" - __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + __slots__ = ("context", "evidence", "outcome", "reason", "updated_input") #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) @@ -203,7 +203,7 @@ def tool_input_of(raw): if isinstance(raw, str) and raw[:1] == "{": try: parsed = _json.loads(raw) - except Exception: + except _json.JSONDecodeError: return {} if isinstance(parsed, dict): return parsed @@ -305,6 +305,10 @@ def hj_parse(cfg, raw, wire=None): raw=raw, ) +_ESCALATE_FROM_TRANSFORM = "escalate_from_transform" + +_TRANSFORM_MISSING_INPUT = "transform_missing_input" + def hj_reverse(cfg): """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" reverse = {} @@ -327,14 +331,14 @@ def _context_body(name, value): def _note_for(v, decision, at_gate, missing_input): notes = v.get("degrade_notes", {}) if decision.outcome == ESCALATE: - if degraded_from(decision) == TRANSFORM and "escalate_from_transform" in notes: - return notes["escalate_from_transform"] + if degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in notes: + return notes[_ESCALATE_FROM_TRANSFORM] if at_gate and "escalate_gate" in notes: return notes["escalate_gate"] return notes.get("escalate") if decision.outcome == TRANSFORM: - if missing_input and "transform_missing_input" in notes: - return notes["transform_missing_input"] + if missing_input and _TRANSFORM_MISSING_INPUT in notes: + return notes[_TRANSFORM_MISSING_INPUT] return notes.get("transform") return None @@ -361,46 +365,54 @@ def _refusal_text(v, decision, at_gate, wire=None): text = "%s (%s)" % (reason, note) if reason and note else (note or reason) return text or default +def _g1_allow(v, decision, wire, name, words): + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + if wire in v.get("allow_silent_events", ()): + return "", 0 + if "allow" in words: + out = {"decision": words["allow"]} + if v.get("allow_context_key") and decision.outcome == ALLOW and value: + out[v["allow_context_key"]] = value + return _json.dumps(out), 0 + return "", 0 + +def _g1_transform(v, decision, name, words): + """None when the transform isn't representable here; `_g1` falls through to the block path.""" + if v.get("transform_grammar") == "hook_specific_tool_input": + return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0 + if decision.updated_input is None: + return None + if v.get("transform_grammar") == "top_level_updated_input": + out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input} + if decision.reason: + out["reason"] = decision.reason + return _json.dumps(out), 0 + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}}), 0 + def _g1(v, gate, decision, wire, name): """Block dialect: a top-level decision word, or silence/context where nothing is read.""" words = dict(v.get("words", {})) words.update(v.get("words_at", {}).get(wire, {})) at_context_event = wire in v.get("context_events", ()) if decision.outcome in (ALLOW, VOUCH, WARN): - value = _context_value(v, decision) - if at_context_event and value: - return _context_body(name, value) - if wire in v.get("allow_silent_events", ()): - return "", 0 - if "allow" in words: - out = {"decision": words["allow"]} - if v.get("allow_context_key") and decision.outcome == ALLOW and value: - out[v["allow_context_key"]] = value - return _json.dumps(out), 0 - return "", 0 + return _g1_allow(v, decision, wire, name, words) if decision.outcome == TRANSFORM and gate["honours_transform"]: - if v.get("transform_grammar") == "hook_specific_tool_input": - return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0 - if decision.updated_input is not None: - if v.get("transform_grammar") == "top_level_updated_input": - out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input} - if decision.reason: - out["reason"] = decision.reason - return _json.dumps(out), 0 - return _json.dumps( - {"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}} - ), 0 + transformed = _g1_transform(v, decision, name, words) + if transformed is not None: + return transformed if ( decision.outcome == ESCALATE and gate["honours_escalate"] and "escalate" in words # An escalate the dispatcher degraded a transform into is a block where the entry # names that degradation (antigravity): prompting would offer the unmodified call. - and not (degraded_from(decision) == TRANSFORM and "escalate_from_transform" in v.get("degrade_notes", {})) + and not (degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in v.get("degrade_notes", {})) ): - reason = decision.reason or _default_for(v, decision, True, wire) + reason = decision.reason or _default_for(v, decision, at_gate=True, wire=wire) return _json.dumps({"decision": words["escalate"], "reason": reason}), 0 - out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, False, wire)} + out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, at_gate=False, wire=wire)} if at_context_event and v.get("context_source") == "context" and decision.context: out["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": decision.context} return _json.dumps(out), 0 @@ -442,49 +454,45 @@ def _hook_dict(cfg, command): entry[key] = value return entry -def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed=True): - """The vendor's hooks-config fragment wiring `command` for these canonical events. +def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): + rules = [] + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + rule = {"event": name, "command": command} + if matcher and hook_entry["matcher"]: + rule["matcher"] = matcher + rules.append(rule) + return rules - `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the - entry says otherwise; a False installs an observer, not a gate. - """ +def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): + gates = cfg["verdicts"]["answer_events"] + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + entry = {"command": command} + if fail_closed and name in gates: + entry["failClosed"] = True + hooks.setdefault(name, []).append(entry) + return {"version": 1, "hooks": hooks} + +def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + hooks.setdefault(name, []).append({"command": command}) + extra = hook_entry.get("also_wires", {}).get(ev) + if extra: + hooks.setdefault(extra, []).append({"command": command}) + return {"hooks": hooks} + +def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] - reverse = hj_reverse(cfg) - wrapper = hook_entry["wrapper"] - if wrapper == "flat_list": - rules = [] - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: - rule["matcher"] = matcher - rules.append(rule) - return rules - if wrapper == "cursor": - gates = cfg["verdicts"]["answer_events"] - hooks = {} - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - entry = {"command": command} - if fail_closed and name in gates: - entry["failClosed"] = True - hooks.setdefault(name, []).append(entry) - return {"version": 1, "hooks": hooks} - if wrapper == "flat_entries": - hooks = {} - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - hooks.setdefault(name, []).append({"command": command}) - extra = hook_entry.get("also_wires", {}).get(ev) - if extra: - hooks.setdefault(extra, []).append({"command": command}) - return {"hooks": hooks} hooks = {} for ev in canonical_events: name = reverse.get(ev) @@ -498,6 +506,23 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed= return {hook_entry["group"]: hooks} return hooks if hook_entry.get("bare") else {"hooks": hooks} +def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_closed=True): + """The vendor's hooks-config fragment wiring `command` for these canonical events. + + `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the + entry says otherwise; a False installs an observer, not a gate. + """ + hook_entry = cfg["hook_entry"] + reverse = hj_reverse(cfg) + wrapper = hook_entry["wrapper"] + if wrapper == "flat_list": + return _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher) + if wrapper == "cursor": + return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) + if wrapper == "flat_entries": + return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + return _default_wrapper(cfg, reverse, canonical_events, command, matcher) + def antigravity_wire(raw): """Name the event from shape; ties go to PreToolUse so the gate stays a gate.""" if "terminationReason" in raw or "fullyIdle" in raw: @@ -506,7 +531,7 @@ def antigravity_wire(raw): return "PostToolUse" if "error" in raw else "PreToolUse" return None -def antigravity_claims(cfg, raw): +def antigravity_claims(_cfg, raw): """Structural: `conversationId` with `workspacePaths` is Antigravity's own envelope.""" if not isinstance(raw, dict): return False diff --git a/tests/fixtures/runtime_goldens/claude_code.py b/tests/fixtures/runtime_goldens/claude_code.py index 3e94a8b..b395abd 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.2.0 -- bundle("claude_code"). Do not hand-edit, except the +# Generated by agentseam 0.2.1 -- 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. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.2.0) +# contract (agentseam 0.2.1) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -64,17 +64,17 @@ class Event: __slots__ = ( "agent", - "event", - "tool", "command", - "path", "content", + "cwd", + "event", "output", + "path", "prompt", + "raw", "session_id", + "tool", "tool_use_id", - "cwd", - "raw", ) def __init__( @@ -133,7 +133,7 @@ def __repr__(self): # pragma: no cover - debugging aid class Decision: """What a handler wants to happen. Adapters translate this to vendor dialect.""" - __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + __slots__ = ("context", "evidence", "outcome", "reason", "updated_input") #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) @@ -203,7 +203,7 @@ def tool_input_of(raw): if isinstance(raw, str) and raw[:1] == "{": try: parsed = _json.loads(raw) - except Exception: + except _json.JSONDecodeError: return {} if isinstance(parsed, dict): return parsed @@ -237,6 +237,24 @@ def _wire_name(cfg, raw): return name return None +def _rejected_by_markers(c, raw): + for marker in c.get("reject_markers", ()): + if marker in raw: + return True + for probe, markers in c.get("reject_markers_unless_probe", {}).items(): + if any(marker in raw for marker in markers) and not PROBES[probe](raw): + return True + return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) + +def _accepted_by_markers(c, raw, name): + accept = c.get("accept_markers", ()) + if not accept or any(marker in raw for marker in accept): + return True + return any( + name == event_name and all(key in raw for key in required) + for event_name, required in c.get("accept_when_all", {}).items() + ) + def hj_claims(cfg, raw): """True when this payload matches the entry's marker discipline.""" if not isinstance(raw, dict): @@ -249,22 +267,9 @@ def hj_claims(cfg, raw): return False if "client_types" in c and raw.get("client_type") not in c["client_types"]: return False - for marker in c.get("reject_markers", ()): - if marker in raw: - return False - for probe, markers in c.get("reject_markers_unless_probe", {}).items(): - if any(marker in raw for marker in markers) and not PROBES[probe](raw): - return False - for probe in c.get("reject_probes", ()): - if PROBES[probe](raw): - return False - accept = c.get("accept_markers", ()) - if accept and not any(marker in raw for marker in accept): - for event_name, required in c.get("accept_when_all", {}).items(): - if name == event_name and all(key in raw for key in required): - return True + if _rejected_by_markers(c, raw): return False - return True + return _accepted_by_markers(c, raw, name) def _segment(node, part): """One path segment: a dict key, or `key[N]` indexing the list under it.""" @@ -351,6 +356,10 @@ def hj_parse(cfg, raw, wire=None): raw=raw, ) +_ESCALATE_FROM_TRANSFORM = "escalate_from_transform" + +_TRANSFORM_MISSING_INPUT = "transform_missing_input" + def hj_reverse(cfg): """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" reverse = {} @@ -373,14 +382,14 @@ def _context_body(name, value): def _note_for(v, decision, at_gate, missing_input): notes = v.get("degrade_notes", {}) if decision.outcome == ESCALATE: - if degraded_from(decision) == TRANSFORM and "escalate_from_transform" in notes: - return notes["escalate_from_transform"] + if degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in notes: + return notes[_ESCALATE_FROM_TRANSFORM] if at_gate and "escalate_gate" in notes: return notes["escalate_gate"] return notes.get("escalate") if decision.outcome == TRANSFORM: - if missing_input and "transform_missing_input" in notes: - return notes["transform_missing_input"] + if missing_input and _TRANSFORM_MISSING_INPUT in notes: + return notes[_TRANSFORM_MISSING_INPUT] return notes.get("transform") return None @@ -407,50 +416,60 @@ def _refusal_text(v, decision, at_gate, wire=None): text = "%s (%s)" % (reason, note) if reason and note else (note or reason) return text or default +def _g1_allow(v, decision, wire, name, words): + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + if wire in v.get("allow_silent_events", ()): + return "", 0 + if "allow" in words: + out = {"decision": words["allow"]} + if v.get("allow_context_key") and decision.outcome == ALLOW and value: + out[v["allow_context_key"]] = value + return _json.dumps(out), 0 + return "", 0 + +def _g1_transform(v, decision, name, words): + """None when the transform isn't representable here; `_g1` falls through to the block path.""" + if v.get("transform_grammar") == "hook_specific_tool_input": + return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0 + if decision.updated_input is None: + return None + if v.get("transform_grammar") == "top_level_updated_input": + out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input} + if decision.reason: + out["reason"] = decision.reason + return _json.dumps(out), 0 + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}}), 0 + def _g1(v, gate, decision, wire, name): """Block dialect: a top-level decision word, or silence/context where nothing is read.""" words = dict(v.get("words", {})) words.update(v.get("words_at", {}).get(wire, {})) at_context_event = wire in v.get("context_events", ()) if decision.outcome in (ALLOW, VOUCH, WARN): - value = _context_value(v, decision) - if at_context_event and value: - return _context_body(name, value) - if wire in v.get("allow_silent_events", ()): - return "", 0 - if "allow" in words: - out = {"decision": words["allow"]} - if v.get("allow_context_key") and decision.outcome == ALLOW and value: - out[v["allow_context_key"]] = value - return _json.dumps(out), 0 - return "", 0 + return _g1_allow(v, decision, wire, name, words) if decision.outcome == TRANSFORM and gate["honours_transform"]: - if v.get("transform_grammar") == "hook_specific_tool_input": - return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0 - if decision.updated_input is not None: - if v.get("transform_grammar") == "top_level_updated_input": - out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input} - if decision.reason: - out["reason"] = decision.reason - return _json.dumps(out), 0 - return _json.dumps( - {"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}} - ), 0 + transformed = _g1_transform(v, decision, name, words) + if transformed is not None: + return transformed if ( decision.outcome == ESCALATE and gate["honours_escalate"] and "escalate" in words # An escalate the dispatcher degraded a transform into is a block where the entry # names that degradation (antigravity): prompting would offer the unmodified call. - and not (degraded_from(decision) == TRANSFORM and "escalate_from_transform" in v.get("degrade_notes", {})) + and not (degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in v.get("degrade_notes", {})) ): - reason = decision.reason or _default_for(v, decision, True, wire) + reason = decision.reason or _default_for(v, decision, at_gate=True, wire=wire) return _json.dumps({"decision": words["escalate"], "reason": reason}), 0 - out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, False, wire)} + out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, at_gate=False, wire=wire)} if at_context_event and v.get("context_source") == "context" and decision.context: out["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": decision.context} return _json.dumps(out), 0 +_PERMISSION_DECISION_REASON = "permissionDecisionReason" + def _g2(v, gate, decision, name): """Permission gate: `hookSpecificOutput.permissionDecision`, filled from the word table.""" if decision.outcome in (ALLOW, WARN): @@ -462,22 +481,22 @@ def _g2(v, gate, decision, name): return "", 0 out["permissionDecision"] = words["vouch"] if decision.reason: - out["permissionDecisionReason"] = decision.reason + out[_PERMISSION_DECISION_REASON] = decision.reason elif ( decision.outcome == TRANSFORM and gate["honours_transform"] - and not (decision.updated_input is None and "transform_missing_input" in v.get("degrade_notes", {})) + and not (decision.updated_input is None and _TRANSFORM_MISSING_INPUT in v.get("degrade_notes", {})) ): out["permissionDecision"] = words.get("transform", "allow") out["updatedInput"] = decision.updated_input if decision.reason: - out["permissionDecisionReason"] = decision.reason + out[_PERMISSION_DECISION_REASON] = decision.reason elif decision.outcome == ESCALATE and gate["honours_escalate"] and "escalate" in words: out["permissionDecision"] = words["escalate"] - out["permissionDecisionReason"] = decision.reason or _default_for(v, decision, True) + out[_PERMISSION_DECISION_REASON] = decision.reason or _default_for(v, decision, at_gate=True) else: out["permissionDecision"] = words.get("deny", "deny") - out["permissionDecisionReason"] = _refusal_text(v, decision, True) + out[_PERMISSION_DECISION_REASON] = _refusal_text(v, decision, at_gate=True) return _json.dumps({"hookSpecificOutput": out}), 0 def hj_respond(cfg, decision, event, wire=None): @@ -517,49 +536,45 @@ def _hook_dict(cfg, command): entry[key] = value return entry -def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed=True): - """The vendor's hooks-config fragment wiring `command` for these canonical events. +def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): + rules = [] + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + rule = {"event": name, "command": command} + if matcher and hook_entry["matcher"]: + rule["matcher"] = matcher + rules.append(rule) + return rules - `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the - entry says otherwise; a False installs an observer, not a gate. - """ +def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): + gates = cfg["verdicts"]["answer_events"] + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + entry = {"command": command} + if fail_closed and name in gates: + entry["failClosed"] = True + hooks.setdefault(name, []).append(entry) + return {"version": 1, "hooks": hooks} + +def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + hooks.setdefault(name, []).append({"command": command}) + extra = hook_entry.get("also_wires", {}).get(ev) + if extra: + hooks.setdefault(extra, []).append({"command": command}) + return {"hooks": hooks} + +def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] - reverse = hj_reverse(cfg) - wrapper = hook_entry["wrapper"] - if wrapper == "flat_list": - rules = [] - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: - rule["matcher"] = matcher - rules.append(rule) - return rules - if wrapper == "cursor": - gates = cfg["verdicts"]["answer_events"] - hooks = {} - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - entry = {"command": command} - if fail_closed and name in gates: - entry["failClosed"] = True - hooks.setdefault(name, []).append(entry) - return {"version": 1, "hooks": hooks} - if wrapper == "flat_entries": - hooks = {} - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - hooks.setdefault(name, []).append({"command": command}) - extra = hook_entry.get("also_wires", {}).get(ev) - if extra: - hooks.setdefault(extra, []).append({"command": command}) - return {"hooks": hooks} hooks = {} for ev in canonical_events: name = reverse.get(ev) @@ -573,13 +588,30 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed= return {hook_entry["group"]: hooks} return hooks if hook_entry.get("bare") else {"hooks": hooks} +def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_closed=True): + """The vendor's hooks-config fragment wiring `command` for these canonical events. + + `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the + entry says otherwise; a False installs an observer, not a gate. + """ + hook_entry = cfg["hook_entry"] + reverse = hj_reverse(cfg) + wrapper = hook_entry["wrapper"] + if wrapper == "flat_list": + return _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher) + if wrapper == "cursor": + return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) + if wrapper == "flat_entries": + return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + return _default_wrapper(cfg, reverse, canonical_events, command, matcher) + # ------------------------------------------------------------------------------ # claude_code vendor config + engine binding 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-08-28', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'live-run', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'live-run', 'date': '2026-08-28', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'live-run', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'live-run', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'live-run', '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', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'live-run', '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', '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, '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-08-28', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'live-run', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'live-run', 'date': '2026-08-28', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'live-run', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'live-run', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'live-run', 'date': '2026-08-28', '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-08-28', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'live-run', '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', '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'}}} def claims(raw): diff --git a/tests/fixtures/runtime_goldens/codex_cli.py b/tests/fixtures/runtime_goldens/codex_cli.py index 3d67533..52d2ce9 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.2.0 -- bundle("codex_cli"). Do not hand-edit, except the +# Generated by agentseam 0.2.1 -- 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. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.2.0) +# contract (agentseam 0.2.1) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -64,17 +64,17 @@ class Event: __slots__ = ( "agent", - "event", - "tool", "command", - "path", "content", + "cwd", + "event", "output", + "path", "prompt", + "raw", "session_id", + "tool", "tool_use_id", - "cwd", - "raw", ) def __init__( @@ -133,7 +133,7 @@ def __repr__(self): # pragma: no cover - debugging aid class Decision: """What a handler wants to happen. Adapters translate this to vendor dialect.""" - __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + __slots__ = ("context", "evidence", "outcome", "reason", "updated_input") #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) @@ -203,7 +203,7 @@ def tool_input_of(raw): if isinstance(raw, str) and raw[:1] == "{": try: parsed = _json.loads(raw) - except Exception: + except _json.JSONDecodeError: return {} if isinstance(parsed, dict): return parsed @@ -224,6 +224,24 @@ def _wire_name(cfg, raw): return name return None +def _rejected_by_markers(c, raw): + for marker in c.get("reject_markers", ()): + if marker in raw: + return True + for probe, markers in c.get("reject_markers_unless_probe", {}).items(): + if any(marker in raw for marker in markers) and not PROBES[probe](raw): + return True + return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) + +def _accepted_by_markers(c, raw, name): + accept = c.get("accept_markers", ()) + if not accept or any(marker in raw for marker in accept): + return True + return any( + name == event_name and all(key in raw for key in required) + for event_name, required in c.get("accept_when_all", {}).items() + ) + def hj_claims(cfg, raw): """True when this payload matches the entry's marker discipline.""" if not isinstance(raw, dict): @@ -236,22 +254,9 @@ def hj_claims(cfg, raw): return False if "client_types" in c and raw.get("client_type") not in c["client_types"]: return False - for marker in c.get("reject_markers", ()): - if marker in raw: - return False - for probe, markers in c.get("reject_markers_unless_probe", {}).items(): - if any(marker in raw for marker in markers) and not PROBES[probe](raw): - return False - for probe in c.get("reject_probes", ()): - if PROBES[probe](raw): - return False - accept = c.get("accept_markers", ()) - if accept and not any(marker in raw for marker in accept): - for event_name, required in c.get("accept_when_all", {}).items(): - if name == event_name and all(key in raw for key in required): - return True + if _rejected_by_markers(c, raw): return False - return True + return _accepted_by_markers(c, raw, name) def _segment(node, part): """One path segment: a dict key, or `key[N]` indexing the list under it.""" @@ -338,6 +343,10 @@ def hj_parse(cfg, raw, wire=None): raw=raw, ) +_ESCALATE_FROM_TRANSFORM = "escalate_from_transform" + +_TRANSFORM_MISSING_INPUT = "transform_missing_input" + def hj_reverse(cfg): """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" reverse = {} @@ -360,14 +369,14 @@ def _context_body(name, value): def _note_for(v, decision, at_gate, missing_input): notes = v.get("degrade_notes", {}) if decision.outcome == ESCALATE: - if degraded_from(decision) == TRANSFORM and "escalate_from_transform" in notes: - return notes["escalate_from_transform"] + if degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in notes: + return notes[_ESCALATE_FROM_TRANSFORM] if at_gate and "escalate_gate" in notes: return notes["escalate_gate"] return notes.get("escalate") if decision.outcome == TRANSFORM: - if missing_input and "transform_missing_input" in notes: - return notes["transform_missing_input"] + if missing_input and _TRANSFORM_MISSING_INPUT in notes: + return notes[_TRANSFORM_MISSING_INPUT] return notes.get("transform") return None @@ -394,50 +403,60 @@ def _refusal_text(v, decision, at_gate, wire=None): text = "%s (%s)" % (reason, note) if reason and note else (note or reason) return text or default +def _g1_allow(v, decision, wire, name, words): + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + if wire in v.get("allow_silent_events", ()): + return "", 0 + if "allow" in words: + out = {"decision": words["allow"]} + if v.get("allow_context_key") and decision.outcome == ALLOW and value: + out[v["allow_context_key"]] = value + return _json.dumps(out), 0 + return "", 0 + +def _g1_transform(v, decision, name, words): + """None when the transform isn't representable here; `_g1` falls through to the block path.""" + if v.get("transform_grammar") == "hook_specific_tool_input": + return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0 + if decision.updated_input is None: + return None + if v.get("transform_grammar") == "top_level_updated_input": + out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input} + if decision.reason: + out["reason"] = decision.reason + return _json.dumps(out), 0 + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}}), 0 + def _g1(v, gate, decision, wire, name): """Block dialect: a top-level decision word, or silence/context where nothing is read.""" words = dict(v.get("words", {})) words.update(v.get("words_at", {}).get(wire, {})) at_context_event = wire in v.get("context_events", ()) if decision.outcome in (ALLOW, VOUCH, WARN): - value = _context_value(v, decision) - if at_context_event and value: - return _context_body(name, value) - if wire in v.get("allow_silent_events", ()): - return "", 0 - if "allow" in words: - out = {"decision": words["allow"]} - if v.get("allow_context_key") and decision.outcome == ALLOW and value: - out[v["allow_context_key"]] = value - return _json.dumps(out), 0 - return "", 0 + return _g1_allow(v, decision, wire, name, words) if decision.outcome == TRANSFORM and gate["honours_transform"]: - if v.get("transform_grammar") == "hook_specific_tool_input": - return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0 - if decision.updated_input is not None: - if v.get("transform_grammar") == "top_level_updated_input": - out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input} - if decision.reason: - out["reason"] = decision.reason - return _json.dumps(out), 0 - return _json.dumps( - {"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}} - ), 0 + transformed = _g1_transform(v, decision, name, words) + if transformed is not None: + return transformed if ( decision.outcome == ESCALATE and gate["honours_escalate"] and "escalate" in words # An escalate the dispatcher degraded a transform into is a block where the entry # names that degradation (antigravity): prompting would offer the unmodified call. - and not (degraded_from(decision) == TRANSFORM and "escalate_from_transform" in v.get("degrade_notes", {})) + and not (degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in v.get("degrade_notes", {})) ): - reason = decision.reason or _default_for(v, decision, True, wire) + reason = decision.reason or _default_for(v, decision, at_gate=True, wire=wire) return _json.dumps({"decision": words["escalate"], "reason": reason}), 0 - out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, False, wire)} + out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, at_gate=False, wire=wire)} if at_context_event and v.get("context_source") == "context" and decision.context: out["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": decision.context} return _json.dumps(out), 0 +_PERMISSION_DECISION_REASON = "permissionDecisionReason" + def _g2(v, gate, decision, name): """Permission gate: `hookSpecificOutput.permissionDecision`, filled from the word table.""" if decision.outcome in (ALLOW, WARN): @@ -449,22 +468,22 @@ def _g2(v, gate, decision, name): return "", 0 out["permissionDecision"] = words["vouch"] if decision.reason: - out["permissionDecisionReason"] = decision.reason + out[_PERMISSION_DECISION_REASON] = decision.reason elif ( decision.outcome == TRANSFORM and gate["honours_transform"] - and not (decision.updated_input is None and "transform_missing_input" in v.get("degrade_notes", {})) + and not (decision.updated_input is None and _TRANSFORM_MISSING_INPUT in v.get("degrade_notes", {})) ): out["permissionDecision"] = words.get("transform", "allow") out["updatedInput"] = decision.updated_input if decision.reason: - out["permissionDecisionReason"] = decision.reason + out[_PERMISSION_DECISION_REASON] = decision.reason elif decision.outcome == ESCALATE and gate["honours_escalate"] and "escalate" in words: out["permissionDecision"] = words["escalate"] - out["permissionDecisionReason"] = decision.reason or _default_for(v, decision, True) + out[_PERMISSION_DECISION_REASON] = decision.reason or _default_for(v, decision, at_gate=True) else: out["permissionDecision"] = words.get("deny", "deny") - out["permissionDecisionReason"] = _refusal_text(v, decision, True) + out[_PERMISSION_DECISION_REASON] = _refusal_text(v, decision, at_gate=True) return _json.dumps({"hookSpecificOutput": out}), 0 def hj_respond(cfg, decision, event, wire=None): @@ -504,49 +523,45 @@ def _hook_dict(cfg, command): entry[key] = value return entry -def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed=True): - """The vendor's hooks-config fragment wiring `command` for these canonical events. +def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): + rules = [] + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + rule = {"event": name, "command": command} + if matcher and hook_entry["matcher"]: + rule["matcher"] = matcher + rules.append(rule) + return rules - `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the - entry says otherwise; a False installs an observer, not a gate. - """ +def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): + gates = cfg["verdicts"]["answer_events"] + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + entry = {"command": command} + if fail_closed and name in gates: + entry["failClosed"] = True + hooks.setdefault(name, []).append(entry) + return {"version": 1, "hooks": hooks} + +def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + hooks.setdefault(name, []).append({"command": command}) + extra = hook_entry.get("also_wires", {}).get(ev) + if extra: + hooks.setdefault(extra, []).append({"command": command}) + return {"hooks": hooks} + +def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] - reverse = hj_reverse(cfg) - wrapper = hook_entry["wrapper"] - if wrapper == "flat_list": - rules = [] - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: - rule["matcher"] = matcher - rules.append(rule) - return rules - if wrapper == "cursor": - gates = cfg["verdicts"]["answer_events"] - hooks = {} - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - entry = {"command": command} - if fail_closed and name in gates: - entry["failClosed"] = True - hooks.setdefault(name, []).append(entry) - return {"version": 1, "hooks": hooks} - if wrapper == "flat_entries": - hooks = {} - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - hooks.setdefault(name, []).append({"command": command}) - extra = hook_entry.get("also_wires", {}).get(ev) - if extra: - hooks.setdefault(extra, []).append({"command": command}) - return {"hooks": hooks} hooks = {} for ev in canonical_events: name = reverse.get(ev) @@ -560,13 +575,30 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed= return {hook_entry["group"]: hooks} return hooks if hook_entry.get("bare") else {"hooks": hooks} +def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_closed=True): + """The vendor's hooks-config fragment wiring `command` for these canonical events. + + `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the + entry says otherwise; a False installs an observer, not a gate. + """ + hook_entry = cfg["hook_entry"] + reverse = hj_reverse(cfg) + wrapper = hook_entry["wrapper"] + if wrapper == "flat_list": + return _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher) + if wrapper == "cursor": + return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) + if wrapper == "flat_entries": + return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + return _default_wrapper(cfg, reverse, canonical_events, command, matcher) + # ------------------------------------------------------------------------------ # codex_cli vendor config + engine binding 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_config.py::test_entries_match_recount'}, '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': {}, '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'}, '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'}}} def claims(raw): diff --git a/tests/fixtures/runtime_goldens/cursor.py b/tests/fixtures/runtime_goldens/cursor.py index 767e653..7e38d7f 100644 --- a/tests/fixtures/runtime_goldens/cursor.py +++ b/tests/fixtures/runtime_goldens/cursor.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.2.0 -- bundle("cursor"). Do not hand-edit, except the +# Generated by agentseam 0.2.1 -- 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. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.2.0) +# contract (agentseam 0.2.1) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -64,17 +64,17 @@ class Event: __slots__ = ( "agent", - "event", - "tool", "command", - "path", "content", + "cwd", + "event", "output", + "path", "prompt", + "raw", "session_id", + "tool", "tool_use_id", - "cwd", - "raw", ) def __init__( @@ -133,7 +133,7 @@ def __repr__(self): # pragma: no cover - debugging aid class Decision: """What a handler wants to happen. Adapters translate this to vendor dialect.""" - __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + __slots__ = ("context", "evidence", "outcome", "reason", "updated_input") #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) @@ -203,7 +203,7 @@ def tool_input_of(raw): if isinstance(raw, str) and raw[:1] == "{": try: parsed = _json.loads(raw) - except Exception: + except _json.JSONDecodeError: return {} if isinstance(parsed, dict): return parsed @@ -305,6 +305,8 @@ def hj_parse(cfg, raw, wire=None): raw=raw, ) +_ESCALATE_FROM_TRANSFORM = "escalate_from_transform" + def hj_reverse(cfg): """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" reverse = {} @@ -325,49 +327,45 @@ def _hook_dict(cfg, command): entry[key] = value return entry -def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed=True): - """The vendor's hooks-config fragment wiring `command` for these canonical events. +def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): + rules = [] + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + rule = {"event": name, "command": command} + if matcher and hook_entry["matcher"]: + rule["matcher"] = matcher + rules.append(rule) + return rules - `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the - entry says otherwise; a False installs an observer, not a gate. - """ +def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): + gates = cfg["verdicts"]["answer_events"] + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + entry = {"command": command} + if fail_closed and name in gates: + entry["failClosed"] = True + hooks.setdefault(name, []).append(entry) + return {"version": 1, "hooks": hooks} + +def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + hooks.setdefault(name, []).append({"command": command}) + extra = hook_entry.get("also_wires", {}).get(ev) + if extra: + hooks.setdefault(extra, []).append({"command": command}) + return {"hooks": hooks} + +def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] - reverse = hj_reverse(cfg) - wrapper = hook_entry["wrapper"] - if wrapper == "flat_list": - rules = [] - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: - rule["matcher"] = matcher - rules.append(rule) - return rules - if wrapper == "cursor": - gates = cfg["verdicts"]["answer_events"] - hooks = {} - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - entry = {"command": command} - if fail_closed and name in gates: - entry["failClosed"] = True - hooks.setdefault(name, []).append(entry) - return {"version": 1, "hooks": hooks} - if wrapper == "flat_entries": - hooks = {} - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - hooks.setdefault(name, []).append({"command": command}) - extra = hook_entry.get("also_wires", {}).get(ev) - if extra: - hooks.setdefault(extra, []).append({"command": command}) - return {"hooks": hooks} hooks = {} for ev in canonical_events: name = reverse.get(ev) @@ -381,6 +379,23 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed= return {hook_entry["group"]: hooks} return hooks if hook_entry.get("bare") else {"hooks": hooks} +def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_closed=True): + """The vendor's hooks-config fragment wiring `command` for these canonical events. + + `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the + entry says otherwise; a False installs an observer, not a gate. + """ + hook_entry = cfg["hook_entry"] + reverse = hj_reverse(cfg) + wrapper = hook_entry["wrapper"] + if wrapper == "flat_list": + return _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher) + if wrapper == "cursor": + return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) + if wrapper == "flat_entries": + return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + return _default_wrapper(cfg, reverse, canonical_events, command, matcher) + _AMBIGUOUS_NAMES = ( "preToolUse", "postToolUse", @@ -432,6 +447,35 @@ def _wire_of(cfg, event): return name return event.tool if event.tool in cfg["events"] else cfg["verdicts"].get("default_wire_event") +def _flag_payload(v, decision, name): + """POST_TOOL/TOOL_FAILURE: a flag can only be raised as additional context, never blocked.""" + if decision.outcome not in (DENY, ESCALATE): + return "", 0 + note = v["flag_note"] % (name, decision.reason or v["flag_note_default"]) + return _json.dumps({"additional_context": note}), 0 + +def _prompt_submit_payload(decision): + payload = {"continue": decision.outcome not in (DENY, ESCALATE, TRANSFORM)} + if decision.reason: + payload["user_message"] = decision.reason + return _json.dumps(payload), 0 + +def _gate_payload(words, notes, gate, decision, name): + """The PRE_TOOL gate's (permission, reason) pair, before the shared trailing message rule.""" + reason = decision.reason + if decision.outcome == TRANSFORM: + if gate["honours_transform"] and decision.updated_input is not None: + return {"permission": words["allow"], "updated_input": decision.updated_input}, reason + return {"permission": words["block"]}, _because(reason, notes["transform"]) + if decision.outcome == DENY: + return {"permission": words["block"]}, reason + if decision.outcome == ESCALATE: + if gate["honours_escalate"]: + return {"permission": words["escalate"]}, reason + note = notes[_ESCALATE_FROM_TRANSFORM] if degraded_from(decision) == TRANSFORM else notes["escalate"] + return {"permission": words["block"]}, _because(reason, note % name) + return {"permission": words["allow"]}, reason + def cursor_respond(cfg, decision, event): v = cfg["verdicts"] name = _wire_of(cfg, event) @@ -439,46 +483,17 @@ def cursor_respond(cfg, decision, event): if canonical == FILE_CHANGED: return "", 0 - if canonical in (POST_TOOL, TOOL_FAILURE): - if decision.outcome in (DENY, ESCALATE): - note = v["flag_note"] % (name, decision.reason or v["flag_note_default"]) - return _json.dumps({"additional_context": note}), 0 - return "", 0 - + return _flag_payload(v, decision, name) if canonical == PROMPT_SUBMIT: - payload = {"continue": decision.outcome not in (DENY, ESCALATE, TRANSFORM)} - if decision.reason: - payload["user_message"] = decision.reason - return _json.dumps(payload), 0 + return _prompt_submit_payload(decision) gate = v["gates"].get(name) if gate is None or canonical != PRE_TOOL: return "", 0 - words = v["words"] - notes = v["degrade_notes"] - reason = decision.reason - - if decision.outcome == TRANSFORM: - if gate["honours_transform"] and decision.updated_input is not None: - payload = {"permission": words["allow"], "updated_input": decision.updated_input} - else: - payload = {"permission": words["block"]} - reason = _because(reason, notes["transform"]) - elif decision.outcome == DENY: - payload = {"permission": words["block"]} - elif decision.outcome == ESCALATE: - if gate["honours_escalate"]: - payload = {"permission": words["escalate"]} - else: - note = notes["escalate_from_transform"] if degraded_from(decision) == TRANSFORM else notes["escalate"] - payload = {"permission": words["block"]} - reason = _because(reason, note % name) - else: - payload = {"permission": words["allow"]} - - if reason and payload["permission"] != words["allow"]: + payload, reason = _gate_payload(v["words"], v["degrade_notes"], gate, decision, name) + if reason and payload["permission"] != v["words"]["allow"]: payload["user_message"] = reason payload["agent_message"] = reason return _json.dumps(payload), 0 diff --git a/tests/fixtures/runtime_goldens/devin.py b/tests/fixtures/runtime_goldens/devin.py index 24a7ffd..c2e7a06 100644 --- a/tests/fixtures/runtime_goldens/devin.py +++ b/tests/fixtures/runtime_goldens/devin.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.2.0 -- bundle("devin"). Do not hand-edit, except the +# Generated by agentseam 0.2.1 -- 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. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.2.0) +# contract (agentseam 0.2.1) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -64,17 +64,17 @@ class Event: __slots__ = ( "agent", - "event", - "tool", "command", - "path", "content", + "cwd", + "event", "output", + "path", "prompt", + "raw", "session_id", + "tool", "tool_use_id", - "cwd", - "raw", ) def __init__( @@ -133,7 +133,7 @@ def __repr__(self): # pragma: no cover - debugging aid class Decision: """What a handler wants to happen. Adapters translate this to vendor dialect.""" - __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + __slots__ = ("context", "evidence", "outcome", "reason", "updated_input") #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) @@ -203,7 +203,7 @@ def tool_input_of(raw): if isinstance(raw, str) and raw[:1] == "{": try: parsed = _json.loads(raw) - except Exception: + except _json.JSONDecodeError: return {} if isinstance(parsed, dict): return parsed @@ -237,6 +237,24 @@ def _wire_name(cfg, raw): return name return None +def _rejected_by_markers(c, raw): + for marker in c.get("reject_markers", ()): + if marker in raw: + return True + for probe, markers in c.get("reject_markers_unless_probe", {}).items(): + if any(marker in raw for marker in markers) and not PROBES[probe](raw): + return True + return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) + +def _accepted_by_markers(c, raw, name): + accept = c.get("accept_markers", ()) + if not accept or any(marker in raw for marker in accept): + return True + return any( + name == event_name and all(key in raw for key in required) + for event_name, required in c.get("accept_when_all", {}).items() + ) + def hj_claims(cfg, raw): """True when this payload matches the entry's marker discipline.""" if not isinstance(raw, dict): @@ -249,22 +267,9 @@ def hj_claims(cfg, raw): return False if "client_types" in c and raw.get("client_type") not in c["client_types"]: return False - for marker in c.get("reject_markers", ()): - if marker in raw: - return False - for probe, markers in c.get("reject_markers_unless_probe", {}).items(): - if any(marker in raw for marker in markers) and not PROBES[probe](raw): - return False - for probe in c.get("reject_probes", ()): - if PROBES[probe](raw): - return False - accept = c.get("accept_markers", ()) - if accept and not any(marker in raw for marker in accept): - for event_name, required in c.get("accept_when_all", {}).items(): - if name == event_name and all(key in raw for key in required): - return True + if _rejected_by_markers(c, raw): return False - return True + return _accepted_by_markers(c, raw, name) def _segment(node, part): """One path segment: a dict key, or `key[N]` indexing the list under it.""" @@ -351,6 +356,10 @@ def hj_parse(cfg, raw, wire=None): raw=raw, ) +_ESCALATE_FROM_TRANSFORM = "escalate_from_transform" + +_TRANSFORM_MISSING_INPUT = "transform_missing_input" + def hj_reverse(cfg): """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" reverse = {} @@ -373,14 +382,14 @@ def _context_body(name, value): def _note_for(v, decision, at_gate, missing_input): notes = v.get("degrade_notes", {}) if decision.outcome == ESCALATE: - if degraded_from(decision) == TRANSFORM and "escalate_from_transform" in notes: - return notes["escalate_from_transform"] + if degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in notes: + return notes[_ESCALATE_FROM_TRANSFORM] if at_gate and "escalate_gate" in notes: return notes["escalate_gate"] return notes.get("escalate") if decision.outcome == TRANSFORM: - if missing_input and "transform_missing_input" in notes: - return notes["transform_missing_input"] + if missing_input and _TRANSFORM_MISSING_INPUT in notes: + return notes[_TRANSFORM_MISSING_INPUT] return notes.get("transform") return None @@ -407,46 +416,54 @@ def _refusal_text(v, decision, at_gate, wire=None): text = "%s (%s)" % (reason, note) if reason and note else (note or reason) return text or default +def _g1_allow(v, decision, wire, name, words): + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + if wire in v.get("allow_silent_events", ()): + return "", 0 + if "allow" in words: + out = {"decision": words["allow"]} + if v.get("allow_context_key") and decision.outcome == ALLOW and value: + out[v["allow_context_key"]] = value + return _json.dumps(out), 0 + return "", 0 + +def _g1_transform(v, decision, name, words): + """None when the transform isn't representable here; `_g1` falls through to the block path.""" + if v.get("transform_grammar") == "hook_specific_tool_input": + return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0 + if decision.updated_input is None: + return None + if v.get("transform_grammar") == "top_level_updated_input": + out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input} + if decision.reason: + out["reason"] = decision.reason + return _json.dumps(out), 0 + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}}), 0 + def _g1(v, gate, decision, wire, name): """Block dialect: a top-level decision word, or silence/context where nothing is read.""" words = dict(v.get("words", {})) words.update(v.get("words_at", {}).get(wire, {})) at_context_event = wire in v.get("context_events", ()) if decision.outcome in (ALLOW, VOUCH, WARN): - value = _context_value(v, decision) - if at_context_event and value: - return _context_body(name, value) - if wire in v.get("allow_silent_events", ()): - return "", 0 - if "allow" in words: - out = {"decision": words["allow"]} - if v.get("allow_context_key") and decision.outcome == ALLOW and value: - out[v["allow_context_key"]] = value - return _json.dumps(out), 0 - return "", 0 + return _g1_allow(v, decision, wire, name, words) if decision.outcome == TRANSFORM and gate["honours_transform"]: - if v.get("transform_grammar") == "hook_specific_tool_input": - return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0 - if decision.updated_input is not None: - if v.get("transform_grammar") == "top_level_updated_input": - out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input} - if decision.reason: - out["reason"] = decision.reason - return _json.dumps(out), 0 - return _json.dumps( - {"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}} - ), 0 + transformed = _g1_transform(v, decision, name, words) + if transformed is not None: + return transformed if ( decision.outcome == ESCALATE and gate["honours_escalate"] and "escalate" in words # An escalate the dispatcher degraded a transform into is a block where the entry # names that degradation (antigravity): prompting would offer the unmodified call. - and not (degraded_from(decision) == TRANSFORM and "escalate_from_transform" in v.get("degrade_notes", {})) + and not (degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in v.get("degrade_notes", {})) ): - reason = decision.reason or _default_for(v, decision, True, wire) + reason = decision.reason or _default_for(v, decision, at_gate=True, wire=wire) return _json.dumps({"decision": words["escalate"], "reason": reason}), 0 - out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, False, wire)} + out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, at_gate=False, wire=wire)} if at_context_event and v.get("context_source") == "context" and decision.context: out["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": decision.context} return _json.dumps(out), 0 @@ -488,49 +505,45 @@ def _hook_dict(cfg, command): entry[key] = value return entry -def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed=True): - """The vendor's hooks-config fragment wiring `command` for these canonical events. +def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): + rules = [] + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + rule = {"event": name, "command": command} + if matcher and hook_entry["matcher"]: + rule["matcher"] = matcher + rules.append(rule) + return rules - `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the - entry says otherwise; a False installs an observer, not a gate. - """ +def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): + gates = cfg["verdicts"]["answer_events"] + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + entry = {"command": command} + if fail_closed and name in gates: + entry["failClosed"] = True + hooks.setdefault(name, []).append(entry) + return {"version": 1, "hooks": hooks} + +def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + hooks.setdefault(name, []).append({"command": command}) + extra = hook_entry.get("also_wires", {}).get(ev) + if extra: + hooks.setdefault(extra, []).append({"command": command}) + return {"hooks": hooks} + +def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] - reverse = hj_reverse(cfg) - wrapper = hook_entry["wrapper"] - if wrapper == "flat_list": - rules = [] - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: - rule["matcher"] = matcher - rules.append(rule) - return rules - if wrapper == "cursor": - gates = cfg["verdicts"]["answer_events"] - hooks = {} - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - entry = {"command": command} - if fail_closed and name in gates: - entry["failClosed"] = True - hooks.setdefault(name, []).append(entry) - return {"version": 1, "hooks": hooks} - if wrapper == "flat_entries": - hooks = {} - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - hooks.setdefault(name, []).append({"command": command}) - extra = hook_entry.get("also_wires", {}).get(ev) - if extra: - hooks.setdefault(extra, []).append({"command": command}) - return {"hooks": hooks} hooks = {} for ev in canonical_events: name = reverse.get(ev) @@ -544,6 +557,23 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed= return {hook_entry["group"]: hooks} return hooks if hook_entry.get("bare") else {"hooks": hooks} +def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_closed=True): + """The vendor's hooks-config fragment wiring `command` for these canonical events. + + `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the + entry says otherwise; a False installs an observer, not a gate. + """ + hook_entry = cfg["hook_entry"] + reverse = hj_reverse(cfg) + wrapper = hook_entry["wrapper"] + if wrapper == "flat_list": + return _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher) + if wrapper == "cursor": + return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) + if wrapper == "flat_entries": + return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + return _default_wrapper(cfg, reverse, canonical_events, command, matcher) + # ------------------------------------------------------------------------------ # devin vendor config + engine binding diff --git a/tests/fixtures/runtime_goldens/gemini_cli.py b/tests/fixtures/runtime_goldens/gemini_cli.py index b95eeb1..c66a9e1 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.2.0 -- bundle("gemini_cli"). Do not hand-edit, except the +# Generated by agentseam 0.2.1 -- 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. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.2.0) +# contract (agentseam 0.2.1) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -64,17 +64,17 @@ class Event: __slots__ = ( "agent", - "event", - "tool", "command", - "path", "content", + "cwd", + "event", "output", + "path", "prompt", + "raw", "session_id", + "tool", "tool_use_id", - "cwd", - "raw", ) def __init__( @@ -133,7 +133,7 @@ def __repr__(self): # pragma: no cover - debugging aid class Decision: """What a handler wants to happen. Adapters translate this to vendor dialect.""" - __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + __slots__ = ("context", "evidence", "outcome", "reason", "updated_input") #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) @@ -203,7 +203,7 @@ def tool_input_of(raw): if isinstance(raw, str) and raw[:1] == "{": try: parsed = _json.loads(raw) - except Exception: + except _json.JSONDecodeError: return {} if isinstance(parsed, dict): return parsed @@ -237,6 +237,24 @@ def _wire_name(cfg, raw): return name return None +def _rejected_by_markers(c, raw): + for marker in c.get("reject_markers", ()): + if marker in raw: + return True + for probe, markers in c.get("reject_markers_unless_probe", {}).items(): + if any(marker in raw for marker in markers) and not PROBES[probe](raw): + return True + return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) + +def _accepted_by_markers(c, raw, name): + accept = c.get("accept_markers", ()) + if not accept or any(marker in raw for marker in accept): + return True + return any( + name == event_name and all(key in raw for key in required) + for event_name, required in c.get("accept_when_all", {}).items() + ) + def hj_claims(cfg, raw): """True when this payload matches the entry's marker discipline.""" if not isinstance(raw, dict): @@ -249,22 +267,9 @@ def hj_claims(cfg, raw): return False if "client_types" in c and raw.get("client_type") not in c["client_types"]: return False - for marker in c.get("reject_markers", ()): - if marker in raw: - return False - for probe, markers in c.get("reject_markers_unless_probe", {}).items(): - if any(marker in raw for marker in markers) and not PROBES[probe](raw): - return False - for probe in c.get("reject_probes", ()): - if PROBES[probe](raw): - return False - accept = c.get("accept_markers", ()) - if accept and not any(marker in raw for marker in accept): - for event_name, required in c.get("accept_when_all", {}).items(): - if name == event_name and all(key in raw for key in required): - return True + if _rejected_by_markers(c, raw): return False - return True + return _accepted_by_markers(c, raw, name) def _segment(node, part): """One path segment: a dict key, or `key[N]` indexing the list under it.""" @@ -351,6 +356,10 @@ def hj_parse(cfg, raw, wire=None): raw=raw, ) +_ESCALATE_FROM_TRANSFORM = "escalate_from_transform" + +_TRANSFORM_MISSING_INPUT = "transform_missing_input" + def hj_reverse(cfg): """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" reverse = {} @@ -373,14 +382,14 @@ def _context_body(name, value): def _note_for(v, decision, at_gate, missing_input): notes = v.get("degrade_notes", {}) if decision.outcome == ESCALATE: - if degraded_from(decision) == TRANSFORM and "escalate_from_transform" in notes: - return notes["escalate_from_transform"] + if degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in notes: + return notes[_ESCALATE_FROM_TRANSFORM] if at_gate and "escalate_gate" in notes: return notes["escalate_gate"] return notes.get("escalate") if decision.outcome == TRANSFORM: - if missing_input and "transform_missing_input" in notes: - return notes["transform_missing_input"] + if missing_input and _TRANSFORM_MISSING_INPUT in notes: + return notes[_TRANSFORM_MISSING_INPUT] return notes.get("transform") return None @@ -407,46 +416,54 @@ def _refusal_text(v, decision, at_gate, wire=None): text = "%s (%s)" % (reason, note) if reason and note else (note or reason) return text or default +def _g1_allow(v, decision, wire, name, words): + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + if wire in v.get("allow_silent_events", ()): + return "", 0 + if "allow" in words: + out = {"decision": words["allow"]} + if v.get("allow_context_key") and decision.outcome == ALLOW and value: + out[v["allow_context_key"]] = value + return _json.dumps(out), 0 + return "", 0 + +def _g1_transform(v, decision, name, words): + """None when the transform isn't representable here; `_g1` falls through to the block path.""" + if v.get("transform_grammar") == "hook_specific_tool_input": + return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0 + if decision.updated_input is None: + return None + if v.get("transform_grammar") == "top_level_updated_input": + out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input} + if decision.reason: + out["reason"] = decision.reason + return _json.dumps(out), 0 + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}}), 0 + def _g1(v, gate, decision, wire, name): """Block dialect: a top-level decision word, or silence/context where nothing is read.""" words = dict(v.get("words", {})) words.update(v.get("words_at", {}).get(wire, {})) at_context_event = wire in v.get("context_events", ()) if decision.outcome in (ALLOW, VOUCH, WARN): - value = _context_value(v, decision) - if at_context_event and value: - return _context_body(name, value) - if wire in v.get("allow_silent_events", ()): - return "", 0 - if "allow" in words: - out = {"decision": words["allow"]} - if v.get("allow_context_key") and decision.outcome == ALLOW and value: - out[v["allow_context_key"]] = value - return _json.dumps(out), 0 - return "", 0 + return _g1_allow(v, decision, wire, name, words) if decision.outcome == TRANSFORM and gate["honours_transform"]: - if v.get("transform_grammar") == "hook_specific_tool_input": - return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0 - if decision.updated_input is not None: - if v.get("transform_grammar") == "top_level_updated_input": - out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input} - if decision.reason: - out["reason"] = decision.reason - return _json.dumps(out), 0 - return _json.dumps( - {"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}} - ), 0 + transformed = _g1_transform(v, decision, name, words) + if transformed is not None: + return transformed if ( decision.outcome == ESCALATE and gate["honours_escalate"] and "escalate" in words # An escalate the dispatcher degraded a transform into is a block where the entry # names that degradation (antigravity): prompting would offer the unmodified call. - and not (degraded_from(decision) == TRANSFORM and "escalate_from_transform" in v.get("degrade_notes", {})) + and not (degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in v.get("degrade_notes", {})) ): - reason = decision.reason or _default_for(v, decision, True, wire) + reason = decision.reason or _default_for(v, decision, at_gate=True, wire=wire) return _json.dumps({"decision": words["escalate"], "reason": reason}), 0 - out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, False, wire)} + out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, at_gate=False, wire=wire)} if at_context_event and v.get("context_source") == "context" and decision.context: out["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": decision.context} return _json.dumps(out), 0 @@ -488,49 +505,45 @@ def _hook_dict(cfg, command): entry[key] = value return entry -def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed=True): - """The vendor's hooks-config fragment wiring `command` for these canonical events. +def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): + rules = [] + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + rule = {"event": name, "command": command} + if matcher and hook_entry["matcher"]: + rule["matcher"] = matcher + rules.append(rule) + return rules - `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the - entry says otherwise; a False installs an observer, not a gate. - """ +def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): + gates = cfg["verdicts"]["answer_events"] + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + entry = {"command": command} + if fail_closed and name in gates: + entry["failClosed"] = True + hooks.setdefault(name, []).append(entry) + return {"version": 1, "hooks": hooks} + +def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + hooks.setdefault(name, []).append({"command": command}) + extra = hook_entry.get("also_wires", {}).get(ev) + if extra: + hooks.setdefault(extra, []).append({"command": command}) + return {"hooks": hooks} + +def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] - reverse = hj_reverse(cfg) - wrapper = hook_entry["wrapper"] - if wrapper == "flat_list": - rules = [] - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: - rule["matcher"] = matcher - rules.append(rule) - return rules - if wrapper == "cursor": - gates = cfg["verdicts"]["answer_events"] - hooks = {} - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - entry = {"command": command} - if fail_closed and name in gates: - entry["failClosed"] = True - hooks.setdefault(name, []).append(entry) - return {"version": 1, "hooks": hooks} - if wrapper == "flat_entries": - hooks = {} - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - hooks.setdefault(name, []).append({"command": command}) - extra = hook_entry.get("also_wires", {}).get(ev) - if extra: - hooks.setdefault(extra, []).append({"command": command}) - return {"hooks": hooks} hooks = {} for ev in canonical_events: name = reverse.get(ev) @@ -544,6 +557,23 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed= return {hook_entry["group"]: hooks} return hooks if hook_entry.get("bare") else {"hooks": hooks} +def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_closed=True): + """The vendor's hooks-config fragment wiring `command` for these canonical events. + + `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the + entry says otherwise; a False installs an observer, not a gate. + """ + hook_entry = cfg["hook_entry"] + reverse = hj_reverse(cfg) + wrapper = hook_entry["wrapper"] + if wrapper == "flat_list": + return _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher) + if wrapper == "cursor": + return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) + if wrapper == "flat_entries": + return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + return _default_wrapper(cfg, reverse, canonical_events, command, matcher) + # ------------------------------------------------------------------------------ # gemini_cli vendor config + engine binding diff --git a/tests/fixtures/runtime_goldens/grok.py b/tests/fixtures/runtime_goldens/grok.py index 76db409..3c5454a 100644 --- a/tests/fixtures/runtime_goldens/grok.py +++ b/tests/fixtures/runtime_goldens/grok.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.2.0 -- bundle("grok"). Do not hand-edit, except the +# Generated by agentseam 0.2.1 -- 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. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.2.0) +# contract (agentseam 0.2.1) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -64,17 +64,17 @@ class Event: __slots__ = ( "agent", - "event", - "tool", "command", - "path", "content", + "cwd", + "event", "output", + "path", "prompt", + "raw", "session_id", + "tool", "tool_use_id", - "cwd", - "raw", ) def __init__( @@ -133,7 +133,7 @@ def __repr__(self): # pragma: no cover - debugging aid class Decision: """What a handler wants to happen. Adapters translate this to vendor dialect.""" - __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + __slots__ = ("context", "evidence", "outcome", "reason", "updated_input") #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) @@ -203,7 +203,7 @@ def tool_input_of(raw): if isinstance(raw, str) and raw[:1] == "{": try: parsed = _json.loads(raw) - except Exception: + except _json.JSONDecodeError: return {} if isinstance(parsed, dict): return parsed @@ -220,6 +220,24 @@ def _wire_name(cfg, raw): return name return None +def _rejected_by_markers(c, raw): + for marker in c.get("reject_markers", ()): + if marker in raw: + return True + for probe, markers in c.get("reject_markers_unless_probe", {}).items(): + if any(marker in raw for marker in markers) and not PROBES[probe](raw): + return True + return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) + +def _accepted_by_markers(c, raw, name): + accept = c.get("accept_markers", ()) + if not accept or any(marker in raw for marker in accept): + return True + return any( + name == event_name and all(key in raw for key in required) + for event_name, required in c.get("accept_when_all", {}).items() + ) + def hj_claims(cfg, raw): """True when this payload matches the entry's marker discipline.""" if not isinstance(raw, dict): @@ -232,22 +250,9 @@ def hj_claims(cfg, raw): return False if "client_types" in c and raw.get("client_type") not in c["client_types"]: return False - for marker in c.get("reject_markers", ()): - if marker in raw: - return False - for probe, markers in c.get("reject_markers_unless_probe", {}).items(): - if any(marker in raw for marker in markers) and not PROBES[probe](raw): - return False - for probe in c.get("reject_probes", ()): - if PROBES[probe](raw): - return False - accept = c.get("accept_markers", ()) - if accept and not any(marker in raw for marker in accept): - for event_name, required in c.get("accept_when_all", {}).items(): - if name == event_name and all(key in raw for key in required): - return True + if _rejected_by_markers(c, raw): return False - return True + return _accepted_by_markers(c, raw, name) def _segment(node, part): """One path segment: a dict key, or `key[N]` indexing the list under it.""" @@ -334,6 +339,10 @@ def hj_parse(cfg, raw, wire=None): raw=raw, ) +_ESCALATE_FROM_TRANSFORM = "escalate_from_transform" + +_TRANSFORM_MISSING_INPUT = "transform_missing_input" + def hj_reverse(cfg): """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" reverse = {} @@ -356,14 +365,14 @@ def _context_body(name, value): def _note_for(v, decision, at_gate, missing_input): notes = v.get("degrade_notes", {}) if decision.outcome == ESCALATE: - if degraded_from(decision) == TRANSFORM and "escalate_from_transform" in notes: - return notes["escalate_from_transform"] + if degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in notes: + return notes[_ESCALATE_FROM_TRANSFORM] if at_gate and "escalate_gate" in notes: return notes["escalate_gate"] return notes.get("escalate") if decision.outcome == TRANSFORM: - if missing_input and "transform_missing_input" in notes: - return notes["transform_missing_input"] + if missing_input and _TRANSFORM_MISSING_INPUT in notes: + return notes[_TRANSFORM_MISSING_INPUT] return notes.get("transform") return None @@ -390,46 +399,54 @@ def _refusal_text(v, decision, at_gate, wire=None): text = "%s (%s)" % (reason, note) if reason and note else (note or reason) return text or default +def _g1_allow(v, decision, wire, name, words): + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + if wire in v.get("allow_silent_events", ()): + return "", 0 + if "allow" in words: + out = {"decision": words["allow"]} + if v.get("allow_context_key") and decision.outcome == ALLOW and value: + out[v["allow_context_key"]] = value + return _json.dumps(out), 0 + return "", 0 + +def _g1_transform(v, decision, name, words): + """None when the transform isn't representable here; `_g1` falls through to the block path.""" + if v.get("transform_grammar") == "hook_specific_tool_input": + return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0 + if decision.updated_input is None: + return None + if v.get("transform_grammar") == "top_level_updated_input": + out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input} + if decision.reason: + out["reason"] = decision.reason + return _json.dumps(out), 0 + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}}), 0 + def _g1(v, gate, decision, wire, name): """Block dialect: a top-level decision word, or silence/context where nothing is read.""" words = dict(v.get("words", {})) words.update(v.get("words_at", {}).get(wire, {})) at_context_event = wire in v.get("context_events", ()) if decision.outcome in (ALLOW, VOUCH, WARN): - value = _context_value(v, decision) - if at_context_event and value: - return _context_body(name, value) - if wire in v.get("allow_silent_events", ()): - return "", 0 - if "allow" in words: - out = {"decision": words["allow"]} - if v.get("allow_context_key") and decision.outcome == ALLOW and value: - out[v["allow_context_key"]] = value - return _json.dumps(out), 0 - return "", 0 + return _g1_allow(v, decision, wire, name, words) if decision.outcome == TRANSFORM and gate["honours_transform"]: - if v.get("transform_grammar") == "hook_specific_tool_input": - return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0 - if decision.updated_input is not None: - if v.get("transform_grammar") == "top_level_updated_input": - out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input} - if decision.reason: - out["reason"] = decision.reason - return _json.dumps(out), 0 - return _json.dumps( - {"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}} - ), 0 + transformed = _g1_transform(v, decision, name, words) + if transformed is not None: + return transformed if ( decision.outcome == ESCALATE and gate["honours_escalate"] and "escalate" in words # An escalate the dispatcher degraded a transform into is a block where the entry # names that degradation (antigravity): prompting would offer the unmodified call. - and not (degraded_from(decision) == TRANSFORM and "escalate_from_transform" in v.get("degrade_notes", {})) + and not (degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in v.get("degrade_notes", {})) ): - reason = decision.reason or _default_for(v, decision, True, wire) + reason = decision.reason or _default_for(v, decision, at_gate=True, wire=wire) return _json.dumps({"decision": words["escalate"], "reason": reason}), 0 - out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, False, wire)} + out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, at_gate=False, wire=wire)} if at_context_event and v.get("context_source") == "context" and decision.context: out["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": decision.context} return _json.dumps(out), 0 @@ -471,49 +488,45 @@ def _hook_dict(cfg, command): entry[key] = value return entry -def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed=True): - """The vendor's hooks-config fragment wiring `command` for these canonical events. +def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): + rules = [] + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + rule = {"event": name, "command": command} + if matcher and hook_entry["matcher"]: + rule["matcher"] = matcher + rules.append(rule) + return rules - `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the - entry says otherwise; a False installs an observer, not a gate. - """ +def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): + gates = cfg["verdicts"]["answer_events"] + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + entry = {"command": command} + if fail_closed and name in gates: + entry["failClosed"] = True + hooks.setdefault(name, []).append(entry) + return {"version": 1, "hooks": hooks} + +def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + hooks.setdefault(name, []).append({"command": command}) + extra = hook_entry.get("also_wires", {}).get(ev) + if extra: + hooks.setdefault(extra, []).append({"command": command}) + return {"hooks": hooks} + +def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] - reverse = hj_reverse(cfg) - wrapper = hook_entry["wrapper"] - if wrapper == "flat_list": - rules = [] - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: - rule["matcher"] = matcher - rules.append(rule) - return rules - if wrapper == "cursor": - gates = cfg["verdicts"]["answer_events"] - hooks = {} - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - entry = {"command": command} - if fail_closed and name in gates: - entry["failClosed"] = True - hooks.setdefault(name, []).append(entry) - return {"version": 1, "hooks": hooks} - if wrapper == "flat_entries": - hooks = {} - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - hooks.setdefault(name, []).append({"command": command}) - extra = hook_entry.get("also_wires", {}).get(ev) - if extra: - hooks.setdefault(extra, []).append({"command": command}) - return {"hooks": hooks} hooks = {} for ev in canonical_events: name = reverse.get(ev) @@ -527,6 +540,23 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed= return {hook_entry["group"]: hooks} return hooks if hook_entry.get("bare") else {"hooks": hooks} +def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_closed=True): + """The vendor's hooks-config fragment wiring `command` for these canonical events. + + `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the + entry says otherwise; a False installs an observer, not a gate. + """ + hook_entry = cfg["hook_entry"] + reverse = hj_reverse(cfg) + wrapper = hook_entry["wrapper"] + if wrapper == "flat_list": + return _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher) + if wrapper == "cursor": + return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) + if wrapper == "flat_entries": + return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + return _default_wrapper(cfg, reverse, canonical_events, command, matcher) + # ------------------------------------------------------------------------------ # grok vendor config + engine binding diff --git a/tests/fixtures/runtime_goldens/tabnine.py b/tests/fixtures/runtime_goldens/tabnine.py index 91fa650..9670808 100644 --- a/tests/fixtures/runtime_goldens/tabnine.py +++ b/tests/fixtures/runtime_goldens/tabnine.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.2.0 -- bundle("tabnine"). Do not hand-edit, except the +# Generated by agentseam 0.2.1 -- 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. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.2.0) +# contract (agentseam 0.2.1) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -64,17 +64,17 @@ class Event: __slots__ = ( "agent", - "event", - "tool", "command", - "path", "content", + "cwd", + "event", "output", + "path", "prompt", + "raw", "session_id", + "tool", "tool_use_id", - "cwd", - "raw", ) def __init__( @@ -133,7 +133,7 @@ def __repr__(self): # pragma: no cover - debugging aid class Decision: """What a handler wants to happen. Adapters translate this to vendor dialect.""" - __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + __slots__ = ("context", "evidence", "outcome", "reason", "updated_input") #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) @@ -203,7 +203,7 @@ def tool_input_of(raw): if isinstance(raw, str) and raw[:1] == "{": try: parsed = _json.loads(raw) - except Exception: + except _json.JSONDecodeError: return {} if isinstance(parsed, dict): return parsed @@ -220,6 +220,24 @@ def _wire_name(cfg, raw): return name return None +def _rejected_by_markers(c, raw): + for marker in c.get("reject_markers", ()): + if marker in raw: + return True + for probe, markers in c.get("reject_markers_unless_probe", {}).items(): + if any(marker in raw for marker in markers) and not PROBES[probe](raw): + return True + return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) + +def _accepted_by_markers(c, raw, name): + accept = c.get("accept_markers", ()) + if not accept or any(marker in raw for marker in accept): + return True + return any( + name == event_name and all(key in raw for key in required) + for event_name, required in c.get("accept_when_all", {}).items() + ) + def hj_claims(cfg, raw): """True when this payload matches the entry's marker discipline.""" if not isinstance(raw, dict): @@ -232,22 +250,9 @@ def hj_claims(cfg, raw): return False if "client_types" in c and raw.get("client_type") not in c["client_types"]: return False - for marker in c.get("reject_markers", ()): - if marker in raw: - return False - for probe, markers in c.get("reject_markers_unless_probe", {}).items(): - if any(marker in raw for marker in markers) and not PROBES[probe](raw): - return False - for probe in c.get("reject_probes", ()): - if PROBES[probe](raw): - return False - accept = c.get("accept_markers", ()) - if accept and not any(marker in raw for marker in accept): - for event_name, required in c.get("accept_when_all", {}).items(): - if name == event_name and all(key in raw for key in required): - return True + if _rejected_by_markers(c, raw): return False - return True + return _accepted_by_markers(c, raw, name) def _segment(node, part): """One path segment: a dict key, or `key[N]` indexing the list under it.""" @@ -334,6 +339,10 @@ def hj_parse(cfg, raw, wire=None): raw=raw, ) +_ESCALATE_FROM_TRANSFORM = "escalate_from_transform" + +_TRANSFORM_MISSING_INPUT = "transform_missing_input" + def hj_reverse(cfg): """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" reverse = {} @@ -356,14 +365,14 @@ def _context_body(name, value): def _note_for(v, decision, at_gate, missing_input): notes = v.get("degrade_notes", {}) if decision.outcome == ESCALATE: - if degraded_from(decision) == TRANSFORM and "escalate_from_transform" in notes: - return notes["escalate_from_transform"] + if degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in notes: + return notes[_ESCALATE_FROM_TRANSFORM] if at_gate and "escalate_gate" in notes: return notes["escalate_gate"] return notes.get("escalate") if decision.outcome == TRANSFORM: - if missing_input and "transform_missing_input" in notes: - return notes["transform_missing_input"] + if missing_input and _TRANSFORM_MISSING_INPUT in notes: + return notes[_TRANSFORM_MISSING_INPUT] return notes.get("transform") return None @@ -390,46 +399,54 @@ def _refusal_text(v, decision, at_gate, wire=None): text = "%s (%s)" % (reason, note) if reason and note else (note or reason) return text or default +def _g1_allow(v, decision, wire, name, words): + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + if wire in v.get("allow_silent_events", ()): + return "", 0 + if "allow" in words: + out = {"decision": words["allow"]} + if v.get("allow_context_key") and decision.outcome == ALLOW and value: + out[v["allow_context_key"]] = value + return _json.dumps(out), 0 + return "", 0 + +def _g1_transform(v, decision, name, words): + """None when the transform isn't representable here; `_g1` falls through to the block path.""" + if v.get("transform_grammar") == "hook_specific_tool_input": + return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0 + if decision.updated_input is None: + return None + if v.get("transform_grammar") == "top_level_updated_input": + out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input} + if decision.reason: + out["reason"] = decision.reason + return _json.dumps(out), 0 + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}}), 0 + def _g1(v, gate, decision, wire, name): """Block dialect: a top-level decision word, or silence/context where nothing is read.""" words = dict(v.get("words", {})) words.update(v.get("words_at", {}).get(wire, {})) at_context_event = wire in v.get("context_events", ()) if decision.outcome in (ALLOW, VOUCH, WARN): - value = _context_value(v, decision) - if at_context_event and value: - return _context_body(name, value) - if wire in v.get("allow_silent_events", ()): - return "", 0 - if "allow" in words: - out = {"decision": words["allow"]} - if v.get("allow_context_key") and decision.outcome == ALLOW and value: - out[v["allow_context_key"]] = value - return _json.dumps(out), 0 - return "", 0 + return _g1_allow(v, decision, wire, name, words) if decision.outcome == TRANSFORM and gate["honours_transform"]: - if v.get("transform_grammar") == "hook_specific_tool_input": - return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0 - if decision.updated_input is not None: - if v.get("transform_grammar") == "top_level_updated_input": - out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input} - if decision.reason: - out["reason"] = decision.reason - return _json.dumps(out), 0 - return _json.dumps( - {"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}} - ), 0 + transformed = _g1_transform(v, decision, name, words) + if transformed is not None: + return transformed if ( decision.outcome == ESCALATE and gate["honours_escalate"] and "escalate" in words # An escalate the dispatcher degraded a transform into is a block where the entry # names that degradation (antigravity): prompting would offer the unmodified call. - and not (degraded_from(decision) == TRANSFORM and "escalate_from_transform" in v.get("degrade_notes", {})) + and not (degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in v.get("degrade_notes", {})) ): - reason = decision.reason or _default_for(v, decision, True, wire) + reason = decision.reason or _default_for(v, decision, at_gate=True, wire=wire) return _json.dumps({"decision": words["escalate"], "reason": reason}), 0 - out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, False, wire)} + out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, at_gate=False, wire=wire)} if at_context_event and v.get("context_source") == "context" and decision.context: out["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": decision.context} return _json.dumps(out), 0 @@ -471,49 +488,45 @@ def _hook_dict(cfg, command): entry[key] = value return entry -def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed=True): - """The vendor's hooks-config fragment wiring `command` for these canonical events. +def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): + rules = [] + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + rule = {"event": name, "command": command} + if matcher and hook_entry["matcher"]: + rule["matcher"] = matcher + rules.append(rule) + return rules - `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the - entry says otherwise; a False installs an observer, not a gate. - """ +def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): + gates = cfg["verdicts"]["answer_events"] + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + entry = {"command": command} + if fail_closed and name in gates: + entry["failClosed"] = True + hooks.setdefault(name, []).append(entry) + return {"version": 1, "hooks": hooks} + +def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + hooks.setdefault(name, []).append({"command": command}) + extra = hook_entry.get("also_wires", {}).get(ev) + if extra: + hooks.setdefault(extra, []).append({"command": command}) + return {"hooks": hooks} + +def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] - reverse = hj_reverse(cfg) - wrapper = hook_entry["wrapper"] - if wrapper == "flat_list": - rules = [] - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: - rule["matcher"] = matcher - rules.append(rule) - return rules - if wrapper == "cursor": - gates = cfg["verdicts"]["answer_events"] - hooks = {} - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - entry = {"command": command} - if fail_closed and name in gates: - entry["failClosed"] = True - hooks.setdefault(name, []).append(entry) - return {"version": 1, "hooks": hooks} - if wrapper == "flat_entries": - hooks = {} - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - hooks.setdefault(name, []).append({"command": command}) - extra = hook_entry.get("also_wires", {}).get(ev) - if extra: - hooks.setdefault(extra, []).append({"command": command}) - return {"hooks": hooks} hooks = {} for ev in canonical_events: name = reverse.get(ev) @@ -527,6 +540,23 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed= return {hook_entry["group"]: hooks} return hooks if hook_entry.get("bare") else {"hooks": hooks} +def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_closed=True): + """The vendor's hooks-config fragment wiring `command` for these canonical events. + + `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the + entry says otherwise; a False installs an observer, not a gate. + """ + hook_entry = cfg["hook_entry"] + reverse = hj_reverse(cfg) + wrapper = hook_entry["wrapper"] + if wrapper == "flat_list": + return _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher) + if wrapper == "cursor": + return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) + if wrapper == "flat_entries": + return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + return _default_wrapper(cfg, reverse, canonical_events, command, matcher) + # ------------------------------------------------------------------------------ # tabnine vendor config + engine binding diff --git a/tests/fixtures/runtime_goldens/vscode_copilot.py b/tests/fixtures/runtime_goldens/vscode_copilot.py index b3000b6..b27f465 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.2.0 -- bundle("vscode_copilot"). Do not hand-edit, except the +# Generated by agentseam 0.2.1 -- 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. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.2.0) +# contract (agentseam 0.2.1) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -64,17 +64,17 @@ class Event: __slots__ = ( "agent", - "event", - "tool", "command", - "path", "content", + "cwd", + "event", "output", + "path", "prompt", + "raw", "session_id", + "tool", "tool_use_id", - "cwd", - "raw", ) def __init__( @@ -133,7 +133,7 @@ def __repr__(self): # pragma: no cover - debugging aid class Decision: """What a handler wants to happen. Adapters translate this to vendor dialect.""" - __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + __slots__ = ("context", "evidence", "outcome", "reason", "updated_input") #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) @@ -203,7 +203,7 @@ def tool_input_of(raw): if isinstance(raw, str) and raw[:1] == "{": try: parsed = _json.loads(raw) - except Exception: + except _json.JSONDecodeError: return {} if isinstance(parsed, dict): return parsed @@ -251,6 +251,10 @@ def powershell_command(command): MEMORY_TOOLS = ("memory", "copilot_memory") MEMORY_WRITE_COMMANDS = ("create", "str_replace", "insert") +#: Copilot's hooks reference ("Tool names for hook matching", read 2026-09-01): runtime +#: names bash (Unix) / powershell (Windows), reported as Claude's Bash in PascalCase payloads. +SHELL_TOOLS = ("bash", "powershell", "Bash") + _CODEX_MARKERS = ("turn_id", "permission_mode") @@ -337,45 +341,63 @@ def _refusal_reason(decision): DECISION_VOCABULARY = frozenset({"allow", "deny", "ask", "block"}) +#: The permission-object dialect's one reason field, named four times below. +_PERMISSION_DECISION_REASON = "permissionDecisionReason" -def respond(decision, event): - """Three dialects, one per event group -- not one gate shape everywhere.""" - import json as _json - if event.event in _TOP_LEVEL_BLOCK: - if decision.outcome in (ALLOW, VOUCH): - return "", 0 - return _json.dumps({"decision": "block", "reason": _refusal_reason(decision)}), 0 +def _top_level_block_body(decision): + """The dict to serialize, or None where nothing blocks -- always exit 0.""" + if decision.outcome in (ALLOW, VOUCH): + return None + return {"decision": "block", "reason": _refusal_reason(decision)} - if event.event in _NESTED_BLOCK: - if decision.outcome in (ALLOW, VOUCH): - return "", 0 - out = {"hookEventName": _echoed_name(event), "decision": "block", "reason": _refusal_reason(decision)} - return _json.dumps({"hookSpecificOutput": out}), 0 - if event.event != PRE_TOOL: - return "", 0 +def _nested_block_body(decision, event): + if decision.outcome in (ALLOW, VOUCH): + return None + out = {"hookEventName": _echoed_name(event), "decision": "block", "reason": _refusal_reason(decision)} + return {"hookSpecificOutput": out} - if decision.outcome == ALLOW: - return "", 0 +def _pre_tool_out(decision, event): out = {"hookEventName": _echoed_name(event)} if decision.outcome == VOUCH: out["permissionDecision"] = "allow" if decision.reason: - out["permissionDecisionReason"] = decision.reason + out[_PERMISSION_DECISION_REASON] = decision.reason elif decision.outcome == DENY: out["permissionDecision"] = "deny" - out["permissionDecisionReason"] = decision.reason or "blocked" + out[_PERMISSION_DECISION_REASON] = decision.reason or "blocked" elif decision.outcome == ASK: out["permissionDecision"] = "ask" - out["permissionDecisionReason"] = decision.reason or "confirmation required" + out[_PERMISSION_DECISION_REASON] = decision.reason or "confirmation required" elif decision.outcome == REWRITE: out["permissionDecision"] = "allow" out["updatedInput"] = decision.updated_input if decision.reason: - out["permissionDecisionReason"] = decision.reason - return _json.dumps({"hookSpecificOutput": out}), 0 + out[_PERMISSION_DECISION_REASON] = decision.reason + return out + + +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 + # imports untouched -- only top-level imports get hoisted into a bundle; see + # test_function_local_imports_are_left_alone -- so hoisting this would only move it, not + # remove it, while touching every response line for no behavioral gain) + + if event.event in _TOP_LEVEL_BLOCK: + body = _top_level_block_body(decision) + return ("", 0) if body is None else (_json.dumps(body), 0) + + if event.event in _NESTED_BLOCK: + body = _nested_block_body(decision, event) + return ("", 0) if body is None else (_json.dumps(body), 0) + + if event.event != PRE_TOOL or decision.outcome == ALLOW: + return "", 0 + + return _json.dumps({"hookSpecificOutput": _pre_tool_out(decision, event)}), 0 REVERSE_EVENT_MAP = { @@ -389,7 +411,9 @@ def respond(decision, event): } -def hook_config(canonical_events, command, matcher=None): +def hook_config(canonical_events, command, matcher=None): # noqa: ARG001 (every adapter's + # hook_config(..., matcher=) is called uniformly by install.py/install_identity.py; + # VS Code has no per-tool matcher to honour, but the parameter stays for interface parity) """The hooks file VS Code actually parses: an object keyed by event name.""" hooks = {} for ev in canonical_events: diff --git a/tests/fixtures/runtime_goldens/windsurf.py b/tests/fixtures/runtime_goldens/windsurf.py index a3c13c1..4dd37fe 100644 --- a/tests/fixtures/runtime_goldens/windsurf.py +++ b/tests/fixtures/runtime_goldens/windsurf.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.2.0 -- bundle("windsurf"). Do not hand-edit, except the +# Generated by agentseam 0.2.1 -- 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. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.2.0) +# contract (agentseam 0.2.1) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -64,17 +64,17 @@ class Event: __slots__ = ( "agent", - "event", - "tool", "command", - "path", "content", + "cwd", + "event", "output", + "path", "prompt", + "raw", "session_id", + "tool", "tool_use_id", - "cwd", - "raw", ) def __init__( @@ -133,7 +133,7 @@ def __repr__(self): # pragma: no cover - debugging aid class Decision: """What a handler wants to happen. Adapters translate this to vendor dialect.""" - __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + __slots__ = ("context", "evidence", "outcome", "reason", "updated_input") #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) @@ -203,7 +203,7 @@ def tool_input_of(raw): if isinstance(raw, str) and raw[:1] == "{": try: parsed = _json.loads(raw) - except Exception: + except _json.JSONDecodeError: return {} if isinstance(parsed, dict): return parsed @@ -305,6 +305,10 @@ def hj_parse(cfg, raw, wire=None): raw=raw, ) +_ESCALATE_FROM_TRANSFORM = "escalate_from_transform" + +_TRANSFORM_MISSING_INPUT = "transform_missing_input" + def hj_reverse(cfg): """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" reverse = {} @@ -317,14 +321,14 @@ def hj_reverse(cfg): def _note_for(v, decision, at_gate, missing_input): notes = v.get("degrade_notes", {}) if decision.outcome == ESCALATE: - if degraded_from(decision) == TRANSFORM and "escalate_from_transform" in notes: - return notes["escalate_from_transform"] + if degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in notes: + return notes[_ESCALATE_FROM_TRANSFORM] if at_gate and "escalate_gate" in notes: return notes["escalate_gate"] return notes.get("escalate") if decision.outcome == TRANSFORM: - if missing_input and "transform_missing_input" in notes: - return notes["transform_missing_input"] + if missing_input and _TRANSFORM_MISSING_INPUT in notes: + return notes[_TRANSFORM_MISSING_INPUT] return notes.get("transform") return None @@ -362,49 +366,45 @@ def _hook_dict(cfg, command): entry[key] = value return entry -def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed=True): - """The vendor's hooks-config fragment wiring `command` for these canonical events. +def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): + rules = [] + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + rule = {"event": name, "command": command} + if matcher and hook_entry["matcher"]: + rule["matcher"] = matcher + rules.append(rule) + return rules - `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the - entry says otherwise; a False installs an observer, not a gate. - """ +def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): + gates = cfg["verdicts"]["answer_events"] + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + entry = {"command": command} + if fail_closed and name in gates: + entry["failClosed"] = True + hooks.setdefault(name, []).append(entry) + return {"version": 1, "hooks": hooks} + +def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + hooks.setdefault(name, []).append({"command": command}) + extra = hook_entry.get("also_wires", {}).get(ev) + if extra: + hooks.setdefault(extra, []).append({"command": command}) + return {"hooks": hooks} + +def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] - reverse = hj_reverse(cfg) - wrapper = hook_entry["wrapper"] - if wrapper == "flat_list": - rules = [] - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"]: - rule["matcher"] = matcher - rules.append(rule) - return rules - if wrapper == "cursor": - gates = cfg["verdicts"]["answer_events"] - hooks = {} - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - entry = {"command": command} - if fail_closed and name in gates: - entry["failClosed"] = True - hooks.setdefault(name, []).append(entry) - return {"version": 1, "hooks": hooks} - if wrapper == "flat_entries": - hooks = {} - for ev in canonical_events: - name = reverse.get(ev) - if not name: - continue - hooks.setdefault(name, []).append({"command": command}) - extra = hook_entry.get("also_wires", {}).get(ev) - if extra: - hooks.setdefault(extra, []).append({"command": command}) - return {"hooks": hooks} hooks = {} for ev in canonical_events: name = reverse.get(ev) @@ -418,6 +418,23 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, fail_closed= return {hook_entry["group"]: hooks} return hooks if hook_entry.get("bare") else {"hooks": hooks} +def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_closed=True): + """The vendor's hooks-config fragment wiring `command` for these canonical events. + + `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the + entry says otherwise; a False installs an observer, not a gate. + """ + hook_entry = cfg["hook_entry"] + reverse = hj_reverse(cfg) + wrapper = hook_entry["wrapper"] + if wrapper == "flat_list": + return _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher) + if wrapper == "cursor": + return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) + if wrapper == "flat_entries": + return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + return _default_wrapper(cfg, reverse, canonical_events, command, matcher) + _MCP_EVENTS = ("pre_mcp_tool_use", "post_mcp_tool_use") def windsurf_wire(raw): @@ -454,7 +471,7 @@ def windsurf_respond(cfg, decision, event): wire = windsurf_wire(event.raw) if event.raw else "" if wire not in v["gates"]: return v["flag_note"] % (wire, decision.reason or v["flag_note_default"]), 0 - return _refusal_text(v, decision, False, wire), 2 + return _refusal_text(v, decision, at_gate=False, wire=wire), 2 # ------------------------------------------------------------------------------ diff --git a/tests/test_copilot_hook_runtime.py b/tests/test_copilot_hook_runtime.py index 69f3c4c..6d1e9f5 100644 --- a/tests/test_copilot_hook_runtime.py +++ b/tests/test_copilot_hook_runtime.py @@ -10,6 +10,7 @@ import pytest import yaml +from chock import vendors from chock.gate import runtime_bundle from chock.plugin.claude import claude_plugin_files from chock.plugin.copilot import HOOKS_REL, build_copilot_plugin, copilot_plugin_files @@ -74,14 +75,15 @@ def test_hook_allows_when_the_plugin_root_cannot_be_resolved(policy, tmp_path: P def test_copilot_and_claude_packages_run_the_same_hook(policy, tmp_path: Path) -> None: - """Two formats, one enforcement system -- same matcher, same guard bytes, own dialect.""" + """Two formats, one enforcement system -- each vendor's own matcher, same guard bytes, own dialect.""" pack = policy(GUARD_MANIFEST, guard=True) copilot = copilot_plugin_files(pack, GUARD_MANIFEST, tmp_path) claude = claude_plugin_files(pack, GUARD_MANIFEST, tmp_path) copilot_entry = json.loads(copilot[Path(HOOKS_REL)])["hooks"]["PreToolUse"][0] claude_entry = json.loads(claude[Path("hooks/hooks.json")])["hooks"]["PreToolUse"][0] - assert copilot_entry["matcher"] == claude_entry["matcher"] + assert copilot_entry["matcher"] == vendors.shell_matcher("vscode_copilot") + assert claude_entry["matcher"] == vendors.shell_matcher("claude_code") assert copilot_entry["hooks"][0]["timeout"] == claude_entry["hooks"][0]["timeout"] copilot_command = copilot_entry["hooks"][0]["command"] claude_command = claude_entry["hooks"][0]["command"] diff --git a/tests/test_copilot_plugin.py b/tests/test_copilot_plugin.py index 37f9bf2..9fcb1fe 100644 --- a/tests/test_copilot_plugin.py +++ b/tests/test_copilot_plugin.py @@ -8,6 +8,7 @@ import pytest import yaml +from chock import vendors from chock.gate import runtime_bundle from chock.plugin.build import build_skill from chock.plugin.claude import POSTURE_ADVISORY @@ -78,7 +79,7 @@ def test_hook_lives_in_the_copilot_namespace(policy, tmp_path: Path) -> None: hooks = json.loads((out / "com.github.copilot" / "hooks" / "hooks.json").read_text(encoding="utf-8")) entry = hooks["hooks"]["PreToolUse"][0] - assert entry["matcher"] == "Bash" + assert entry["matcher"] == vendors.shell_matcher("vscode_copilot") command = entry["hooks"][0]["command"] assert command == ( 'r="${PLUGIN_ROOT:-}"; [ -n "$r" ] && [ -f "$r/scripts/vscode_copilot.py" ] || exit 0; ' diff --git a/tests/test_vendor_wire_facts.py b/tests/test_vendor_wire_facts.py index 03586c9..c19fbd7 100644 --- a/tests/test_vendor_wire_facts.py +++ b/tests/test_vendor_wire_facts.py @@ -38,18 +38,29 @@ def test_every_wired_vendor_has_a_public_vendor_entry() -> None: assert vendor in VENDOR_CONFIG, f"{vendor} lost its agentseam vendor-config entry" -def test_shell_vocabulary_borrow_is_still_a_gap_upstream() -> None: - """codex_cli/vscode_copilot claude-format hooks borrow claude_code's matcher (in_agent.MATCHER). +def test_shell_vocabulary_is_derived_per_vendor_not_borrowed() -> None: + """codex_cli/vscode_copilot claude-format hooks used to borrow claude_code's matcher. - The borrow is legitimate only while agentseam records no shell vocabulary for them; the - day either records one, derive it there instead of borrowing. + agentseam now records a shell vocabulary for both, so in_agent.hooks_map_file must read + each vendor's own tools.shell (vendors.shell_matcher) instead of falling back to + 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",) for vendor in ("codex_cli", "vscode_copilot"): - assert adapters.shell_tools(vendor) == (), ( - f"agentseam now records a shell vocabulary for {vendor}; " - f"stop borrowing claude_code's matcher and derive it (see in_agent.MATCHER)" - ) + assert adapters.shell_tools(vendor), f"agentseam records no shell vocabulary for {vendor} anymore" + + vscode_matcher = vendors.shell_matcher("vscode_copilot") + assert vscode_matcher != in_agent.MATCHER, ( + "vscode_copilot's own derived matcher happens to equal claude_code's -- " + "pick a different vendor to prove derivation, not the borrowed constant" + ) + (rendered_entry,) = in_agent.hooks_map_file("vscode_copilot", "CMD")["hooks"][ + vendors.pre_tool_event("vscode_copilot") + ] + assert rendered_entry["matcher"] == vscode_matcher, ( + "hooks_map_file still borrows claude_code's MATCHER instead of deriving vscode_copilot's own" + ) def test_agent_hooks_shape_is_a_witnessed_override_until_upstream_ingests_it() -> None: @@ -75,11 +86,16 @@ def test_agent_hooks_shape_is_a_witnessed_override_until_upstream_ingests_it() - ) -def test_repo_root_token_still_has_no_vendor_config_field() -> None: - """The `${CLAUDE_PROJECT_DIR}` wire token lives in chock only while D2's schema lacks it.""" - assert in_agent.PROJECT_DIR_TOKEN == "${CLAUDE_PROJECT_DIR}" - suspects = [key for key in SCHEMA["properties"] if "root" in key or "token" in key] - assert not suspects, f"agentseam's vendor-config schema grew {suspects}; derive PROJECT_DIR_TOKEN from it" +def test_repo_root_token_is_derived_from_vendor_config() -> None: + """The `${CLAUDE_PROJECT_DIR}` wire token used to live in chock as a hardcoded copy. + + agentseam's vendor-config schema now carries `repo_root_token`; PROJECT_DIR_TOKEN must + read it via vendors.repo_root_token instead. If a future release drops the field again, + this fails and says to hardcode the token back. + """ + assert "repo_root_token" in SCHEMA["properties"], "agentseam's vendor-config schema lost repo_root_token" + assert VENDOR_CONFIG["claude_code"]["repo_root_token"] == "${CLAUDE_PROJECT_DIR}" + assert vendors.repo_root_token("claude_code") == in_agent.PROJECT_DIR_TOKEN == "${CLAUDE_PROJECT_DIR}" def test_cursor_fail_closed_stays_unset_pending_the_owner_decision() -> None: From d79f47bd3c6aeccc9207bebab34159620180a327 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 11:01:44 +0000 Subject: [PATCH 2/2] Bump requirements/brand-assets.in's agentseam pin to 0.2.1 too The brand-assets CI job installs this separate pip-compile lockfile, then imports chock's own in_agent.py to render the social-preview SVG. That module now asserts PROJECT_DIR_TOKEN is not None at import time, derived from agentseam's repo_root_token -- a field 0.2.0 (still pinned here) doesn't have, so the job failed importing chock's own source. Regenerated requirements/brand-assets.txt with pip-compile to match. Signed-off-by: Claude --- requirements/brand-assets.in | 2 +- requirements/brand-assets.txt | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements/brand-assets.in b/requirements/brand-assets.in index 5a4ce58..d3a5c79 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.2.0 +agentseam==0.2.1 cairosvg==2.9.0 diff --git a/requirements/brand-assets.txt b/requirements/brand-assets.txt index 5e43af8..17a3f73 100644 --- a/requirements/brand-assets.txt +++ b/requirements/brand-assets.txt @@ -4,9 +4,9 @@ # # pip-compile --generate-hashes --output-file=requirements/brand-assets.txt --strip-extras requirements/brand-assets.in # -agentseam==0.2.0 \ - --hash=sha256:cf92114fc239de59d3b3bbf177ba3eaa69d465bd7cd4d9d047f1ec1358c9e0d6 \ - --hash=sha256:fd63d96d1bfe2058bf7edb1d9172eaa9db4b245c567bf8a9cb09e0485dff0816 +agentseam==0.2.1 \ + --hash=sha256:22b322c7b48229fb8db5525b2f3c30894d455a4b9a66dbb24c98dce861ea1940 \ + --hash=sha256:b9371bf4d6d95ad73a47a26303324fc1344f0701518f4126d95da334471a7fd7 # via -r requirements/brand-assets.in attrs==26.1.0 \ --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \