diff --git a/.agents/hooks.json b/.agents/hooks.json new file mode 100644 index 0000000..b0b1e07 --- /dev/null +++ b/.agents/hooks.json @@ -0,0 +1,38 @@ +{ + "agentseam": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python\" \".chock/bin/antigravity.py\" --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python\" \".chock/bin/antigravity.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python\" \".chock/bin/antigravity.py\" --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python\" \".chock/bin/antigravity.py\" --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/bin/antigravity.py b/.chock/bin/antigravity.py new file mode 100755 index 0000000..e08fd43 --- /dev/null +++ b/.chock/bin/antigravity.py @@ -0,0 +1,763 @@ +# Generated by agentseam 0.2.0 -- 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. +# +# Self-contained: stdlib only, no "import agentseam" anywhere in this file. Regenerate +# with `agentseam.bundler.bundle("antigravity")` (same agentseam version -> identical bytes, +# except your own edits inside the handler block) rather than patching this by hand. +# https://github.com/open-coder-ai/agentseam + +from __future__ import annotations + +import json +_json = json +import sys + +import os as _chock_os +import shlex as _chock_shlex +import subprocess as _chock_subprocess +from datetime import datetime as _chock_datetime, timezone as _chock_timezone +from pathlib import Path as _chock_Path +import warnings as _warnings + +# ------------------------------------------------------------------------------ +# contract (agentseam 0.2.0) + +"""Canonical event vocabulary, normalized envelope, and decision type.""" + + + +SESSION_START = "session_start" +SESSION_END = "session_end" +PROMPT_SUBMIT = "prompt_submit" +PRE_TOOL = "pre_tool" +POST_TOOL = "post_tool" +TOOL_FAILURE = "tool_failure" +PRE_COMPACT = "pre_compact" +STOP = "stop" +SUBAGENT_START = "subagent_start" +SUBAGENT_STOP = "subagent_stop" +INSTRUCTIONS_LOADED = "instructions_loaded" +FILE_CHANGED = "file_changed" + +UNKNOWN = "unknown" + +EVENTS = ( + SESSION_START, + SESSION_END, + PROMPT_SUBMIT, + PRE_TOOL, + POST_TOOL, + TOOL_FAILURE, + PRE_COMPACT, + STOP, + SUBAGENT_START, + SUBAGENT_STOP, + INSTRUCTIONS_LOADED, + FILE_CHANGED, +) + + +class Event: + """One agent lifecycle event, normalized.""" + + __slots__ = ( + "agent", + "event", + "tool", + "command", + "path", + "content", + "output", + "prompt", + "session_id", + "tool_use_id", + "cwd", + "raw", + ) + + def __init__( + self, + agent, + event, + *, + tool=None, + command=None, + path=None, + content=None, + output=None, + prompt=None, + session_id=None, + tool_use_id=None, + cwd=None, + raw=None, + ): + self.agent = agent + self.event = event + self.tool = tool + self.command = command + self.path = path + self.content = content + self.output = output + self.prompt = prompt + self.session_id = session_id + self.tool_use_id = tool_use_id + self.cwd = cwd + self.raw = raw if raw is not None else {} + + def __repr__(self): # pragma: no cover - debugging aid + return "Event(%s/%s tool=%r path=%r)" % (self.agent, self.event, self.tool, self.path) + + +ALLOW = "allow" +DENY = "deny" +ESCALATE = "escalate" +TRANSFORM = "transform" +WARN = "warn" +VOUCH = "vouch" + +# Pre-ACS-alignment names. Same strings as their ACS-named counterparts, so every existing +# `is`/`==` comparison against the old constant keeps working untouched. +ASK = ESCALATE +REWRITE = TRANSFORM + +_CANONICAL_OUTCOMES = (ALLOW, DENY, ESCALATE, TRANSFORM, WARN, VOUCH) + +# The literal spellings a caller might still pass to Decision(outcome, ...) directly, mapped +# to the value that now backs them. Only needed for the raw-string constructor path -- +# Decision.ask()/.rewrite() below build the canonical outcome themselves. +_LEGACY_SPELLING = {"ask": ESCALATE, "rewrite": TRANSFORM} + + +class Decision: + """What a handler wants to happen. Adapters translate this to vendor dialect.""" + + __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + + #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). + DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) + + def __init__(self, outcome, reason=None, updated_input=None, evidence=None, context=None): + outcome = _LEGACY_SPELLING.get(outcome, outcome) + if outcome not in _CANONICAL_OUTCOMES: + raise ValueError("unknown outcome: %r" % (outcome,)) + self.outcome = outcome + self.reason = reason + self.updated_input = updated_input + self.evidence = evidence or {} + self.context = context + + @classmethod + def allow(cls, reason=None, evidence=None, context=None): + return cls(ALLOW, reason, evidence=evidence, context=context) + + @classmethod + def deny(cls, reason, evidence=None, context=None): + return cls(DENY, reason, evidence=evidence, context=context) + + @classmethod + def escalate(cls, reason, evidence=None, context=None): + """Defer the action to the host's own approval path (ACS `escalate`).""" + return cls(ESCALATE, reason, evidence=evidence, context=context) + + @classmethod + def ask(cls, reason, evidence=None, context=None): + """Deprecated alias of escalate() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.ask() is deprecated; use Decision.escalate()", DeprecationWarning, stacklevel=2) + return cls.escalate(reason, evidence=evidence, context=context) + + @classmethod + def transform(cls, updated_input, reason=None, evidence=None, context=None): + """Replace the tool input wholesale (ACS `transform`, at whole-value granularity).""" + return cls(TRANSFORM, reason, updated_input=updated_input, evidence=evidence, context=context) + + @classmethod + def rewrite(cls, updated_input, reason=None, evidence=None, context=None): + """Deprecated alias of transform() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.rewrite() is deprecated; use Decision.transform()", DeprecationWarning, stacklevel=2) + return cls.transform(updated_input, reason, evidence=evidence, context=context) + + @classmethod + def warn(cls, reason=None, evidence=None, context=None): + """Permit the action with no change, recording a warning (ACS `warn`).""" + return cls(WARN, reason, evidence=evidence, context=context) + + @classmethod + def vouch(cls, reason=None, evidence=None, context=None): + return cls(VOUCH, reason, evidence=evidence, context=context) + + def __repr__(self): # pragma: no cover - debugging aid + return "Decision(%s, %r)" % (self.outcome, self.reason) + + +def degraded_from(decision): + """What this decision was before the dispatcher reduced it, or None.""" + return (decision.evidence or {}).get("degraded_from") + + +def tool_input_of(raw): + """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" + if isinstance(raw, dict): + return raw + if isinstance(raw, str) and raw[:1] == "{": + try: + parsed = _json.loads(raw) + except Exception: + return {} + if isinstance(parsed, dict): + return parsed + return {} + + +# ------------------------------------------------------------------------------ +# antigravity family engine (trimmed to what this entry uses) + +def _wire_name(cfg, raw): + for key in cfg["claims"].get("event_key", ()): + name = raw.get(key) + if name is not None: + return name + return None + +def _segment(node, part): + """One path segment: a dict key, or `key[N]` indexing the list under it.""" + if part.endswith("]") and "[" in part: + key, _, index = part[:-1].partition("[") + items = node.get(key) if isinstance(node, dict) else None + i = int(index) + return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None + return node.get(part) if isinstance(node, dict) else None + +def _walk(node, path): + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + if "[]." in path: + head, sub = path.split("[].", 1) + items = _walk(node, head) + if not isinstance(items, (list, tuple)): + return None + joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) + return joined or None + for part in path.split("."): + node = _segment(node, part) + if node is None: + return None + return node + +def _lookup(raw, ti, key): + """One config key: a `tool_input.` path walks the decoded tool input, else the payload.""" + if key.startswith("tool_input."): + return _walk(ti, key[len("tool_input.") :]) + return _walk(raw, key) + +def _field(raw, ti, chain): + value = None + for key in chain: + if value: + break + value = value or _lookup(raw, ti, key) + return value + +_FIELD_META = ("tool_input", "content_only_for_write_tools", "stringify") + +def _tool_input_raw(cfg, raw): + for key in cfg["fields"].get("tool_input", ("tool_input",)): + value = raw.get(key) + if value is not None: + return value + return None + +def _canonical_of(cfg, name): + """The canonical event for one wire name; an entry with no `events` at all (antigravity, + whose payloads never carry one) maps the shape-inferred name back through `wire_events`.""" + events = cfg["events"] + if not events: + return {wire: canonical for canonical, wire in cfg.get("wire_events", {}).items()}.get(name, UNKNOWN) + return events.get(name, UNKNOWN) + +def hj_parse(cfg, raw, wire=None): + """Normalise one payload along the entry's ordered field-fallback chains. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + ti = tool_input_of(_tool_input_raw(cfg, raw)) + fields = {name: _field(raw, ti, chain) for name, chain in cfg["fields"].items() if name not in _FIELD_META} + if cfg["fields"].get("content_only_for_write_tools") and fields.get("tool") not in cfg["tools"].get("write", ()): + fields["content"] = None + if isinstance(fields.get("output"), (dict, list)): + fields["output"] = _json.dumps(fields["output"]) + for name in cfg["fields"].get("stringify", ()): + if fields.get(name) is not None: + fields[name] = str(fields[name]) + return Event( + cfg["agent"], + _canonical_of(cfg, wire if wire is not None else _wire_name(cfg, raw)), + tool=fields.get("tool"), + command=fields.get("command"), + path=fields.get("path"), + content=fields.get("content"), + output=fields.get("output"), + prompt=fields.get("prompt"), + session_id=fields.get("session_id"), + tool_use_id=fields.get("tool_use_id"), + cwd=fields.get("cwd"), + raw=raw, + ) + +def hj_reverse(cfg): + """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" + reverse = {} + for name, canonical in cfg["events"].items(): + if canonical != UNKNOWN: + reverse[canonical] = name + reverse.update(cfg.get("wire_events", {})) + return reverse + +def _context_value(v, decision): + if v.get("context_source") == "context": + return decision.context + if v.get("context_source") == "reason": + return decision.reason + return None + +def _context_body(name, value): + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "additionalContext": value}}), 0 + +def _note_for(v, decision, at_gate, missing_input): + notes = v.get("degrade_notes", {}) + if decision.outcome == ESCALATE: + if degraded_from(decision) == TRANSFORM and "escalate_from_transform" in notes: + return notes["escalate_from_transform"] + if at_gate and "escalate_gate" in notes: + return notes["escalate_gate"] + return notes.get("escalate") + if decision.outcome == TRANSFORM: + if missing_input and "transform_missing_input" in notes: + return notes["transform_missing_input"] + return notes.get("transform") + return None + +def _default_for(v, decision, at_gate, wire=None): + gate_defaults = v.get("gate_reason_defaults", {}) + if wire in gate_defaults: + return gate_defaults[wire] + defaults = v.get("reason_defaults", {}) + key = {DENY: "deny", ESCALATE: "escalate", TRANSFORM: "transform"}.get(decision.outcome, "deny") + if at_gate and key + "_gate" in defaults: + return defaults[key + "_gate"] + return defaults.get(key, "blocked by policy") + +def _refusal_text(v, decision, at_gate, wire=None): + note = _note_for(v, decision, at_gate, decision.updated_input is None) + default = _default_for(v, decision, at_gate, wire) + if note and "%s" in note: + # A template note fills (the reason or its default, the wire event name) itself. + return note % (decision.reason or default, wire) + reason = decision.reason + if v.get("note_style") == "suffix": + reason = reason or default + return "%s (%s)" % (reason, note) if note else reason + text = "%s (%s)" % (reason, note) if reason and note else (note or reason) + return text or default + +def _g1(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 + 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 + if ( + decision.outcome == ESCALATE + and gate["honours_escalate"] + and "escalate" in words + # An escalate the dispatcher degraded a transform into is a block where the entry + # names that degradation (antigravity): prompting would offer the unmodified call. + and not (degraded_from(decision) == TRANSFORM and "escalate_from_transform" in v.get("degrade_notes", {})) + ): + reason = decision.reason or _default_for(v, decision, True, wire) + return _json.dumps({"decision": words["escalate"], "reason": reason}), 0 + out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, False, wire)} + if at_context_event and v.get("context_source") == "context" and decision.context: + out["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": decision.context} + return _json.dumps(out), 0 + +def hj_respond(cfg, decision, event, wire=None): + """(stdout_text, exit_code) in this entry's dialect for the gate the payload names. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + v = cfg["verdicts"] + if wire is None: + wire = _wire_name(cfg, event.raw or {}) + if wire in v.get("empty_object_events", ()): + return _json.dumps({}), 0 + if wire is None: + wire = v.get("default_wire_event") + if wire is None and v.get("missing_wire") == "reverse_map": + wire = hj_reverse(cfg).get(event.event) + name = wire if v.get("echo") == "payload" else hj_reverse(cfg).get(event.event, "PreToolUse") + gate = v["gates"].get(wire) + if gate is None: + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + return "", 0 + if gate["grammar"] == "G2": + return _g2(v, gate, decision, name) + return _g1(v, gate, decision, wire, name) + +_WINDOWS_KEYS = ("commandWindows", "windows") + +def _hook_dict(cfg, command): + entry = {"type": "command", "command": command} + for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): + if key in _WINDOWS_KEYS: + entry[key] = powershell_command(command) + else: + entry[key] = value + return entry + +def 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": + 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) + if not name: + continue + entry = {"hooks": [_hook_dict(cfg, command)]} + if matcher and hook_entry["matcher"]: + entry["matcher"] = matcher + hooks.setdefault(name, []).append(entry) + if hook_entry.get("group"): + return {hook_entry["group"]: hooks} + return hooks if hook_entry.get("bare") else {"hooks": hooks} + +def 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: + return "Stop" + if isinstance(raw.get("toolCall"), dict): + return "PostToolUse" if "error" in raw else "PreToolUse" + return None + +def antigravity_claims(cfg, raw): + """Structural: `conversationId` with `workspacePaths` is Antigravity's own envelope.""" + if not isinstance(raw, dict): + return False + return "conversationId" in raw and isinstance(raw.get("workspacePaths"), list) + +def antigravity_parse(cfg, raw): + return hj_parse(cfg, raw, wire=antigravity_wire(raw)) + +def antigravity_respond(cfg, decision, event): + return hj_respond(cfg, decision, event, wire=antigravity_wire(event.raw or {})) + + +# ------------------------------------------------------------------------------ +# antigravity vendor config + engine binding + +AGENT = "antigravity" + +VENDOR = {'agent': 'antigravity', 'claims': {'mode': 'shape_inferred'}, 'config_format': 'json', 'config_path': '.agents/hooks.json', 'display': 'Antigravity', 'events': {}, 'evidence': {'claims': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'antigravity', 'fields': {'command': ('toolCall.args.CommandLine',), 'content': ('toolCall.args.CodeContent', 'toolCall.args.ReplacementContent', 'toolCall.args.ReplacementChunks[].ReplacementContent'), 'cwd': ('toolCall.args.Cwd', 'workspacePaths[0]'), 'output': ('error',), 'path': ('toolCall.args.TargetFile', 'toolCall.args.AbsolutePath'), 'session_id': ('conversationId',), 'stringify': ('tool_use_id',), 'tool': ('toolCall.name',), 'tool_use_id': ('stepIdx',)}, 'hook_entry': {'group': 'agentseam', 'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'tools': {}, 'verdicts': {'answer_events': ('PreToolUse', 'Stop'), 'bare_allow': 'unverified', 'default_wire_event': 'PreToolUse', 'degrade_notes': {'escalate': 'Antigravity cannot prompt at Stop', 'escalate_from_transform': 'Antigravity cannot modify a tool call', 'transform': 'Antigravity cannot modify a tool call'}, 'empty_object_events': ('PostToolUse',), 'gate_reason_defaults': {'Stop': 'policy requires more work'}, 'gates': {'PreToolUse': {'grammar': 'G1', 'honours_escalate': True, 'honours_transform': False}, 'Stop': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'because', 'reason_defaults': {'escalate': 'confirmation required'}, 'vocabulary': ('allow', 'ask', 'continue', 'deny', 'deny_unless_prior_grant', 'force_ask', 'stop'), 'vocabulary_basis': 'verified', 'words': {'allow': 'allow', 'block': 'deny', 'escalate': 'ask'}, 'words_at': {'Stop': {'allow': 'stop', 'block': 'continue'}}}, 'wire_events': {'post_tool': 'PostToolUse', 'pre_tool': 'PreToolUse', 'stop': 'Stop'}} + + +def claims(raw): + return antigravity_claims(VENDOR, raw) + + +def parse(raw): + return antigravity_parse(VENDOR, raw) + + +def respond(decision, event): + return antigravity_respond(VENDOR, decision, event) + + +def hook_config(canonical_events, command, matcher=None): + return hook_entry_config(VENDOR, canonical_events, command, matcher) + + +# ------------------------------------------------------------------------------ +# runtime (agentseam dispatch, specialized to antigravity) + +_VOUCH_SPEAKS = False +_WARN_SPEAKS = False +_TRANSFORM_EVENTS = frozenset(()) + + +def degrade(decision, event): + """Reduce a decision to what 'antigravity' can actually honor, honestly. + + This is agentseam.dispatch.degrade(), specialized to one fixed agent so this file needs + no "import agentseam" -- no evidence establishes that an explicit approval word means anything beyond a plain allow here, so vouch degrades to one; see agentseam.allow_semantics. + """ + if decision.outcome == TRANSFORM and event.event not in _TRANSFORM_EVENTS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = TRANSFORM + return Decision.escalate( + decision.reason or "input requires modification before it can run", evidence=evidence + ) + if decision.outcome == VOUCH and not _VOUCH_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = VOUCH + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + if decision.outcome == WARN and not _WARN_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = WARN + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + return decision + + +# >>> agentseam handler >>> +GUARD_VIOLATION = 1 + +_BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') + +GATE_LOG_ENV = 'CHOCK_GATE_LOG' + +_LOG_MAX_BYTES = 1048576 + +_GUARD_TIMEOUT_SECONDS = 30 + +GUARD_BLOCKED = 'blocked' + +GUARD_CLEAN = 'clean' + +GUARD_UNCHECKED = 'unchecked' + +GUARD_ERRORED = 'errored' + +VERDICT_DENY = 'deny' + +VERDICT_ESCALATE = 'escalate' + +def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: + """The `--guard ` argument a vendored runtime was invoked with, or None.""" + if '--guard' in argv: + i = argv.index('--guard') + if i + 1 < len(argv): + return _chock_Path(argv[i + 1]) + return None + +def find_bash(guard: _chock_Path) -> str | None: + """First interpreter that can actually see `guard`, or None.""" + for candidate in _BASH_CANDIDATES: + try: + proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10) + except (OSError, _chock_subprocess.SubprocessError): + continue + if proc.returncode == 0: + return candidate + return None + +def run_guard(guard: _chock_Path, command: str) -> str: + """`GUARD_BLOCKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + try: + args = _chock_shlex.split(command) + except ValueError: + print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) + return GUARD_UNCHECKED + if not args: + return GUARD_UNCHECKED + bash = find_bash(guard) + if bash is None: + print(f'chock: no usable bash found, {guard.name} not checked', file=sys.stderr) + return GUARD_UNCHECKED + try: + env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} + proc = _chock_subprocess.run([bash, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS) + except _chock_subprocess.TimeoutExpired: + print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) + return GUARD_ERRORED + except (OSError, UnicodeError) as exc: + print(f'chock: guard could not run, not checked: {exc}', file=sys.stderr) + return GUARD_ERRORED + if proc.returncode == GUARD_VIOLATION: + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + if not ((proc.stdout or '') + (proc.stderr or '')).strip(): + print(f'chock: blocked by {_chock_Path(guard).name} (guard gave no reason)', file=sys.stderr) + return GUARD_BLOCKED + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout or '').strip().splitlines() + print(f'chock: guard exited {proc.returncode}, not checked' + (f': {detail[0][:120]}' if detail else ''), file=sys.stderr) + return GUARD_ERRORED + return GUARD_CLEAN + +def log_outcome(guard: _chock_Path, tool: str, blocked: bool) -> None: + """Append one outcome record. Best effort: never raises, never changes the verdict.""" + try: + if _chock_os.environ.get(GATE_LOG_ENV) == '0': + return + guard = guard.resolve() + if guard.parent.name != 'implementations': + return + artifact_root = None + for parent in guard.parents: + if (parent / '.chock').is_dir(): + artifact_root = parent / '.chock' + break + if artifact_root is None: + return + log_dir = artifact_root / 'log' + log_dir.mkdir(parents=True, exist_ok=True) + log_path = log_dir / 'gate-events.jsonl' + if log_path.exists() and log_path.stat().st_size > _LOG_MAX_BYTES: + log_path.replace(log_dir / 'gate-events.1.jsonl') + import json + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': 'block' if blocked else 'allow'} + with log_path.open('a', encoding='utf-8') as fh: + fh.write(json.dumps(record, ensure_ascii=False) + '\n') + except Exception: + return + +def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: + """Run the guard named on `argv` (`--guard `) against `command`.""" + guard = guard_path_from_argv(argv) + if guard is None or not guard.exists(): + return None + verdict = run_guard(guard, command) + if verdict in (GUARD_BLOCKED, GUARD_CLEAN): + log_outcome(guard, tool, verdict == GUARD_BLOCKED) + if verdict == GUARD_BLOCKED: + return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + if verdict == GUARD_ERRORED: + return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") + return None + + +def handle(event): + if event.event == "pre_tool" and event.command: + verdict = evaluate(sys.argv[1:], event.command, event.tool or "") + if verdict is not None: + outcome, reason = verdict + return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + return None +# <<< agentseam handler <<< + + +def _coerce(result): + if result is None: + return Decision.allow() + if isinstance(result, Decision): + return result + raise TypeError("handle() must return a Decision or None, got %r" % (type(result),)) + + +def _read_payload(stream): + # Decode bytes ourselves rather than trust the platform locale -- a BOM'd or non-UTF-8 + # stdin must not silently disable the gate. See agentseam.dispatch for the incident + # this guards: a Windows console's cp1252 layer turning a UTF-8 BOM into three bytes + # json cannot parse, with the hook allowing everything while claiming enforcement. + buffer = getattr(stream, "buffer", None) + if buffer is not None: + return buffer.read().decode("utf-8-sig", errors="replace") + return stream.read().lstrip("\ufeff") + + +def _emit(out, text): + buffer = getattr(out, "buffer", None) + if buffer is not None: + buffer.write(text.encode("utf-8")) + buffer.flush() + else: + out.write(text) + out.flush() + + +def main(stdin=None, stdout=None, exit=True): + """Read one payload from stdin, dispatch, emit the 'antigravity' response, exit.""" + stream = stdin if stdin is not None else sys.stdin + out = stdout if stdout is not None else sys.stdout + try: + raw = json.loads(_read_payload(stream)) + except Exception: + # Malformed input is not the agent's fault to pay for: allow, stay silent. + if exit: + sys.exit(0) + return 0 + event = parse(raw) + if event.event == UNKNOWN: + # A vendor event this adapter has no mapping for. handle() is not called: it + # reasons about the canonical vocabulary, and handing it something outside that + # vocabulary invites a decision made on a false premise. + if exit: + sys.exit(0) + return 0 + decision = degrade(_coerce(handle(event)), event) + text, code = respond(decision, event) + if text: + _emit(out, text) + if exit: + sys.exit(code) + return code + + +if __name__ == "__main__": # pragma: no cover + main() + diff --git a/.chock/bin/codex_cli.py b/.chock/bin/codex_cli.py new file mode 100755 index 0000000..3b5a068 --- /dev/null +++ b/.chock/bin/codex_cli.py @@ -0,0 +1,805 @@ +# Generated by agentseam 0.2.0 -- 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. +# +# Self-contained: stdlib only, no "import agentseam" anywhere in this file. Regenerate +# with `agentseam.bundler.bundle("codex_cli")` (same agentseam version -> identical bytes, +# except your own edits inside the handler block) rather than patching this by hand. +# https://github.com/open-coder-ai/agentseam + +from __future__ import annotations + +import json +_json = json +import sys + +import os as _chock_os +import shlex as _chock_shlex +import subprocess as _chock_subprocess +from datetime import datetime as _chock_datetime, timezone as _chock_timezone +from pathlib import Path as _chock_Path +import warnings as _warnings + +# ------------------------------------------------------------------------------ +# contract (agentseam 0.2.0) + +"""Canonical event vocabulary, normalized envelope, and decision type.""" + + + +SESSION_START = "session_start" +SESSION_END = "session_end" +PROMPT_SUBMIT = "prompt_submit" +PRE_TOOL = "pre_tool" +POST_TOOL = "post_tool" +TOOL_FAILURE = "tool_failure" +PRE_COMPACT = "pre_compact" +STOP = "stop" +SUBAGENT_START = "subagent_start" +SUBAGENT_STOP = "subagent_stop" +INSTRUCTIONS_LOADED = "instructions_loaded" +FILE_CHANGED = "file_changed" + +UNKNOWN = "unknown" + +EVENTS = ( + SESSION_START, + SESSION_END, + PROMPT_SUBMIT, + PRE_TOOL, + POST_TOOL, + TOOL_FAILURE, + PRE_COMPACT, + STOP, + SUBAGENT_START, + SUBAGENT_STOP, + INSTRUCTIONS_LOADED, + FILE_CHANGED, +) + + +class Event: + """One agent lifecycle event, normalized.""" + + __slots__ = ( + "agent", + "event", + "tool", + "command", + "path", + "content", + "output", + "prompt", + "session_id", + "tool_use_id", + "cwd", + "raw", + ) + + def __init__( + self, + agent, + event, + *, + tool=None, + command=None, + path=None, + content=None, + output=None, + prompt=None, + session_id=None, + tool_use_id=None, + cwd=None, + raw=None, + ): + self.agent = agent + self.event = event + self.tool = tool + self.command = command + self.path = path + self.content = content + self.output = output + self.prompt = prompt + self.session_id = session_id + self.tool_use_id = tool_use_id + self.cwd = cwd + self.raw = raw if raw is not None else {} + + def __repr__(self): # pragma: no cover - debugging aid + return "Event(%s/%s tool=%r path=%r)" % (self.agent, self.event, self.tool, self.path) + + +ALLOW = "allow" +DENY = "deny" +ESCALATE = "escalate" +TRANSFORM = "transform" +WARN = "warn" +VOUCH = "vouch" + +# Pre-ACS-alignment names. Same strings as their ACS-named counterparts, so every existing +# `is`/`==` comparison against the old constant keeps working untouched. +ASK = ESCALATE +REWRITE = TRANSFORM + +_CANONICAL_OUTCOMES = (ALLOW, DENY, ESCALATE, TRANSFORM, WARN, VOUCH) + +# The literal spellings a caller might still pass to Decision(outcome, ...) directly, mapped +# to the value that now backs them. Only needed for the raw-string constructor path -- +# Decision.ask()/.rewrite() below build the canonical outcome themselves. +_LEGACY_SPELLING = {"ask": ESCALATE, "rewrite": TRANSFORM} + + +class Decision: + """What a handler wants to happen. Adapters translate this to vendor dialect.""" + + __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + + #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). + DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) + + def __init__(self, outcome, reason=None, updated_input=None, evidence=None, context=None): + outcome = _LEGACY_SPELLING.get(outcome, outcome) + if outcome not in _CANONICAL_OUTCOMES: + raise ValueError("unknown outcome: %r" % (outcome,)) + self.outcome = outcome + self.reason = reason + self.updated_input = updated_input + self.evidence = evidence or {} + self.context = context + + @classmethod + def allow(cls, reason=None, evidence=None, context=None): + return cls(ALLOW, reason, evidence=evidence, context=context) + + @classmethod + def deny(cls, reason, evidence=None, context=None): + return cls(DENY, reason, evidence=evidence, context=context) + + @classmethod + def escalate(cls, reason, evidence=None, context=None): + """Defer the action to the host's own approval path (ACS `escalate`).""" + return cls(ESCALATE, reason, evidence=evidence, context=context) + + @classmethod + def ask(cls, reason, evidence=None, context=None): + """Deprecated alias of escalate() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.ask() is deprecated; use Decision.escalate()", DeprecationWarning, stacklevel=2) + return cls.escalate(reason, evidence=evidence, context=context) + + @classmethod + def transform(cls, updated_input, reason=None, evidence=None, context=None): + """Replace the tool input wholesale (ACS `transform`, at whole-value granularity).""" + return cls(TRANSFORM, reason, updated_input=updated_input, evidence=evidence, context=context) + + @classmethod + def rewrite(cls, updated_input, reason=None, evidence=None, context=None): + """Deprecated alias of transform() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.rewrite() is deprecated; use Decision.transform()", DeprecationWarning, stacklevel=2) + return cls.transform(updated_input, reason, evidence=evidence, context=context) + + @classmethod + def warn(cls, reason=None, evidence=None, context=None): + """Permit the action with no change, recording a warning (ACS `warn`).""" + return cls(WARN, reason, evidence=evidence, context=context) + + @classmethod + def vouch(cls, reason=None, evidence=None, context=None): + return cls(VOUCH, reason, evidence=evidence, context=context) + + def __repr__(self): # pragma: no cover - debugging aid + return "Decision(%s, %r)" % (self.outcome, self.reason) + + +def degraded_from(decision): + """What this decision was before the dispatcher reduced it, or None.""" + return (decision.evidence or {}).get("degraded_from") + + +def tool_input_of(raw): + """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" + if isinstance(raw, dict): + return raw + if isinstance(raw, str) and raw[:1] == "{": + try: + parsed = _json.loads(raw) + except Exception: + return {} + if isinstance(parsed, dict): + return parsed + return {} + + +# ------------------------------------------------------------------------------ +# hook_json family engine (trimmed to what this entry uses) + +def powershell_command(command): + """`command` rewritten so PowerShell will actually run it.""" + return command if command.lstrip().startswith("&") else "& " + command + +def _wire_name(cfg, raw): + for key in cfg["claims"].get("event_key", ()): + name = raw.get(key) + if name is not None: + return name + return None + +def hj_claims(cfg, raw): + """True when this payload matches the entry's marker discipline.""" + if not isinstance(raw, dict): + return False + c = cfg["claims"] + name = _wire_name(cfg, raw) + if name in c.get("accept_names", ()): + return True + if name not in cfg["events"]: + 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 + return False + return True + +def _segment(node, part): + """One path segment: a dict key, or `key[N]` indexing the list under it.""" + if part.endswith("]") and "[" in part: + key, _, index = part[:-1].partition("[") + items = node.get(key) if isinstance(node, dict) else None + i = int(index) + return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None + return node.get(part) if isinstance(node, dict) else None + +def _walk(node, path): + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + if "[]." in path: + head, sub = path.split("[].", 1) + items = _walk(node, head) + if not isinstance(items, (list, tuple)): + return None + joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) + return joined or None + for part in path.split("."): + node = _segment(node, part) + if node is None: + return None + return node + +def _lookup(raw, ti, key): + """One config key: a `tool_input.` path walks the decoded tool input, else the payload.""" + if key.startswith("tool_input."): + return _walk(ti, key[len("tool_input.") :]) + return _walk(raw, key) + +def _field(raw, ti, chain): + value = None + for key in chain: + if value: + break + value = value or _lookup(raw, ti, key) + return value + +_FIELD_META = ("tool_input", "content_only_for_write_tools", "stringify") + +def _tool_input_raw(cfg, raw): + for key in cfg["fields"].get("tool_input", ("tool_input",)): + value = raw.get(key) + if value is not None: + return value + return None + +def _canonical_of(cfg, name): + """The canonical event for one wire name; an entry with no `events` at all (antigravity, + whose payloads never carry one) maps the shape-inferred name back through `wire_events`.""" + events = cfg["events"] + if not events: + return {wire: canonical for canonical, wire in cfg.get("wire_events", {}).items()}.get(name, UNKNOWN) + return events.get(name, UNKNOWN) + +def hj_parse(cfg, raw, wire=None): + """Normalise one payload along the entry's ordered field-fallback chains. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + ti = tool_input_of(_tool_input_raw(cfg, raw)) + fields = {name: _field(raw, ti, chain) for name, chain in cfg["fields"].items() if name not in _FIELD_META} + if cfg["fields"].get("content_only_for_write_tools") and fields.get("tool") not in cfg["tools"].get("write", ()): + fields["content"] = None + if isinstance(fields.get("output"), (dict, list)): + fields["output"] = _json.dumps(fields["output"]) + for name in cfg["fields"].get("stringify", ()): + if fields.get(name) is not None: + fields[name] = str(fields[name]) + return Event( + cfg["agent"], + _canonical_of(cfg, wire if wire is not None else _wire_name(cfg, raw)), + tool=fields.get("tool"), + command=fields.get("command"), + path=fields.get("path"), + content=fields.get("content"), + output=fields.get("output"), + prompt=fields.get("prompt"), + session_id=fields.get("session_id"), + tool_use_id=fields.get("tool_use_id"), + cwd=fields.get("cwd"), + raw=raw, + ) + +def hj_reverse(cfg): + """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" + reverse = {} + for name, canonical in cfg["events"].items(): + if canonical != UNKNOWN: + reverse[canonical] = name + reverse.update(cfg.get("wire_events", {})) + return reverse + +def _context_value(v, decision): + if v.get("context_source") == "context": + return decision.context + if v.get("context_source") == "reason": + return decision.reason + return None + +def _context_body(name, value): + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "additionalContext": value}}), 0 + +def _note_for(v, decision, at_gate, missing_input): + notes = v.get("degrade_notes", {}) + if decision.outcome == ESCALATE: + if degraded_from(decision) == TRANSFORM and "escalate_from_transform" in notes: + return notes["escalate_from_transform"] + if at_gate and "escalate_gate" in notes: + return notes["escalate_gate"] + return notes.get("escalate") + if decision.outcome == TRANSFORM: + if missing_input and "transform_missing_input" in notes: + return notes["transform_missing_input"] + return notes.get("transform") + return None + +def _default_for(v, decision, at_gate, wire=None): + gate_defaults = v.get("gate_reason_defaults", {}) + if wire in gate_defaults: + return gate_defaults[wire] + defaults = v.get("reason_defaults", {}) + key = {DENY: "deny", ESCALATE: "escalate", TRANSFORM: "transform"}.get(decision.outcome, "deny") + if at_gate and key + "_gate" in defaults: + return defaults[key + "_gate"] + return defaults.get(key, "blocked by policy") + +def _refusal_text(v, decision, at_gate, wire=None): + note = _note_for(v, decision, at_gate, decision.updated_input is None) + default = _default_for(v, decision, at_gate, wire) + if note and "%s" in note: + # A template note fills (the reason or its default, the wire event name) itself. + return note % (decision.reason or default, wire) + reason = decision.reason + if v.get("note_style") == "suffix": + reason = reason or default + return "%s (%s)" % (reason, note) if note else reason + text = "%s (%s)" % (reason, note) if reason and note else (note or reason) + return text or default + +def _g1(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 + 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 + if ( + decision.outcome == ESCALATE + and gate["honours_escalate"] + and "escalate" in words + # An escalate the dispatcher degraded a transform into is a block where the entry + # names that degradation (antigravity): prompting would offer the unmodified call. + and not (degraded_from(decision) == TRANSFORM and "escalate_from_transform" in v.get("degrade_notes", {})) + ): + reason = decision.reason or _default_for(v, decision, True, wire) + return _json.dumps({"decision": words["escalate"], "reason": reason}), 0 + out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, False, wire)} + if at_context_event and v.get("context_source") == "context" and decision.context: + out["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": decision.context} + return _json.dumps(out), 0 + +def _g2(v, gate, decision, name): + """Permission gate: `hookSpecificOutput.permissionDecision`, filled from the word table.""" + if decision.outcome in (ALLOW, WARN): + return "", 0 + words = v.get("words", {}) + out = {"hookEventName": name} + if decision.outcome == VOUCH: + if "vouch" not in words: + return "", 0 + out["permissionDecision"] = words["vouch"] + if decision.reason: + out["permissionDecisionReason"] = decision.reason + elif ( + decision.outcome == TRANSFORM + and gate["honours_transform"] + and not (decision.updated_input is None and "transform_missing_input" in v.get("degrade_notes", {})) + ): + out["permissionDecision"] = words.get("transform", "allow") + out["updatedInput"] = decision.updated_input + if decision.reason: + out["permissionDecisionReason"] = 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) + else: + out["permissionDecision"] = words.get("deny", "deny") + out["permissionDecisionReason"] = _refusal_text(v, decision, True) + return _json.dumps({"hookSpecificOutput": out}), 0 + +def hj_respond(cfg, decision, event, wire=None): + """(stdout_text, exit_code) in this entry's dialect for the gate the payload names. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + v = cfg["verdicts"] + if wire is None: + wire = _wire_name(cfg, event.raw or {}) + if wire in v.get("empty_object_events", ()): + return _json.dumps({}), 0 + if wire is None: + wire = v.get("default_wire_event") + if wire is None and v.get("missing_wire") == "reverse_map": + wire = hj_reverse(cfg).get(event.event) + name = wire if v.get("echo") == "payload" else hj_reverse(cfg).get(event.event, "PreToolUse") + gate = v["gates"].get(wire) + if gate is None: + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + return "", 0 + if gate["grammar"] == "G2": + return _g2(v, gate, decision, name) + return _g1(v, gate, decision, wire, name) + +_WINDOWS_KEYS = ("commandWindows", "windows") + +def _hook_dict(cfg, command): + entry = {"type": "command", "command": command} + for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): + if key in _WINDOWS_KEYS: + entry[key] = powershell_command(command) + else: + entry[key] = value + return entry + +def 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": + 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) + if not name: + continue + entry = {"hooks": [_hook_dict(cfg, command)]} + if matcher and hook_entry["matcher"]: + entry["matcher"] = matcher + hooks.setdefault(name, []).append(entry) + if hook_entry.get("group"): + return {hook_entry["group"]: hooks} + return hooks if hook_entry.get("bare") else {"hooks": hooks} + + +# ------------------------------------------------------------------------------ +# 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'}}} + + +def claims(raw): + return hj_claims(VENDOR, raw) + + +def parse(raw): + return hj_parse(VENDOR, raw) + + +def respond(decision, event): + return hj_respond(VENDOR, decision, event) + + +def hook_config(canonical_events, command, matcher=None): + return hook_entry_config(VENDOR, canonical_events, command, matcher) + + +# ------------------------------------------------------------------------------ +# runtime (agentseam dispatch, specialized to codex_cli) + +_VOUCH_SPEAKS = False +_WARN_SPEAKS = False +_TRANSFORM_EVENTS = frozenset(('pre_tool',)) + + +def degrade(decision, event): + """Reduce a decision to what 'codex_cli' can actually honor, honestly. + + This is agentseam.dispatch.degrade(), specialized to one fixed agent so this file needs + no "import agentseam" -- no evidence establishes that an explicit approval word means anything beyond a plain allow here, so vouch degrades to one; see agentseam.allow_semantics. + """ + if decision.outcome == TRANSFORM and event.event not in _TRANSFORM_EVENTS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = TRANSFORM + return Decision.escalate( + decision.reason or "input requires modification before it can run", evidence=evidence + ) + if decision.outcome == VOUCH and not _VOUCH_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = VOUCH + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + if decision.outcome == WARN and not _WARN_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = WARN + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + return decision + + +# >>> agentseam handler >>> +GUARD_VIOLATION = 1 + +_BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') + +GATE_LOG_ENV = 'CHOCK_GATE_LOG' + +_LOG_MAX_BYTES = 1048576 + +_GUARD_TIMEOUT_SECONDS = 30 + +GUARD_BLOCKED = 'blocked' + +GUARD_CLEAN = 'clean' + +GUARD_UNCHECKED = 'unchecked' + +GUARD_ERRORED = 'errored' + +VERDICT_DENY = 'deny' + +VERDICT_ESCALATE = 'escalate' + +def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: + """The `--guard ` argument a vendored runtime was invoked with, or None.""" + if '--guard' in argv: + i = argv.index('--guard') + if i + 1 < len(argv): + return _chock_Path(argv[i + 1]) + return None + +def find_bash(guard: _chock_Path) -> str | None: + """First interpreter that can actually see `guard`, or None.""" + for candidate in _BASH_CANDIDATES: + try: + proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10) + except (OSError, _chock_subprocess.SubprocessError): + continue + if proc.returncode == 0: + return candidate + return None + +def run_guard(guard: _chock_Path, command: str) -> str: + """`GUARD_BLOCKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + try: + args = _chock_shlex.split(command) + except ValueError: + print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) + return GUARD_UNCHECKED + if not args: + return GUARD_UNCHECKED + bash = find_bash(guard) + if bash is None: + print(f'chock: no usable bash found, {guard.name} not checked', file=sys.stderr) + return GUARD_UNCHECKED + try: + env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} + proc = _chock_subprocess.run([bash, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS) + except _chock_subprocess.TimeoutExpired: + print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) + return GUARD_ERRORED + except (OSError, UnicodeError) as exc: + print(f'chock: guard could not run, not checked: {exc}', file=sys.stderr) + return GUARD_ERRORED + if proc.returncode == GUARD_VIOLATION: + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + if not ((proc.stdout or '') + (proc.stderr or '')).strip(): + print(f'chock: blocked by {_chock_Path(guard).name} (guard gave no reason)', file=sys.stderr) + return GUARD_BLOCKED + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout or '').strip().splitlines() + print(f'chock: guard exited {proc.returncode}, not checked' + (f': {detail[0][:120]}' if detail else ''), file=sys.stderr) + return GUARD_ERRORED + return GUARD_CLEAN + +def log_outcome(guard: _chock_Path, tool: str, blocked: bool) -> None: + """Append one outcome record. Best effort: never raises, never changes the verdict.""" + try: + if _chock_os.environ.get(GATE_LOG_ENV) == '0': + return + guard = guard.resolve() + if guard.parent.name != 'implementations': + return + artifact_root = None + for parent in guard.parents: + if (parent / '.chock').is_dir(): + artifact_root = parent / '.chock' + break + if artifact_root is None: + return + log_dir = artifact_root / 'log' + log_dir.mkdir(parents=True, exist_ok=True) + log_path = log_dir / 'gate-events.jsonl' + if log_path.exists() and log_path.stat().st_size > _LOG_MAX_BYTES: + log_path.replace(log_dir / 'gate-events.1.jsonl') + import json + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': 'block' if blocked else 'allow'} + with log_path.open('a', encoding='utf-8') as fh: + fh.write(json.dumps(record, ensure_ascii=False) + '\n') + except Exception: + return + +def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: + """Run the guard named on `argv` (`--guard `) against `command`.""" + guard = guard_path_from_argv(argv) + if guard is None or not guard.exists(): + return None + verdict = run_guard(guard, command) + if verdict in (GUARD_BLOCKED, GUARD_CLEAN): + log_outcome(guard, tool, verdict == GUARD_BLOCKED) + if verdict == GUARD_BLOCKED: + return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + if verdict == GUARD_ERRORED: + return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") + return None + + +def handle(event): + if event.event == "pre_tool" and event.command: + verdict = evaluate(sys.argv[1:], event.command, event.tool or "") + if verdict is not None: + outcome, reason = verdict + return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + return None +# <<< agentseam handler <<< + + +def _coerce(result): + if result is None: + return Decision.allow() + if isinstance(result, Decision): + return result + raise TypeError("handle() must return a Decision or None, got %r" % (type(result),)) + + +def _read_payload(stream): + # Decode bytes ourselves rather than trust the platform locale -- a BOM'd or non-UTF-8 + # stdin must not silently disable the gate. See agentseam.dispatch for the incident + # this guards: a Windows console's cp1252 layer turning a UTF-8 BOM into three bytes + # json cannot parse, with the hook allowing everything while claiming enforcement. + buffer = getattr(stream, "buffer", None) + if buffer is not None: + return buffer.read().decode("utf-8-sig", errors="replace") + return stream.read().lstrip("\ufeff") + + +def _emit(out, text): + buffer = getattr(out, "buffer", None) + if buffer is not None: + buffer.write(text.encode("utf-8")) + buffer.flush() + else: + out.write(text) + out.flush() + + +def main(stdin=None, stdout=None, exit=True): + """Read one payload from stdin, dispatch, emit the 'codex_cli' response, exit.""" + stream = stdin if stdin is not None else sys.stdin + out = stdout if stdout is not None else sys.stdout + try: + raw = json.loads(_read_payload(stream)) + except Exception: + # Malformed input is not the agent's fault to pay for: allow, stay silent. + if exit: + sys.exit(0) + return 0 + event = parse(raw) + if event.event == UNKNOWN: + # A vendor event this adapter has no mapping for. handle() is not called: it + # reasons about the canonical vocabulary, and handing it something outside that + # vocabulary invites a decision made on a false premise. + if exit: + sys.exit(0) + return 0 + decision = degrade(_coerce(handle(event)), event) + text, code = respond(decision, event) + if text: + _emit(out, text) + if exit: + sys.exit(code) + return code + + +if __name__ == "__main__": # pragma: no cover + main() + diff --git a/.chock/bin/devin.py b/.chock/bin/devin.py new file mode 100755 index 0000000..02cee60 --- /dev/null +++ b/.chock/bin/devin.py @@ -0,0 +1,789 @@ +# Generated by agentseam 0.2.0 -- 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. +# +# Self-contained: stdlib only, no "import agentseam" anywhere in this file. Regenerate +# with `agentseam.bundler.bundle("devin")` (same agentseam version -> identical bytes, +# except your own edits inside the handler block) rather than patching this by hand. +# https://github.com/open-coder-ai/agentseam + +from __future__ import annotations + +import json +_json = json +import sys + +import os as _chock_os +import shlex as _chock_shlex +import subprocess as _chock_subprocess +from datetime import datetime as _chock_datetime, timezone as _chock_timezone +from pathlib import Path as _chock_Path +import warnings as _warnings + +# ------------------------------------------------------------------------------ +# contract (agentseam 0.2.0) + +"""Canonical event vocabulary, normalized envelope, and decision type.""" + + + +SESSION_START = "session_start" +SESSION_END = "session_end" +PROMPT_SUBMIT = "prompt_submit" +PRE_TOOL = "pre_tool" +POST_TOOL = "post_tool" +TOOL_FAILURE = "tool_failure" +PRE_COMPACT = "pre_compact" +STOP = "stop" +SUBAGENT_START = "subagent_start" +SUBAGENT_STOP = "subagent_stop" +INSTRUCTIONS_LOADED = "instructions_loaded" +FILE_CHANGED = "file_changed" + +UNKNOWN = "unknown" + +EVENTS = ( + SESSION_START, + SESSION_END, + PROMPT_SUBMIT, + PRE_TOOL, + POST_TOOL, + TOOL_FAILURE, + PRE_COMPACT, + STOP, + SUBAGENT_START, + SUBAGENT_STOP, + INSTRUCTIONS_LOADED, + FILE_CHANGED, +) + + +class Event: + """One agent lifecycle event, normalized.""" + + __slots__ = ( + "agent", + "event", + "tool", + "command", + "path", + "content", + "output", + "prompt", + "session_id", + "tool_use_id", + "cwd", + "raw", + ) + + def __init__( + self, + agent, + event, + *, + tool=None, + command=None, + path=None, + content=None, + output=None, + prompt=None, + session_id=None, + tool_use_id=None, + cwd=None, + raw=None, + ): + self.agent = agent + self.event = event + self.tool = tool + self.command = command + self.path = path + self.content = content + self.output = output + self.prompt = prompt + self.session_id = session_id + self.tool_use_id = tool_use_id + self.cwd = cwd + self.raw = raw if raw is not None else {} + + def __repr__(self): # pragma: no cover - debugging aid + return "Event(%s/%s tool=%r path=%r)" % (self.agent, self.event, self.tool, self.path) + + +ALLOW = "allow" +DENY = "deny" +ESCALATE = "escalate" +TRANSFORM = "transform" +WARN = "warn" +VOUCH = "vouch" + +# Pre-ACS-alignment names. Same strings as their ACS-named counterparts, so every existing +# `is`/`==` comparison against the old constant keeps working untouched. +ASK = ESCALATE +REWRITE = TRANSFORM + +_CANONICAL_OUTCOMES = (ALLOW, DENY, ESCALATE, TRANSFORM, WARN, VOUCH) + +# The literal spellings a caller might still pass to Decision(outcome, ...) directly, mapped +# to the value that now backs them. Only needed for the raw-string constructor path -- +# Decision.ask()/.rewrite() below build the canonical outcome themselves. +_LEGACY_SPELLING = {"ask": ESCALATE, "rewrite": TRANSFORM} + + +class Decision: + """What a handler wants to happen. Adapters translate this to vendor dialect.""" + + __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + + #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). + DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) + + def __init__(self, outcome, reason=None, updated_input=None, evidence=None, context=None): + outcome = _LEGACY_SPELLING.get(outcome, outcome) + if outcome not in _CANONICAL_OUTCOMES: + raise ValueError("unknown outcome: %r" % (outcome,)) + self.outcome = outcome + self.reason = reason + self.updated_input = updated_input + self.evidence = evidence or {} + self.context = context + + @classmethod + def allow(cls, reason=None, evidence=None, context=None): + return cls(ALLOW, reason, evidence=evidence, context=context) + + @classmethod + def deny(cls, reason, evidence=None, context=None): + return cls(DENY, reason, evidence=evidence, context=context) + + @classmethod + def escalate(cls, reason, evidence=None, context=None): + """Defer the action to the host's own approval path (ACS `escalate`).""" + return cls(ESCALATE, reason, evidence=evidence, context=context) + + @classmethod + def ask(cls, reason, evidence=None, context=None): + """Deprecated alias of escalate() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.ask() is deprecated; use Decision.escalate()", DeprecationWarning, stacklevel=2) + return cls.escalate(reason, evidence=evidence, context=context) + + @classmethod + def transform(cls, updated_input, reason=None, evidence=None, context=None): + """Replace the tool input wholesale (ACS `transform`, at whole-value granularity).""" + return cls(TRANSFORM, reason, updated_input=updated_input, evidence=evidence, context=context) + + @classmethod + def rewrite(cls, updated_input, reason=None, evidence=None, context=None): + """Deprecated alias of transform() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.rewrite() is deprecated; use Decision.transform()", DeprecationWarning, stacklevel=2) + return cls.transform(updated_input, reason, evidence=evidence, context=context) + + @classmethod + def warn(cls, reason=None, evidence=None, context=None): + """Permit the action with no change, recording a warning (ACS `warn`).""" + return cls(WARN, reason, evidence=evidence, context=context) + + @classmethod + def vouch(cls, reason=None, evidence=None, context=None): + return cls(VOUCH, reason, evidence=evidence, context=context) + + def __repr__(self): # pragma: no cover - debugging aid + return "Decision(%s, %r)" % (self.outcome, self.reason) + + +def degraded_from(decision): + """What this decision was before the dispatcher reduced it, or None.""" + return (decision.evidence or {}).get("degraded_from") + + +def tool_input_of(raw): + """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" + if isinstance(raw, dict): + return raw + if isinstance(raw, str) and raw[:1] == "{": + try: + parsed = _json.loads(raw) + except Exception: + return {} + if isinstance(parsed, dict): + return parsed + return {} + + +# ------------------------------------------------------------------------------ +# hook_json family engine (trimmed to what this entry uses) + +OBSERVED_MARKERS = ( + "transcript_path", + "permission_mode", + "stop_hook_active", + "agent_transcript_path", + "background_tasks", + "session_crons", + "custom_instructions", + "effort", +) + +def looks_like_claude_code(raw): + """True when the payload carries a field only Claude Code has been seen to send.""" + return isinstance(raw, dict) and any(marker in raw for marker in OBSERVED_MARKERS) + +PROBES = {"looks_like_claude_code": looks_like_claude_code} + +def _wire_name(cfg, raw): + for key in cfg["claims"].get("event_key", ()): + name = raw.get(key) + if name is not None: + return name + return None + +def hj_claims(cfg, raw): + """True when this payload matches the entry's marker discipline.""" + if not isinstance(raw, dict): + return False + c = cfg["claims"] + name = _wire_name(cfg, raw) + if name in c.get("accept_names", ()): + return True + if name not in cfg["events"]: + 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 + return False + return True + +def _segment(node, part): + """One path segment: a dict key, or `key[N]` indexing the list under it.""" + if part.endswith("]") and "[" in part: + key, _, index = part[:-1].partition("[") + items = node.get(key) if isinstance(node, dict) else None + i = int(index) + return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None + return node.get(part) if isinstance(node, dict) else None + +def _walk(node, path): + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + if "[]." in path: + head, sub = path.split("[].", 1) + items = _walk(node, head) + if not isinstance(items, (list, tuple)): + return None + joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) + return joined or None + for part in path.split("."): + node = _segment(node, part) + if node is None: + return None + return node + +def _lookup(raw, ti, key): + """One config key: a `tool_input.` path walks the decoded tool input, else the payload.""" + if key.startswith("tool_input."): + return _walk(ti, key[len("tool_input.") :]) + return _walk(raw, key) + +def _field(raw, ti, chain): + value = None + for key in chain: + if value: + break + value = value or _lookup(raw, ti, key) + return value + +_FIELD_META = ("tool_input", "content_only_for_write_tools", "stringify") + +def _tool_input_raw(cfg, raw): + for key in cfg["fields"].get("tool_input", ("tool_input",)): + value = raw.get(key) + if value is not None: + return value + return None + +def _canonical_of(cfg, name): + """The canonical event for one wire name; an entry with no `events` at all (antigravity, + whose payloads never carry one) maps the shape-inferred name back through `wire_events`.""" + events = cfg["events"] + if not events: + return {wire: canonical for canonical, wire in cfg.get("wire_events", {}).items()}.get(name, UNKNOWN) + return events.get(name, UNKNOWN) + +def hj_parse(cfg, raw, wire=None): + """Normalise one payload along the entry's ordered field-fallback chains. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + ti = tool_input_of(_tool_input_raw(cfg, raw)) + fields = {name: _field(raw, ti, chain) for name, chain in cfg["fields"].items() if name not in _FIELD_META} + if cfg["fields"].get("content_only_for_write_tools") and fields.get("tool") not in cfg["tools"].get("write", ()): + fields["content"] = None + if isinstance(fields.get("output"), (dict, list)): + fields["output"] = _json.dumps(fields["output"]) + for name in cfg["fields"].get("stringify", ()): + if fields.get(name) is not None: + fields[name] = str(fields[name]) + return Event( + cfg["agent"], + _canonical_of(cfg, wire if wire is not None else _wire_name(cfg, raw)), + tool=fields.get("tool"), + command=fields.get("command"), + path=fields.get("path"), + content=fields.get("content"), + output=fields.get("output"), + prompt=fields.get("prompt"), + session_id=fields.get("session_id"), + tool_use_id=fields.get("tool_use_id"), + cwd=fields.get("cwd"), + raw=raw, + ) + +def hj_reverse(cfg): + """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" + reverse = {} + for name, canonical in cfg["events"].items(): + if canonical != UNKNOWN: + reverse[canonical] = name + reverse.update(cfg.get("wire_events", {})) + return reverse + +def _context_value(v, decision): + if v.get("context_source") == "context": + return decision.context + if v.get("context_source") == "reason": + return decision.reason + return None + +def _context_body(name, value): + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "additionalContext": value}}), 0 + +def _note_for(v, decision, at_gate, missing_input): + notes = v.get("degrade_notes", {}) + if decision.outcome == ESCALATE: + if degraded_from(decision) == TRANSFORM and "escalate_from_transform" in notes: + return notes["escalate_from_transform"] + if at_gate and "escalate_gate" in notes: + return notes["escalate_gate"] + return notes.get("escalate") + if decision.outcome == TRANSFORM: + if missing_input and "transform_missing_input" in notes: + return notes["transform_missing_input"] + return notes.get("transform") + return None + +def _default_for(v, decision, at_gate, wire=None): + gate_defaults = v.get("gate_reason_defaults", {}) + if wire in gate_defaults: + return gate_defaults[wire] + defaults = v.get("reason_defaults", {}) + key = {DENY: "deny", ESCALATE: "escalate", TRANSFORM: "transform"}.get(decision.outcome, "deny") + if at_gate and key + "_gate" in defaults: + return defaults[key + "_gate"] + return defaults.get(key, "blocked by policy") + +def _refusal_text(v, decision, at_gate, wire=None): + note = _note_for(v, decision, at_gate, decision.updated_input is None) + default = _default_for(v, decision, at_gate, wire) + if note and "%s" in note: + # A template note fills (the reason or its default, the wire event name) itself. + return note % (decision.reason or default, wire) + reason = decision.reason + if v.get("note_style") == "suffix": + reason = reason or default + return "%s (%s)" % (reason, note) if note else reason + text = "%s (%s)" % (reason, note) if reason and note else (note or reason) + return text or default + +def _g1(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 + 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 + if ( + decision.outcome == ESCALATE + and gate["honours_escalate"] + and "escalate" in words + # An escalate the dispatcher degraded a transform into is a block where the entry + # names that degradation (antigravity): prompting would offer the unmodified call. + and not (degraded_from(decision) == TRANSFORM and "escalate_from_transform" in v.get("degrade_notes", {})) + ): + reason = decision.reason or _default_for(v, decision, True, wire) + return _json.dumps({"decision": words["escalate"], "reason": reason}), 0 + out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, False, wire)} + if at_context_event and v.get("context_source") == "context" and decision.context: + out["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": decision.context} + return _json.dumps(out), 0 + +def hj_respond(cfg, decision, event, wire=None): + """(stdout_text, exit_code) in this entry's dialect for the gate the payload names. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + v = cfg["verdicts"] + if wire is None: + wire = _wire_name(cfg, event.raw or {}) + if wire in v.get("empty_object_events", ()): + return _json.dumps({}), 0 + if wire is None: + wire = v.get("default_wire_event") + if wire is None and v.get("missing_wire") == "reverse_map": + wire = hj_reverse(cfg).get(event.event) + name = wire if v.get("echo") == "payload" else hj_reverse(cfg).get(event.event, "PreToolUse") + gate = v["gates"].get(wire) + if gate is None: + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + return "", 0 + if gate["grammar"] == "G2": + return _g2(v, gate, decision, name) + return _g1(v, gate, decision, wire, name) + +_WINDOWS_KEYS = ("commandWindows", "windows") + +def _hook_dict(cfg, command): + entry = {"type": "command", "command": command} + for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): + if key in _WINDOWS_KEYS: + entry[key] = powershell_command(command) + else: + entry[key] = value + return entry + +def 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": + 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) + if not name: + continue + entry = {"hooks": [_hook_dict(cfg, command)]} + if matcher and hook_entry["matcher"]: + entry["matcher"] = matcher + hooks.setdefault(name, []).append(entry) + if hook_entry.get("group"): + return {hook_entry["group"]: hooks} + return hooks if hook_entry.get("bare") else {"hooks": hooks} + + +# ------------------------------------------------------------------------------ +# devin vendor config + engine binding + +AGENT = "devin" + +VENDOR = {'agent': 'devin', 'claims': {'accept_markers': ('prompt_id',), 'accept_names': ('PermissionRequest', 'PostCompaction'), 'event_key': ('hook_event_name',), 'mode': 'marker', 'notes': 'accept_names are names Claude Code never sends, claimed before any marker check; prompt_id is required alongside looks_like_claude_code(raw) being false.', 'reject_probes': ('looks_like_claude_code',)}, 'config_format': 'json', 'config_path': '.devin/hooks.v1.json', 'display': 'Devin', 'events': {'PermissionRequest': 'pre_tool', 'PostToolUse': 'post_tool', 'PreToolUse': 'pre_tool', 'SessionEnd': 'session_end', 'SessionStart': 'session_start', 'Stop': 'stop', 'UserPromptSubmit': 'prompt_submit'}, 'evidence': {'claims': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'hook_json', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content', 'tool_input.new_string'), 'cwd': ('cwd',), 'output': ('tool_output',), 'path': ('tool_input.file_path', 'tool_input.path'), 'prompt': ('prompt',), 'session_id': ('session_id',), 'tool': ('tool_name',)}, 'hook_entry': {'bare': True, 'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'tools': {}, 'verdicts': {'answer_events': ('PermissionRequest', 'PreToolUse', 'Stop', 'UserPromptSubmit'), 'bare_allow': 'unverified', 'context_events': ('PostToolUse', 'SessionStart', 'UserPromptSubmit'), 'context_source': 'reason', 'default_wire_event': 'PreToolUse', 'degrade_notes': {'escalate': 'Devin cannot prompt for confirmation, so this is a block', 'escalate_from_transform': 'Devin cannot modify a tool call, so this is a block'}, 'echo': 'payload', 'gates': {'PermissionRequest': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'PreToolUse': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': True}, 'Stop': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'UserPromptSubmit': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'suffix', 'reason_defaults': {'transform': 'input requires modification before it can run'}, 'transform_grammar': 'hook_specific_updated_input', 'vocabulary': ('approve', 'block'), 'vocabulary_basis': 'verified', 'words': {'allow': 'approve', 'block': 'block'}}, 'wire_events': {'pre_tool': 'PreToolUse'}} + + +def claims(raw): + return hj_claims(VENDOR, raw) + + +def parse(raw): + return hj_parse(VENDOR, raw) + + +def respond(decision, event): + return hj_respond(VENDOR, decision, event) + + +def hook_config(canonical_events, command, matcher=None): + return hook_entry_config(VENDOR, canonical_events, command, matcher) + + +# ------------------------------------------------------------------------------ +# runtime (agentseam dispatch, specialized to devin) + +_VOUCH_SPEAKS = False +_WARN_SPEAKS = False +_TRANSFORM_EVENTS = frozenset(('pre_tool',)) + + +def degrade(decision, event): + """Reduce a decision to what 'devin' can actually honor, honestly. + + This is agentseam.dispatch.degrade(), specialized to one fixed agent so this file needs + no "import agentseam" -- no evidence establishes that an explicit approval word means anything beyond a plain allow here, so vouch degrades to one; see agentseam.allow_semantics. + """ + if decision.outcome == TRANSFORM and event.event not in _TRANSFORM_EVENTS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = TRANSFORM + return Decision.escalate( + decision.reason or "input requires modification before it can run", evidence=evidence + ) + if decision.outcome == VOUCH and not _VOUCH_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = VOUCH + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + if decision.outcome == WARN and not _WARN_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = WARN + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + return decision + + +# >>> agentseam handler >>> +GUARD_VIOLATION = 1 + +_BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') + +GATE_LOG_ENV = 'CHOCK_GATE_LOG' + +_LOG_MAX_BYTES = 1048576 + +_GUARD_TIMEOUT_SECONDS = 30 + +GUARD_BLOCKED = 'blocked' + +GUARD_CLEAN = 'clean' + +GUARD_UNCHECKED = 'unchecked' + +GUARD_ERRORED = 'errored' + +VERDICT_DENY = 'deny' + +VERDICT_ESCALATE = 'escalate' + +def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: + """The `--guard ` argument a vendored runtime was invoked with, or None.""" + if '--guard' in argv: + i = argv.index('--guard') + if i + 1 < len(argv): + return _chock_Path(argv[i + 1]) + return None + +def find_bash(guard: _chock_Path) -> str | None: + """First interpreter that can actually see `guard`, or None.""" + for candidate in _BASH_CANDIDATES: + try: + proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10) + except (OSError, _chock_subprocess.SubprocessError): + continue + if proc.returncode == 0: + return candidate + return None + +def run_guard(guard: _chock_Path, command: str) -> str: + """`GUARD_BLOCKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + try: + args = _chock_shlex.split(command) + except ValueError: + print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) + return GUARD_UNCHECKED + if not args: + return GUARD_UNCHECKED + bash = find_bash(guard) + if bash is None: + print(f'chock: no usable bash found, {guard.name} not checked', file=sys.stderr) + return GUARD_UNCHECKED + try: + env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} + proc = _chock_subprocess.run([bash, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS) + except _chock_subprocess.TimeoutExpired: + print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) + return GUARD_ERRORED + except (OSError, UnicodeError) as exc: + print(f'chock: guard could not run, not checked: {exc}', file=sys.stderr) + return GUARD_ERRORED + if proc.returncode == GUARD_VIOLATION: + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + if not ((proc.stdout or '') + (proc.stderr or '')).strip(): + print(f'chock: blocked by {_chock_Path(guard).name} (guard gave no reason)', file=sys.stderr) + return GUARD_BLOCKED + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout or '').strip().splitlines() + print(f'chock: guard exited {proc.returncode}, not checked' + (f': {detail[0][:120]}' if detail else ''), file=sys.stderr) + return GUARD_ERRORED + return GUARD_CLEAN + +def log_outcome(guard: _chock_Path, tool: str, blocked: bool) -> None: + """Append one outcome record. Best effort: never raises, never changes the verdict.""" + try: + if _chock_os.environ.get(GATE_LOG_ENV) == '0': + return + guard = guard.resolve() + if guard.parent.name != 'implementations': + return + artifact_root = None + for parent in guard.parents: + if (parent / '.chock').is_dir(): + artifact_root = parent / '.chock' + break + if artifact_root is None: + return + log_dir = artifact_root / 'log' + log_dir.mkdir(parents=True, exist_ok=True) + log_path = log_dir / 'gate-events.jsonl' + if log_path.exists() and log_path.stat().st_size > _LOG_MAX_BYTES: + log_path.replace(log_dir / 'gate-events.1.jsonl') + import json + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': 'block' if blocked else 'allow'} + with log_path.open('a', encoding='utf-8') as fh: + fh.write(json.dumps(record, ensure_ascii=False) + '\n') + except Exception: + return + +def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: + """Run the guard named on `argv` (`--guard `) against `command`.""" + guard = guard_path_from_argv(argv) + if guard is None or not guard.exists(): + return None + verdict = run_guard(guard, command) + if verdict in (GUARD_BLOCKED, GUARD_CLEAN): + log_outcome(guard, tool, verdict == GUARD_BLOCKED) + if verdict == GUARD_BLOCKED: + return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + if verdict == GUARD_ERRORED: + return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") + return None + + +def handle(event): + if event.event == "pre_tool" and event.command: + verdict = evaluate(sys.argv[1:], event.command, event.tool or "") + if verdict is not None: + outcome, reason = verdict + return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + return None +# <<< agentseam handler <<< + + +def _coerce(result): + if result is None: + return Decision.allow() + if isinstance(result, Decision): + return result + raise TypeError("handle() must return a Decision or None, got %r" % (type(result),)) + + +def _read_payload(stream): + # Decode bytes ourselves rather than trust the platform locale -- a BOM'd or non-UTF-8 + # stdin must not silently disable the gate. See agentseam.dispatch for the incident + # this guards: a Windows console's cp1252 layer turning a UTF-8 BOM into three bytes + # json cannot parse, with the hook allowing everything while claiming enforcement. + buffer = getattr(stream, "buffer", None) + if buffer is not None: + return buffer.read().decode("utf-8-sig", errors="replace") + return stream.read().lstrip("\ufeff") + + +def _emit(out, text): + buffer = getattr(out, "buffer", None) + if buffer is not None: + buffer.write(text.encode("utf-8")) + buffer.flush() + else: + out.write(text) + out.flush() + + +def main(stdin=None, stdout=None, exit=True): + """Read one payload from stdin, dispatch, emit the 'devin' response, exit.""" + stream = stdin if stdin is not None else sys.stdin + out = stdout if stdout is not None else sys.stdout + try: + raw = json.loads(_read_payload(stream)) + except Exception: + # Malformed input is not the agent's fault to pay for: allow, stay silent. + if exit: + sys.exit(0) + return 0 + event = parse(raw) + if event.event == UNKNOWN: + # A vendor event this adapter has no mapping for. handle() is not called: it + # reasons about the canonical vocabulary, and handing it something outside that + # vocabulary invites a decision made on a false premise. + if exit: + sys.exit(0) + return 0 + decision = degrade(_coerce(handle(event)), event) + text, code = respond(decision, event) + if text: + _emit(out, text) + if exit: + sys.exit(code) + return code + + +if __name__ == "__main__": # pragma: no cover + main() + diff --git a/.chock/bin/gemini_cli.py b/.chock/bin/gemini_cli.py new file mode 100755 index 0000000..f466f97 --- /dev/null +++ b/.chock/bin/gemini_cli.py @@ -0,0 +1,789 @@ +# Generated by agentseam 0.2.0 -- 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. +# +# Self-contained: stdlib only, no "import agentseam" anywhere in this file. Regenerate +# with `agentseam.bundler.bundle("gemini_cli")` (same agentseam version -> identical bytes, +# except your own edits inside the handler block) rather than patching this by hand. +# https://github.com/open-coder-ai/agentseam + +from __future__ import annotations + +import json +_json = json +import sys + +import os as _chock_os +import shlex as _chock_shlex +import subprocess as _chock_subprocess +from datetime import datetime as _chock_datetime, timezone as _chock_timezone +from pathlib import Path as _chock_Path +import warnings as _warnings + +# ------------------------------------------------------------------------------ +# contract (agentseam 0.2.0) + +"""Canonical event vocabulary, normalized envelope, and decision type.""" + + + +SESSION_START = "session_start" +SESSION_END = "session_end" +PROMPT_SUBMIT = "prompt_submit" +PRE_TOOL = "pre_tool" +POST_TOOL = "post_tool" +TOOL_FAILURE = "tool_failure" +PRE_COMPACT = "pre_compact" +STOP = "stop" +SUBAGENT_START = "subagent_start" +SUBAGENT_STOP = "subagent_stop" +INSTRUCTIONS_LOADED = "instructions_loaded" +FILE_CHANGED = "file_changed" + +UNKNOWN = "unknown" + +EVENTS = ( + SESSION_START, + SESSION_END, + PROMPT_SUBMIT, + PRE_TOOL, + POST_TOOL, + TOOL_FAILURE, + PRE_COMPACT, + STOP, + SUBAGENT_START, + SUBAGENT_STOP, + INSTRUCTIONS_LOADED, + FILE_CHANGED, +) + + +class Event: + """One agent lifecycle event, normalized.""" + + __slots__ = ( + "agent", + "event", + "tool", + "command", + "path", + "content", + "output", + "prompt", + "session_id", + "tool_use_id", + "cwd", + "raw", + ) + + def __init__( + self, + agent, + event, + *, + tool=None, + command=None, + path=None, + content=None, + output=None, + prompt=None, + session_id=None, + tool_use_id=None, + cwd=None, + raw=None, + ): + self.agent = agent + self.event = event + self.tool = tool + self.command = command + self.path = path + self.content = content + self.output = output + self.prompt = prompt + self.session_id = session_id + self.tool_use_id = tool_use_id + self.cwd = cwd + self.raw = raw if raw is not None else {} + + def __repr__(self): # pragma: no cover - debugging aid + return "Event(%s/%s tool=%r path=%r)" % (self.agent, self.event, self.tool, self.path) + + +ALLOW = "allow" +DENY = "deny" +ESCALATE = "escalate" +TRANSFORM = "transform" +WARN = "warn" +VOUCH = "vouch" + +# Pre-ACS-alignment names. Same strings as their ACS-named counterparts, so every existing +# `is`/`==` comparison against the old constant keeps working untouched. +ASK = ESCALATE +REWRITE = TRANSFORM + +_CANONICAL_OUTCOMES = (ALLOW, DENY, ESCALATE, TRANSFORM, WARN, VOUCH) + +# The literal spellings a caller might still pass to Decision(outcome, ...) directly, mapped +# to the value that now backs them. Only needed for the raw-string constructor path -- +# Decision.ask()/.rewrite() below build the canonical outcome themselves. +_LEGACY_SPELLING = {"ask": ESCALATE, "rewrite": TRANSFORM} + + +class Decision: + """What a handler wants to happen. Adapters translate this to vendor dialect.""" + + __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + + #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). + DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) + + def __init__(self, outcome, reason=None, updated_input=None, evidence=None, context=None): + outcome = _LEGACY_SPELLING.get(outcome, outcome) + if outcome not in _CANONICAL_OUTCOMES: + raise ValueError("unknown outcome: %r" % (outcome,)) + self.outcome = outcome + self.reason = reason + self.updated_input = updated_input + self.evidence = evidence or {} + self.context = context + + @classmethod + def allow(cls, reason=None, evidence=None, context=None): + return cls(ALLOW, reason, evidence=evidence, context=context) + + @classmethod + def deny(cls, reason, evidence=None, context=None): + return cls(DENY, reason, evidence=evidence, context=context) + + @classmethod + def escalate(cls, reason, evidence=None, context=None): + """Defer the action to the host's own approval path (ACS `escalate`).""" + return cls(ESCALATE, reason, evidence=evidence, context=context) + + @classmethod + def ask(cls, reason, evidence=None, context=None): + """Deprecated alias of escalate() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.ask() is deprecated; use Decision.escalate()", DeprecationWarning, stacklevel=2) + return cls.escalate(reason, evidence=evidence, context=context) + + @classmethod + def transform(cls, updated_input, reason=None, evidence=None, context=None): + """Replace the tool input wholesale (ACS `transform`, at whole-value granularity).""" + return cls(TRANSFORM, reason, updated_input=updated_input, evidence=evidence, context=context) + + @classmethod + def rewrite(cls, updated_input, reason=None, evidence=None, context=None): + """Deprecated alias of transform() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.rewrite() is deprecated; use Decision.transform()", DeprecationWarning, stacklevel=2) + return cls.transform(updated_input, reason, evidence=evidence, context=context) + + @classmethod + def warn(cls, reason=None, evidence=None, context=None): + """Permit the action with no change, recording a warning (ACS `warn`).""" + return cls(WARN, reason, evidence=evidence, context=context) + + @classmethod + def vouch(cls, reason=None, evidence=None, context=None): + return cls(VOUCH, reason, evidence=evidence, context=context) + + def __repr__(self): # pragma: no cover - debugging aid + return "Decision(%s, %r)" % (self.outcome, self.reason) + + +def degraded_from(decision): + """What this decision was before the dispatcher reduced it, or None.""" + return (decision.evidence or {}).get("degraded_from") + + +def tool_input_of(raw): + """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" + if isinstance(raw, dict): + return raw + if isinstance(raw, str) and raw[:1] == "{": + try: + parsed = _json.loads(raw) + except Exception: + return {} + if isinstance(parsed, dict): + return parsed + return {} + + +# ------------------------------------------------------------------------------ +# flat_decision family engine (trimmed to what this entry uses) + +OBSERVED_MARKERS = ( + "transcript_path", + "permission_mode", + "stop_hook_active", + "agent_transcript_path", + "background_tasks", + "session_crons", + "custom_instructions", + "effort", +) + +def looks_like_claude_code(raw): + """True when the payload carries a field only Claude Code has been seen to send.""" + return isinstance(raw, dict) and any(marker in raw for marker in OBSERVED_MARKERS) + +PROBES = {"looks_like_claude_code": looks_like_claude_code} + +def _wire_name(cfg, raw): + for key in cfg["claims"].get("event_key", ()): + name = raw.get(key) + if name is not None: + return name + return None + +def hj_claims(cfg, raw): + """True when this payload matches the entry's marker discipline.""" + if not isinstance(raw, dict): + return False + c = cfg["claims"] + name = _wire_name(cfg, raw) + if name in c.get("accept_names", ()): + return True + if name not in cfg["events"]: + 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 + return False + return True + +def _segment(node, part): + """One path segment: a dict key, or `key[N]` indexing the list under it.""" + if part.endswith("]") and "[" in part: + key, _, index = part[:-1].partition("[") + items = node.get(key) if isinstance(node, dict) else None + i = int(index) + return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None + return node.get(part) if isinstance(node, dict) else None + +def _walk(node, path): + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + if "[]." in path: + head, sub = path.split("[].", 1) + items = _walk(node, head) + if not isinstance(items, (list, tuple)): + return None + joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) + return joined or None + for part in path.split("."): + node = _segment(node, part) + if node is None: + return None + return node + +def _lookup(raw, ti, key): + """One config key: a `tool_input.` path walks the decoded tool input, else the payload.""" + if key.startswith("tool_input."): + return _walk(ti, key[len("tool_input.") :]) + return _walk(raw, key) + +def _field(raw, ti, chain): + value = None + for key in chain: + if value: + break + value = value or _lookup(raw, ti, key) + return value + +_FIELD_META = ("tool_input", "content_only_for_write_tools", "stringify") + +def _tool_input_raw(cfg, raw): + for key in cfg["fields"].get("tool_input", ("tool_input",)): + value = raw.get(key) + if value is not None: + return value + return None + +def _canonical_of(cfg, name): + """The canonical event for one wire name; an entry with no `events` at all (antigravity, + whose payloads never carry one) maps the shape-inferred name back through `wire_events`.""" + events = cfg["events"] + if not events: + return {wire: canonical for canonical, wire in cfg.get("wire_events", {}).items()}.get(name, UNKNOWN) + return events.get(name, UNKNOWN) + +def hj_parse(cfg, raw, wire=None): + """Normalise one payload along the entry's ordered field-fallback chains. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + ti = tool_input_of(_tool_input_raw(cfg, raw)) + fields = {name: _field(raw, ti, chain) for name, chain in cfg["fields"].items() if name not in _FIELD_META} + if cfg["fields"].get("content_only_for_write_tools") and fields.get("tool") not in cfg["tools"].get("write", ()): + fields["content"] = None + if isinstance(fields.get("output"), (dict, list)): + fields["output"] = _json.dumps(fields["output"]) + for name in cfg["fields"].get("stringify", ()): + if fields.get(name) is not None: + fields[name] = str(fields[name]) + return Event( + cfg["agent"], + _canonical_of(cfg, wire if wire is not None else _wire_name(cfg, raw)), + tool=fields.get("tool"), + command=fields.get("command"), + path=fields.get("path"), + content=fields.get("content"), + output=fields.get("output"), + prompt=fields.get("prompt"), + session_id=fields.get("session_id"), + tool_use_id=fields.get("tool_use_id"), + cwd=fields.get("cwd"), + raw=raw, + ) + +def hj_reverse(cfg): + """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" + reverse = {} + for name, canonical in cfg["events"].items(): + if canonical != UNKNOWN: + reverse[canonical] = name + reverse.update(cfg.get("wire_events", {})) + return reverse + +def _context_value(v, decision): + if v.get("context_source") == "context": + return decision.context + if v.get("context_source") == "reason": + return decision.reason + return None + +def _context_body(name, value): + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "additionalContext": value}}), 0 + +def _note_for(v, decision, at_gate, missing_input): + notes = v.get("degrade_notes", {}) + if decision.outcome == ESCALATE: + if degraded_from(decision) == TRANSFORM and "escalate_from_transform" in notes: + return notes["escalate_from_transform"] + if at_gate and "escalate_gate" in notes: + return notes["escalate_gate"] + return notes.get("escalate") + if decision.outcome == TRANSFORM: + if missing_input and "transform_missing_input" in notes: + return notes["transform_missing_input"] + return notes.get("transform") + return None + +def _default_for(v, decision, at_gate, wire=None): + gate_defaults = v.get("gate_reason_defaults", {}) + if wire in gate_defaults: + return gate_defaults[wire] + defaults = v.get("reason_defaults", {}) + key = {DENY: "deny", ESCALATE: "escalate", TRANSFORM: "transform"}.get(decision.outcome, "deny") + if at_gate and key + "_gate" in defaults: + return defaults[key + "_gate"] + return defaults.get(key, "blocked by policy") + +def _refusal_text(v, decision, at_gate, wire=None): + note = _note_for(v, decision, at_gate, decision.updated_input is None) + default = _default_for(v, decision, at_gate, wire) + if note and "%s" in note: + # A template note fills (the reason or its default, the wire event name) itself. + return note % (decision.reason or default, wire) + reason = decision.reason + if v.get("note_style") == "suffix": + reason = reason or default + return "%s (%s)" % (reason, note) if note else reason + text = "%s (%s)" % (reason, note) if reason and note else (note or reason) + return text or default + +def _g1(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 + 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 + if ( + decision.outcome == ESCALATE + and gate["honours_escalate"] + and "escalate" in words + # An escalate the dispatcher degraded a transform into is a block where the entry + # names that degradation (antigravity): prompting would offer the unmodified call. + and not (degraded_from(decision) == TRANSFORM and "escalate_from_transform" in v.get("degrade_notes", {})) + ): + reason = decision.reason or _default_for(v, decision, True, wire) + return _json.dumps({"decision": words["escalate"], "reason": reason}), 0 + out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, False, wire)} + if at_context_event and v.get("context_source") == "context" and decision.context: + out["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": decision.context} + return _json.dumps(out), 0 + +def hj_respond(cfg, decision, event, wire=None): + """(stdout_text, exit_code) in this entry's dialect for the gate the payload names. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + v = cfg["verdicts"] + if wire is None: + wire = _wire_name(cfg, event.raw or {}) + if wire in v.get("empty_object_events", ()): + return _json.dumps({}), 0 + if wire is None: + wire = v.get("default_wire_event") + if wire is None and v.get("missing_wire") == "reverse_map": + wire = hj_reverse(cfg).get(event.event) + name = wire if v.get("echo") == "payload" else hj_reverse(cfg).get(event.event, "PreToolUse") + gate = v["gates"].get(wire) + if gate is None: + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + return "", 0 + if gate["grammar"] == "G2": + return _g2(v, gate, decision, name) + return _g1(v, gate, decision, wire, name) + +_WINDOWS_KEYS = ("commandWindows", "windows") + +def _hook_dict(cfg, command): + entry = {"type": "command", "command": command} + for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): + if key in _WINDOWS_KEYS: + entry[key] = powershell_command(command) + else: + entry[key] = value + return entry + +def 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": + 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) + if not name: + continue + entry = {"hooks": [_hook_dict(cfg, command)]} + if matcher and hook_entry["matcher"]: + entry["matcher"] = matcher + hooks.setdefault(name, []).append(entry) + if hook_entry.get("group"): + return {hook_entry["group"]: hooks} + return hooks if hook_entry.get("bare") else {"hooks": hooks} + + +# ------------------------------------------------------------------------------ +# gemini_cli vendor config + engine binding + +AGENT = "gemini_cli" + +VENDOR = {'agent': 'gemini_cli', 'claims': {'client_types': (None, 'gemini_cli', 'gemini'), 'event_key': ('hook_event_name',), 'mode': 'marker', 'reject_markers': ('timestamp', 'project_path', 'prompt_id', 'turn_id'), 'reject_probes': ('looks_like_claude_code',)}, 'config_format': 'json', 'config_path': '.gemini/settings.json', 'display': 'Gemini CLI', 'events': {'AfterAgent': 'stop', 'AfterTool': 'post_tool', 'BeforeAgent': 'prompt_submit', 'BeforeTool': 'pre_tool', 'PreCompress': 'pre_compact', 'SessionEnd': 'session_end', 'SessionStart': 'session_start'}, 'evidence': {'claims': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'flat_decision', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content', 'tool_input.new_string', 'tool_input.new_str'), 'content_only_for_write_tools': True, 'cwd': ('cwd',), 'output': ('tool_output', 'tool_response'), 'path': ('tool_input.file_path', 'tool_input.absolute_path', 'tool_input.path'), 'prompt': ('prompt', 'user_message'), 'session_id': ('session_id',), 'tool': ('tool_name',)}, 'hook_entry': {'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'tools': {'shell': ('run_shell_command',), 'write': ('write_file', 'replace')}, 'verdicts': {'answer_events': ('AfterAgent', 'AfterTool', 'BeforeAgent', 'BeforeTool'), 'bare_allow': 'inert', 'degrade_notes': {'escalate': '%s (confirmation required; %s cannot prompt from a hook)'}, 'gates': {'AfterAgent': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'AfterTool': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'BeforeAgent': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'BeforeTool': {'grammar': 'G1', 'honours_escalate': True, 'honours_transform': True}}, 'reason_defaults': {'escalate': 'policy requires confirmation', 'escalate_gate': 'confirmation required'}, 'transform_grammar': 'hook_specific_tool_input', 'vocabulary': ('allow', 'ask', 'deny'), 'vocabulary_basis': 'verified', 'words': {'allow': 'allow', 'block': 'deny', 'escalate': 'ask'}}} + + +def claims(raw): + return hj_claims(VENDOR, raw) + + +def parse(raw): + return hj_parse(VENDOR, raw) + + +def respond(decision, event): + return hj_respond(VENDOR, decision, event) + + +def hook_config(canonical_events, command, matcher=None): + return hook_entry_config(VENDOR, canonical_events, command, matcher) + + +# ------------------------------------------------------------------------------ +# runtime (agentseam dispatch, specialized to gemini_cli) + +_VOUCH_SPEAKS = False +_WARN_SPEAKS = False +_TRANSFORM_EVENTS = frozenset(('pre_tool',)) + + +def degrade(decision, event): + """Reduce a decision to what 'gemini_cli' can actually honor, honestly. + + This is agentseam.dispatch.degrade(), specialized to one fixed agent so this file needs + no "import agentseam" -- no evidence establishes that an explicit approval word means anything beyond a plain allow here, so vouch degrades to one; see agentseam.allow_semantics. + """ + if decision.outcome == TRANSFORM and event.event not in _TRANSFORM_EVENTS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = TRANSFORM + return Decision.escalate( + decision.reason or "input requires modification before it can run", evidence=evidence + ) + if decision.outcome == VOUCH and not _VOUCH_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = VOUCH + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + if decision.outcome == WARN and not _WARN_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = WARN + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + return decision + + +# >>> agentseam handler >>> +GUARD_VIOLATION = 1 + +_BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') + +GATE_LOG_ENV = 'CHOCK_GATE_LOG' + +_LOG_MAX_BYTES = 1048576 + +_GUARD_TIMEOUT_SECONDS = 30 + +GUARD_BLOCKED = 'blocked' + +GUARD_CLEAN = 'clean' + +GUARD_UNCHECKED = 'unchecked' + +GUARD_ERRORED = 'errored' + +VERDICT_DENY = 'deny' + +VERDICT_ESCALATE = 'escalate' + +def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: + """The `--guard ` argument a vendored runtime was invoked with, or None.""" + if '--guard' in argv: + i = argv.index('--guard') + if i + 1 < len(argv): + return _chock_Path(argv[i + 1]) + return None + +def find_bash(guard: _chock_Path) -> str | None: + """First interpreter that can actually see `guard`, or None.""" + for candidate in _BASH_CANDIDATES: + try: + proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10) + except (OSError, _chock_subprocess.SubprocessError): + continue + if proc.returncode == 0: + return candidate + return None + +def run_guard(guard: _chock_Path, command: str) -> str: + """`GUARD_BLOCKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + try: + args = _chock_shlex.split(command) + except ValueError: + print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) + return GUARD_UNCHECKED + if not args: + return GUARD_UNCHECKED + bash = find_bash(guard) + if bash is None: + print(f'chock: no usable bash found, {guard.name} not checked', file=sys.stderr) + return GUARD_UNCHECKED + try: + env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} + proc = _chock_subprocess.run([bash, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS) + except _chock_subprocess.TimeoutExpired: + print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) + return GUARD_ERRORED + except (OSError, UnicodeError) as exc: + print(f'chock: guard could not run, not checked: {exc}', file=sys.stderr) + return GUARD_ERRORED + if proc.returncode == GUARD_VIOLATION: + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + if not ((proc.stdout or '') + (proc.stderr or '')).strip(): + print(f'chock: blocked by {_chock_Path(guard).name} (guard gave no reason)', file=sys.stderr) + return GUARD_BLOCKED + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout or '').strip().splitlines() + print(f'chock: guard exited {proc.returncode}, not checked' + (f': {detail[0][:120]}' if detail else ''), file=sys.stderr) + return GUARD_ERRORED + return GUARD_CLEAN + +def log_outcome(guard: _chock_Path, tool: str, blocked: bool) -> None: + """Append one outcome record. Best effort: never raises, never changes the verdict.""" + try: + if _chock_os.environ.get(GATE_LOG_ENV) == '0': + return + guard = guard.resolve() + if guard.parent.name != 'implementations': + return + artifact_root = None + for parent in guard.parents: + if (parent / '.chock').is_dir(): + artifact_root = parent / '.chock' + break + if artifact_root is None: + return + log_dir = artifact_root / 'log' + log_dir.mkdir(parents=True, exist_ok=True) + log_path = log_dir / 'gate-events.jsonl' + if log_path.exists() and log_path.stat().st_size > _LOG_MAX_BYTES: + log_path.replace(log_dir / 'gate-events.1.jsonl') + import json + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': 'block' if blocked else 'allow'} + with log_path.open('a', encoding='utf-8') as fh: + fh.write(json.dumps(record, ensure_ascii=False) + '\n') + except Exception: + return + +def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: + """Run the guard named on `argv` (`--guard `) against `command`.""" + guard = guard_path_from_argv(argv) + if guard is None or not guard.exists(): + return None + verdict = run_guard(guard, command) + if verdict in (GUARD_BLOCKED, GUARD_CLEAN): + log_outcome(guard, tool, verdict == GUARD_BLOCKED) + if verdict == GUARD_BLOCKED: + return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + if verdict == GUARD_ERRORED: + return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") + return None + + +def handle(event): + if event.event == "pre_tool" and event.command: + verdict = evaluate(sys.argv[1:], event.command, event.tool or "") + if verdict is not None: + outcome, reason = verdict + return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + return None +# <<< agentseam handler <<< + + +def _coerce(result): + if result is None: + return Decision.allow() + if isinstance(result, Decision): + return result + raise TypeError("handle() must return a Decision or None, got %r" % (type(result),)) + + +def _read_payload(stream): + # Decode bytes ourselves rather than trust the platform locale -- a BOM'd or non-UTF-8 + # stdin must not silently disable the gate. See agentseam.dispatch for the incident + # this guards: a Windows console's cp1252 layer turning a UTF-8 BOM into three bytes + # json cannot parse, with the hook allowing everything while claiming enforcement. + buffer = getattr(stream, "buffer", None) + if buffer is not None: + return buffer.read().decode("utf-8-sig", errors="replace") + return stream.read().lstrip("\ufeff") + + +def _emit(out, text): + buffer = getattr(out, "buffer", None) + if buffer is not None: + buffer.write(text.encode("utf-8")) + buffer.flush() + else: + out.write(text) + out.flush() + + +def main(stdin=None, stdout=None, exit=True): + """Read one payload from stdin, dispatch, emit the 'gemini_cli' response, exit.""" + stream = stdin if stdin is not None else sys.stdin + out = stdout if stdout is not None else sys.stdout + try: + raw = json.loads(_read_payload(stream)) + except Exception: + # Malformed input is not the agent's fault to pay for: allow, stay silent. + if exit: + sys.exit(0) + return 0 + event = parse(raw) + if event.event == UNKNOWN: + # A vendor event this adapter has no mapping for. handle() is not called: it + # reasons about the canonical vocabulary, and handing it something outside that + # vocabulary invites a decision made on a false premise. + if exit: + sys.exit(0) + return 0 + decision = degrade(_coerce(handle(event)), event) + text, code = respond(decision, event) + if text: + _emit(out, text) + if exit: + sys.exit(code) + return code + + +if __name__ == "__main__": # pragma: no cover + main() + diff --git a/.chock/bin/grok.py b/.chock/bin/grok.py new file mode 100755 index 0000000..2ddef51 --- /dev/null +++ b/.chock/bin/grok.py @@ -0,0 +1,772 @@ +# Generated by agentseam 0.2.0 -- 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. +# +# Self-contained: stdlib only, no "import agentseam" anywhere in this file. Regenerate +# with `agentseam.bundler.bundle("grok")` (same agentseam version -> identical bytes, +# except your own edits inside the handler block) rather than patching this by hand. +# https://github.com/open-coder-ai/agentseam + +from __future__ import annotations + +import json +_json = json +import sys + +import os as _chock_os +import shlex as _chock_shlex +import subprocess as _chock_subprocess +from datetime import datetime as _chock_datetime, timezone as _chock_timezone +from pathlib import Path as _chock_Path +import warnings as _warnings + +# ------------------------------------------------------------------------------ +# contract (agentseam 0.2.0) + +"""Canonical event vocabulary, normalized envelope, and decision type.""" + + + +SESSION_START = "session_start" +SESSION_END = "session_end" +PROMPT_SUBMIT = "prompt_submit" +PRE_TOOL = "pre_tool" +POST_TOOL = "post_tool" +TOOL_FAILURE = "tool_failure" +PRE_COMPACT = "pre_compact" +STOP = "stop" +SUBAGENT_START = "subagent_start" +SUBAGENT_STOP = "subagent_stop" +INSTRUCTIONS_LOADED = "instructions_loaded" +FILE_CHANGED = "file_changed" + +UNKNOWN = "unknown" + +EVENTS = ( + SESSION_START, + SESSION_END, + PROMPT_SUBMIT, + PRE_TOOL, + POST_TOOL, + TOOL_FAILURE, + PRE_COMPACT, + STOP, + SUBAGENT_START, + SUBAGENT_STOP, + INSTRUCTIONS_LOADED, + FILE_CHANGED, +) + + +class Event: + """One agent lifecycle event, normalized.""" + + __slots__ = ( + "agent", + "event", + "tool", + "command", + "path", + "content", + "output", + "prompt", + "session_id", + "tool_use_id", + "cwd", + "raw", + ) + + def __init__( + self, + agent, + event, + *, + tool=None, + command=None, + path=None, + content=None, + output=None, + prompt=None, + session_id=None, + tool_use_id=None, + cwd=None, + raw=None, + ): + self.agent = agent + self.event = event + self.tool = tool + self.command = command + self.path = path + self.content = content + self.output = output + self.prompt = prompt + self.session_id = session_id + self.tool_use_id = tool_use_id + self.cwd = cwd + self.raw = raw if raw is not None else {} + + def __repr__(self): # pragma: no cover - debugging aid + return "Event(%s/%s tool=%r path=%r)" % (self.agent, self.event, self.tool, self.path) + + +ALLOW = "allow" +DENY = "deny" +ESCALATE = "escalate" +TRANSFORM = "transform" +WARN = "warn" +VOUCH = "vouch" + +# Pre-ACS-alignment names. Same strings as their ACS-named counterparts, so every existing +# `is`/`==` comparison against the old constant keeps working untouched. +ASK = ESCALATE +REWRITE = TRANSFORM + +_CANONICAL_OUTCOMES = (ALLOW, DENY, ESCALATE, TRANSFORM, WARN, VOUCH) + +# The literal spellings a caller might still pass to Decision(outcome, ...) directly, mapped +# to the value that now backs them. Only needed for the raw-string constructor path -- +# Decision.ask()/.rewrite() below build the canonical outcome themselves. +_LEGACY_SPELLING = {"ask": ESCALATE, "rewrite": TRANSFORM} + + +class Decision: + """What a handler wants to happen. Adapters translate this to vendor dialect.""" + + __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + + #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). + DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) + + def __init__(self, outcome, reason=None, updated_input=None, evidence=None, context=None): + outcome = _LEGACY_SPELLING.get(outcome, outcome) + if outcome not in _CANONICAL_OUTCOMES: + raise ValueError("unknown outcome: %r" % (outcome,)) + self.outcome = outcome + self.reason = reason + self.updated_input = updated_input + self.evidence = evidence or {} + self.context = context + + @classmethod + def allow(cls, reason=None, evidence=None, context=None): + return cls(ALLOW, reason, evidence=evidence, context=context) + + @classmethod + def deny(cls, reason, evidence=None, context=None): + return cls(DENY, reason, evidence=evidence, context=context) + + @classmethod + def escalate(cls, reason, evidence=None, context=None): + """Defer the action to the host's own approval path (ACS `escalate`).""" + return cls(ESCALATE, reason, evidence=evidence, context=context) + + @classmethod + def ask(cls, reason, evidence=None, context=None): + """Deprecated alias of escalate() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.ask() is deprecated; use Decision.escalate()", DeprecationWarning, stacklevel=2) + return cls.escalate(reason, evidence=evidence, context=context) + + @classmethod + def transform(cls, updated_input, reason=None, evidence=None, context=None): + """Replace the tool input wholesale (ACS `transform`, at whole-value granularity).""" + return cls(TRANSFORM, reason, updated_input=updated_input, evidence=evidence, context=context) + + @classmethod + def rewrite(cls, updated_input, reason=None, evidence=None, context=None): + """Deprecated alias of transform() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.rewrite() is deprecated; use Decision.transform()", DeprecationWarning, stacklevel=2) + return cls.transform(updated_input, reason, evidence=evidence, context=context) + + @classmethod + def warn(cls, reason=None, evidence=None, context=None): + """Permit the action with no change, recording a warning (ACS `warn`).""" + return cls(WARN, reason, evidence=evidence, context=context) + + @classmethod + def vouch(cls, reason=None, evidence=None, context=None): + return cls(VOUCH, reason, evidence=evidence, context=context) + + def __repr__(self): # pragma: no cover - debugging aid + return "Decision(%s, %r)" % (self.outcome, self.reason) + + +def degraded_from(decision): + """What this decision was before the dispatcher reduced it, or None.""" + return (decision.evidence or {}).get("degraded_from") + + +def tool_input_of(raw): + """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" + if isinstance(raw, dict): + return raw + if isinstance(raw, str) and raw[:1] == "{": + try: + parsed = _json.loads(raw) + except Exception: + return {} + if isinstance(parsed, dict): + return parsed + return {} + + +# ------------------------------------------------------------------------------ +# flat_decision family engine (trimmed to what this entry uses) + +def _wire_name(cfg, raw): + for key in cfg["claims"].get("event_key", ()): + name = raw.get(key) + if name is not None: + return name + return None + +def hj_claims(cfg, raw): + """True when this payload matches the entry's marker discipline.""" + if not isinstance(raw, dict): + return False + c = cfg["claims"] + name = _wire_name(cfg, raw) + if name in c.get("accept_names", ()): + return True + if name not in cfg["events"]: + 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 + return False + return True + +def _segment(node, part): + """One path segment: a dict key, or `key[N]` indexing the list under it.""" + if part.endswith("]") and "[" in part: + key, _, index = part[:-1].partition("[") + items = node.get(key) if isinstance(node, dict) else None + i = int(index) + return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None + return node.get(part) if isinstance(node, dict) else None + +def _walk(node, path): + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + if "[]." in path: + head, sub = path.split("[].", 1) + items = _walk(node, head) + if not isinstance(items, (list, tuple)): + return None + joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) + return joined or None + for part in path.split("."): + node = _segment(node, part) + if node is None: + return None + return node + +def _lookup(raw, ti, key): + """One config key: a `tool_input.` path walks the decoded tool input, else the payload.""" + if key.startswith("tool_input."): + return _walk(ti, key[len("tool_input.") :]) + return _walk(raw, key) + +def _field(raw, ti, chain): + value = None + for key in chain: + if value: + break + value = value or _lookup(raw, ti, key) + return value + +_FIELD_META = ("tool_input", "content_only_for_write_tools", "stringify") + +def _tool_input_raw(cfg, raw): + for key in cfg["fields"].get("tool_input", ("tool_input",)): + value = raw.get(key) + if value is not None: + return value + return None + +def _canonical_of(cfg, name): + """The canonical event for one wire name; an entry with no `events` at all (antigravity, + whose payloads never carry one) maps the shape-inferred name back through `wire_events`.""" + events = cfg["events"] + if not events: + return {wire: canonical for canonical, wire in cfg.get("wire_events", {}).items()}.get(name, UNKNOWN) + return events.get(name, UNKNOWN) + +def hj_parse(cfg, raw, wire=None): + """Normalise one payload along the entry's ordered field-fallback chains. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + ti = tool_input_of(_tool_input_raw(cfg, raw)) + fields = {name: _field(raw, ti, chain) for name, chain in cfg["fields"].items() if name not in _FIELD_META} + if cfg["fields"].get("content_only_for_write_tools") and fields.get("tool") not in cfg["tools"].get("write", ()): + fields["content"] = None + if isinstance(fields.get("output"), (dict, list)): + fields["output"] = _json.dumps(fields["output"]) + for name in cfg["fields"].get("stringify", ()): + if fields.get(name) is not None: + fields[name] = str(fields[name]) + return Event( + cfg["agent"], + _canonical_of(cfg, wire if wire is not None else _wire_name(cfg, raw)), + tool=fields.get("tool"), + command=fields.get("command"), + path=fields.get("path"), + content=fields.get("content"), + output=fields.get("output"), + prompt=fields.get("prompt"), + session_id=fields.get("session_id"), + tool_use_id=fields.get("tool_use_id"), + cwd=fields.get("cwd"), + raw=raw, + ) + +def hj_reverse(cfg): + """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" + reverse = {} + for name, canonical in cfg["events"].items(): + if canonical != UNKNOWN: + reverse[canonical] = name + reverse.update(cfg.get("wire_events", {})) + return reverse + +def _context_value(v, decision): + if v.get("context_source") == "context": + return decision.context + if v.get("context_source") == "reason": + return decision.reason + return None + +def _context_body(name, value): + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "additionalContext": value}}), 0 + +def _note_for(v, decision, at_gate, missing_input): + notes = v.get("degrade_notes", {}) + if decision.outcome == ESCALATE: + if degraded_from(decision) == TRANSFORM and "escalate_from_transform" in notes: + return notes["escalate_from_transform"] + if at_gate and "escalate_gate" in notes: + return notes["escalate_gate"] + return notes.get("escalate") + if decision.outcome == TRANSFORM: + if missing_input and "transform_missing_input" in notes: + return notes["transform_missing_input"] + return notes.get("transform") + return None + +def _default_for(v, decision, at_gate, wire=None): + gate_defaults = v.get("gate_reason_defaults", {}) + if wire in gate_defaults: + return gate_defaults[wire] + defaults = v.get("reason_defaults", {}) + key = {DENY: "deny", ESCALATE: "escalate", TRANSFORM: "transform"}.get(decision.outcome, "deny") + if at_gate and key + "_gate" in defaults: + return defaults[key + "_gate"] + return defaults.get(key, "blocked by policy") + +def _refusal_text(v, decision, at_gate, wire=None): + note = _note_for(v, decision, at_gate, decision.updated_input is None) + default = _default_for(v, decision, at_gate, wire) + if note and "%s" in note: + # A template note fills (the reason or its default, the wire event name) itself. + return note % (decision.reason or default, wire) + reason = decision.reason + if v.get("note_style") == "suffix": + reason = reason or default + return "%s (%s)" % (reason, note) if note else reason + text = "%s (%s)" % (reason, note) if reason and note else (note or reason) + return text or default + +def _g1(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 + 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 + if ( + decision.outcome == ESCALATE + and gate["honours_escalate"] + and "escalate" in words + # An escalate the dispatcher degraded a transform into is a block where the entry + # names that degradation (antigravity): prompting would offer the unmodified call. + and not (degraded_from(decision) == TRANSFORM and "escalate_from_transform" in v.get("degrade_notes", {})) + ): + reason = decision.reason or _default_for(v, decision, True, wire) + return _json.dumps({"decision": words["escalate"], "reason": reason}), 0 + out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, False, wire)} + if at_context_event and v.get("context_source") == "context" and decision.context: + out["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": decision.context} + return _json.dumps(out), 0 + +def hj_respond(cfg, decision, event, wire=None): + """(stdout_text, exit_code) in this entry's dialect for the gate the payload names. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + v = cfg["verdicts"] + if wire is None: + wire = _wire_name(cfg, event.raw or {}) + if wire in v.get("empty_object_events", ()): + return _json.dumps({}), 0 + if wire is None: + wire = v.get("default_wire_event") + if wire is None and v.get("missing_wire") == "reverse_map": + wire = hj_reverse(cfg).get(event.event) + name = wire if v.get("echo") == "payload" else hj_reverse(cfg).get(event.event, "PreToolUse") + gate = v["gates"].get(wire) + if gate is None: + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + return "", 0 + if gate["grammar"] == "G2": + return _g2(v, gate, decision, name) + return _g1(v, gate, decision, wire, name) + +_WINDOWS_KEYS = ("commandWindows", "windows") + +def _hook_dict(cfg, command): + entry = {"type": "command", "command": command} + for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): + if key in _WINDOWS_KEYS: + entry[key] = powershell_command(command) + else: + entry[key] = value + return entry + +def 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": + 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) + if not name: + continue + entry = {"hooks": [_hook_dict(cfg, command)]} + if matcher and hook_entry["matcher"]: + entry["matcher"] = matcher + hooks.setdefault(name, []).append(entry) + if hook_entry.get("group"): + return {hook_entry["group"]: hooks} + return hooks if hook_entry.get("bare") else {"hooks": hooks} + + +# ------------------------------------------------------------------------------ +# grok vendor config + engine binding + +AGENT = "grok" + +VENDOR = {'agent': 'grok', 'claims': {'event_key': ('hookEventName',), 'mode': 'marker'}, 'config_format': 'json', 'config_path': '.grok/hooks/agentseam.json', 'display': 'Grok CLI', 'events': {'PermissionDenied': 'tool_failure', 'PostCompact': 'pre_compact', 'PostToolUse': 'post_tool', 'PostToolUseFailure': 'tool_failure', 'PreCompact': 'pre_compact', 'PreToolUse': 'pre_tool', 'SessionEnd': 'session_end', 'SessionStart': 'session_start', 'Stop': 'stop', 'StopFailure': 'stop', 'SubagentStart': 'subagent_start', 'SubagentStop': 'subagent_stop', 'UserPromptSubmit': 'prompt_submit'}, 'evidence': {'claims': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'flat_decision', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content', 'tool_input.new_string'), 'cwd': ('cwd', 'workspaceRoot'), 'output': ('toolOutput',), 'path': ('tool_input.file_path', 'tool_input.path'), 'prompt': ('prompt',), 'session_id': ('sessionId',), 'tool': ('toolName',), 'tool_input': ('toolInput',)}, 'hook_entry': {'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': True, 'tools': {}, 'verdicts': {'answer_events': ('PreToolUse',), 'bare_allow': 'silent', 'degrade_notes': {'escalate': 'Grok cannot prompt for confirmation', 'escalate_from_transform': 'Grok cannot modify a tool call', 'transform': 'Grok cannot modify a tool call'}, 'gates': {'PreToolUse': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'because', 'vocabulary': ('deny',), 'vocabulary_basis': 'verified', 'words': {'block': 'deny'}}, 'wire_events': {'pre_compact': 'PreCompact', 'stop': 'Stop', 'tool_failure': 'PostToolUseFailure'}} + + +def claims(raw): + return hj_claims(VENDOR, raw) + + +def parse(raw): + return hj_parse(VENDOR, raw) + + +def respond(decision, event): + return hj_respond(VENDOR, decision, event) + + +def hook_config(canonical_events, command, matcher=None): + return hook_entry_config(VENDOR, canonical_events, command, matcher) + + +# ------------------------------------------------------------------------------ +# runtime (agentseam dispatch, specialized to grok) + +_VOUCH_SPEAKS = False +_WARN_SPEAKS = False +_TRANSFORM_EVENTS = frozenset(()) + + +def degrade(decision, event): + """Reduce a decision to what 'grok' can actually honor, honestly. + + This is agentseam.dispatch.degrade(), specialized to one fixed agent so this file needs + no "import agentseam" -- no evidence establishes that an explicit approval word means anything beyond a plain allow here, so vouch degrades to one; see agentseam.allow_semantics. + """ + if decision.outcome == TRANSFORM and event.event not in _TRANSFORM_EVENTS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = TRANSFORM + return Decision.escalate( + decision.reason or "input requires modification before it can run", evidence=evidence + ) + if decision.outcome == VOUCH and not _VOUCH_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = VOUCH + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + if decision.outcome == WARN and not _WARN_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = WARN + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + return decision + + +# >>> agentseam handler >>> +GUARD_VIOLATION = 1 + +_BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') + +GATE_LOG_ENV = 'CHOCK_GATE_LOG' + +_LOG_MAX_BYTES = 1048576 + +_GUARD_TIMEOUT_SECONDS = 30 + +GUARD_BLOCKED = 'blocked' + +GUARD_CLEAN = 'clean' + +GUARD_UNCHECKED = 'unchecked' + +GUARD_ERRORED = 'errored' + +VERDICT_DENY = 'deny' + +VERDICT_ESCALATE = 'escalate' + +def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: + """The `--guard ` argument a vendored runtime was invoked with, or None.""" + if '--guard' in argv: + i = argv.index('--guard') + if i + 1 < len(argv): + return _chock_Path(argv[i + 1]) + return None + +def find_bash(guard: _chock_Path) -> str | None: + """First interpreter that can actually see `guard`, or None.""" + for candidate in _BASH_CANDIDATES: + try: + proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10) + except (OSError, _chock_subprocess.SubprocessError): + continue + if proc.returncode == 0: + return candidate + return None + +def run_guard(guard: _chock_Path, command: str) -> str: + """`GUARD_BLOCKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + try: + args = _chock_shlex.split(command) + except ValueError: + print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) + return GUARD_UNCHECKED + if not args: + return GUARD_UNCHECKED + bash = find_bash(guard) + if bash is None: + print(f'chock: no usable bash found, {guard.name} not checked', file=sys.stderr) + return GUARD_UNCHECKED + try: + env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} + proc = _chock_subprocess.run([bash, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS) + except _chock_subprocess.TimeoutExpired: + print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) + return GUARD_ERRORED + except (OSError, UnicodeError) as exc: + print(f'chock: guard could not run, not checked: {exc}', file=sys.stderr) + return GUARD_ERRORED + if proc.returncode == GUARD_VIOLATION: + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + if not ((proc.stdout or '') + (proc.stderr or '')).strip(): + print(f'chock: blocked by {_chock_Path(guard).name} (guard gave no reason)', file=sys.stderr) + return GUARD_BLOCKED + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout or '').strip().splitlines() + print(f'chock: guard exited {proc.returncode}, not checked' + (f': {detail[0][:120]}' if detail else ''), file=sys.stderr) + return GUARD_ERRORED + return GUARD_CLEAN + +def log_outcome(guard: _chock_Path, tool: str, blocked: bool) -> None: + """Append one outcome record. Best effort: never raises, never changes the verdict.""" + try: + if _chock_os.environ.get(GATE_LOG_ENV) == '0': + return + guard = guard.resolve() + if guard.parent.name != 'implementations': + return + artifact_root = None + for parent in guard.parents: + if (parent / '.chock').is_dir(): + artifact_root = parent / '.chock' + break + if artifact_root is None: + return + log_dir = artifact_root / 'log' + log_dir.mkdir(parents=True, exist_ok=True) + log_path = log_dir / 'gate-events.jsonl' + if log_path.exists() and log_path.stat().st_size > _LOG_MAX_BYTES: + log_path.replace(log_dir / 'gate-events.1.jsonl') + import json + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': 'block' if blocked else 'allow'} + with log_path.open('a', encoding='utf-8') as fh: + fh.write(json.dumps(record, ensure_ascii=False) + '\n') + except Exception: + return + +def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: + """Run the guard named on `argv` (`--guard `) against `command`.""" + guard = guard_path_from_argv(argv) + if guard is None or not guard.exists(): + return None + verdict = run_guard(guard, command) + if verdict in (GUARD_BLOCKED, GUARD_CLEAN): + log_outcome(guard, tool, verdict == GUARD_BLOCKED) + if verdict == GUARD_BLOCKED: + return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + if verdict == GUARD_ERRORED: + return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") + return None + + +def handle(event): + if event.event == "pre_tool" and event.command: + verdict = evaluate(sys.argv[1:], event.command, event.tool or "") + if verdict is not None: + outcome, reason = verdict + return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + return None +# <<< agentseam handler <<< + + +def _coerce(result): + if result is None: + return Decision.allow() + if isinstance(result, Decision): + return result + raise TypeError("handle() must return a Decision or None, got %r" % (type(result),)) + + +def _read_payload(stream): + # Decode bytes ourselves rather than trust the platform locale -- a BOM'd or non-UTF-8 + # stdin must not silently disable the gate. See agentseam.dispatch for the incident + # this guards: a Windows console's cp1252 layer turning a UTF-8 BOM into three bytes + # json cannot parse, with the hook allowing everything while claiming enforcement. + buffer = getattr(stream, "buffer", None) + if buffer is not None: + return buffer.read().decode("utf-8-sig", errors="replace") + return stream.read().lstrip("\ufeff") + + +def _emit(out, text): + buffer = getattr(out, "buffer", None) + if buffer is not None: + buffer.write(text.encode("utf-8")) + buffer.flush() + else: + out.write(text) + out.flush() + + +def main(stdin=None, stdout=None, exit=True): + """Read one payload from stdin, dispatch, emit the 'grok' response, exit.""" + stream = stdin if stdin is not None else sys.stdin + out = stdout if stdout is not None else sys.stdout + try: + raw = json.loads(_read_payload(stream)) + except Exception: + # Malformed input is not the agent's fault to pay for: allow, stay silent. + if exit: + sys.exit(0) + return 0 + event = parse(raw) + if event.event == UNKNOWN: + # A vendor event this adapter has no mapping for. handle() is not called: it + # reasons about the canonical vocabulary, and handing it something outside that + # vocabulary invites a decision made on a false premise. + if exit: + sys.exit(0) + return 0 + decision = degrade(_coerce(handle(event)), event) + text, code = respond(decision, event) + if text: + _emit(out, text) + if exit: + sys.exit(code) + return code + + +if __name__ == "__main__": # pragma: no cover + main() + diff --git a/.chock/bin/tabnine.py b/.chock/bin/tabnine.py new file mode 100755 index 0000000..23b39e6 --- /dev/null +++ b/.chock/bin/tabnine.py @@ -0,0 +1,772 @@ +# Generated by agentseam 0.2.0 -- 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. +# +# Self-contained: stdlib only, no "import agentseam" anywhere in this file. Regenerate +# with `agentseam.bundler.bundle("tabnine")` (same agentseam version -> identical bytes, +# except your own edits inside the handler block) rather than patching this by hand. +# https://github.com/open-coder-ai/agentseam + +from __future__ import annotations + +import json +_json = json +import sys + +import os as _chock_os +import shlex as _chock_shlex +import subprocess as _chock_subprocess +from datetime import datetime as _chock_datetime, timezone as _chock_timezone +from pathlib import Path as _chock_Path +import warnings as _warnings + +# ------------------------------------------------------------------------------ +# contract (agentseam 0.2.0) + +"""Canonical event vocabulary, normalized envelope, and decision type.""" + + + +SESSION_START = "session_start" +SESSION_END = "session_end" +PROMPT_SUBMIT = "prompt_submit" +PRE_TOOL = "pre_tool" +POST_TOOL = "post_tool" +TOOL_FAILURE = "tool_failure" +PRE_COMPACT = "pre_compact" +STOP = "stop" +SUBAGENT_START = "subagent_start" +SUBAGENT_STOP = "subagent_stop" +INSTRUCTIONS_LOADED = "instructions_loaded" +FILE_CHANGED = "file_changed" + +UNKNOWN = "unknown" + +EVENTS = ( + SESSION_START, + SESSION_END, + PROMPT_SUBMIT, + PRE_TOOL, + POST_TOOL, + TOOL_FAILURE, + PRE_COMPACT, + STOP, + SUBAGENT_START, + SUBAGENT_STOP, + INSTRUCTIONS_LOADED, + FILE_CHANGED, +) + + +class Event: + """One agent lifecycle event, normalized.""" + + __slots__ = ( + "agent", + "event", + "tool", + "command", + "path", + "content", + "output", + "prompt", + "session_id", + "tool_use_id", + "cwd", + "raw", + ) + + def __init__( + self, + agent, + event, + *, + tool=None, + command=None, + path=None, + content=None, + output=None, + prompt=None, + session_id=None, + tool_use_id=None, + cwd=None, + raw=None, + ): + self.agent = agent + self.event = event + self.tool = tool + self.command = command + self.path = path + self.content = content + self.output = output + self.prompt = prompt + self.session_id = session_id + self.tool_use_id = tool_use_id + self.cwd = cwd + self.raw = raw if raw is not None else {} + + def __repr__(self): # pragma: no cover - debugging aid + return "Event(%s/%s tool=%r path=%r)" % (self.agent, self.event, self.tool, self.path) + + +ALLOW = "allow" +DENY = "deny" +ESCALATE = "escalate" +TRANSFORM = "transform" +WARN = "warn" +VOUCH = "vouch" + +# Pre-ACS-alignment names. Same strings as their ACS-named counterparts, so every existing +# `is`/`==` comparison against the old constant keeps working untouched. +ASK = ESCALATE +REWRITE = TRANSFORM + +_CANONICAL_OUTCOMES = (ALLOW, DENY, ESCALATE, TRANSFORM, WARN, VOUCH) + +# The literal spellings a caller might still pass to Decision(outcome, ...) directly, mapped +# to the value that now backs them. Only needed for the raw-string constructor path -- +# Decision.ask()/.rewrite() below build the canonical outcome themselves. +_LEGACY_SPELLING = {"ask": ESCALATE, "rewrite": TRANSFORM} + + +class Decision: + """What a handler wants to happen. Adapters translate this to vendor dialect.""" + + __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + + #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). + DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) + + def __init__(self, outcome, reason=None, updated_input=None, evidence=None, context=None): + outcome = _LEGACY_SPELLING.get(outcome, outcome) + if outcome not in _CANONICAL_OUTCOMES: + raise ValueError("unknown outcome: %r" % (outcome,)) + self.outcome = outcome + self.reason = reason + self.updated_input = updated_input + self.evidence = evidence or {} + self.context = context + + @classmethod + def allow(cls, reason=None, evidence=None, context=None): + return cls(ALLOW, reason, evidence=evidence, context=context) + + @classmethod + def deny(cls, reason, evidence=None, context=None): + return cls(DENY, reason, evidence=evidence, context=context) + + @classmethod + def escalate(cls, reason, evidence=None, context=None): + """Defer the action to the host's own approval path (ACS `escalate`).""" + return cls(ESCALATE, reason, evidence=evidence, context=context) + + @classmethod + def ask(cls, reason, evidence=None, context=None): + """Deprecated alias of escalate() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.ask() is deprecated; use Decision.escalate()", DeprecationWarning, stacklevel=2) + return cls.escalate(reason, evidence=evidence, context=context) + + @classmethod + def transform(cls, updated_input, reason=None, evidence=None, context=None): + """Replace the tool input wholesale (ACS `transform`, at whole-value granularity).""" + return cls(TRANSFORM, reason, updated_input=updated_input, evidence=evidence, context=context) + + @classmethod + def rewrite(cls, updated_input, reason=None, evidence=None, context=None): + """Deprecated alias of transform() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.rewrite() is deprecated; use Decision.transform()", DeprecationWarning, stacklevel=2) + return cls.transform(updated_input, reason, evidence=evidence, context=context) + + @classmethod + def warn(cls, reason=None, evidence=None, context=None): + """Permit the action with no change, recording a warning (ACS `warn`).""" + return cls(WARN, reason, evidence=evidence, context=context) + + @classmethod + def vouch(cls, reason=None, evidence=None, context=None): + return cls(VOUCH, reason, evidence=evidence, context=context) + + def __repr__(self): # pragma: no cover - debugging aid + return "Decision(%s, %r)" % (self.outcome, self.reason) + + +def degraded_from(decision): + """What this decision was before the dispatcher reduced it, or None.""" + return (decision.evidence or {}).get("degraded_from") + + +def tool_input_of(raw): + """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" + if isinstance(raw, dict): + return raw + if isinstance(raw, str) and raw[:1] == "{": + try: + parsed = _json.loads(raw) + except Exception: + return {} + if isinstance(parsed, dict): + return parsed + return {} + + +# ------------------------------------------------------------------------------ +# flat_decision family engine (trimmed to what this entry uses) + +def _wire_name(cfg, raw): + for key in cfg["claims"].get("event_key", ()): + name = raw.get(key) + if name is not None: + return name + return None + +def hj_claims(cfg, raw): + """True when this payload matches the entry's marker discipline.""" + if not isinstance(raw, dict): + return False + c = cfg["claims"] + name = _wire_name(cfg, raw) + if name in c.get("accept_names", ()): + return True + if name not in cfg["events"]: + 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 + return False + return True + +def _segment(node, part): + """One path segment: a dict key, or `key[N]` indexing the list under it.""" + if part.endswith("]") and "[" in part: + key, _, index = part[:-1].partition("[") + items = node.get(key) if isinstance(node, dict) else None + i = int(index) + return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None + return node.get(part) if isinstance(node, dict) else None + +def _walk(node, path): + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + if "[]." in path: + head, sub = path.split("[].", 1) + items = _walk(node, head) + if not isinstance(items, (list, tuple)): + return None + joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) + return joined or None + for part in path.split("."): + node = _segment(node, part) + if node is None: + return None + return node + +def _lookup(raw, ti, key): + """One config key: a `tool_input.` path walks the decoded tool input, else the payload.""" + if key.startswith("tool_input."): + return _walk(ti, key[len("tool_input.") :]) + return _walk(raw, key) + +def _field(raw, ti, chain): + value = None + for key in chain: + if value: + break + value = value or _lookup(raw, ti, key) + return value + +_FIELD_META = ("tool_input", "content_only_for_write_tools", "stringify") + +def _tool_input_raw(cfg, raw): + for key in cfg["fields"].get("tool_input", ("tool_input",)): + value = raw.get(key) + if value is not None: + return value + return None + +def _canonical_of(cfg, name): + """The canonical event for one wire name; an entry with no `events` at all (antigravity, + whose payloads never carry one) maps the shape-inferred name back through `wire_events`.""" + events = cfg["events"] + if not events: + return {wire: canonical for canonical, wire in cfg.get("wire_events", {}).items()}.get(name, UNKNOWN) + return events.get(name, UNKNOWN) + +def hj_parse(cfg, raw, wire=None): + """Normalise one payload along the entry's ordered field-fallback chains. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + ti = tool_input_of(_tool_input_raw(cfg, raw)) + fields = {name: _field(raw, ti, chain) for name, chain in cfg["fields"].items() if name not in _FIELD_META} + if cfg["fields"].get("content_only_for_write_tools") and fields.get("tool") not in cfg["tools"].get("write", ()): + fields["content"] = None + if isinstance(fields.get("output"), (dict, list)): + fields["output"] = _json.dumps(fields["output"]) + for name in cfg["fields"].get("stringify", ()): + if fields.get(name) is not None: + fields[name] = str(fields[name]) + return Event( + cfg["agent"], + _canonical_of(cfg, wire if wire is not None else _wire_name(cfg, raw)), + tool=fields.get("tool"), + command=fields.get("command"), + path=fields.get("path"), + content=fields.get("content"), + output=fields.get("output"), + prompt=fields.get("prompt"), + session_id=fields.get("session_id"), + tool_use_id=fields.get("tool_use_id"), + cwd=fields.get("cwd"), + raw=raw, + ) + +def hj_reverse(cfg): + """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" + reverse = {} + for name, canonical in cfg["events"].items(): + if canonical != UNKNOWN: + reverse[canonical] = name + reverse.update(cfg.get("wire_events", {})) + return reverse + +def _context_value(v, decision): + if v.get("context_source") == "context": + return decision.context + if v.get("context_source") == "reason": + return decision.reason + return None + +def _context_body(name, value): + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "additionalContext": value}}), 0 + +def _note_for(v, decision, at_gate, missing_input): + notes = v.get("degrade_notes", {}) + if decision.outcome == ESCALATE: + if degraded_from(decision) == TRANSFORM and "escalate_from_transform" in notes: + return notes["escalate_from_transform"] + if at_gate and "escalate_gate" in notes: + return notes["escalate_gate"] + return notes.get("escalate") + if decision.outcome == TRANSFORM: + if missing_input and "transform_missing_input" in notes: + return notes["transform_missing_input"] + return notes.get("transform") + return None + +def _default_for(v, decision, at_gate, wire=None): + gate_defaults = v.get("gate_reason_defaults", {}) + if wire in gate_defaults: + return gate_defaults[wire] + defaults = v.get("reason_defaults", {}) + key = {DENY: "deny", ESCALATE: "escalate", TRANSFORM: "transform"}.get(decision.outcome, "deny") + if at_gate and key + "_gate" in defaults: + return defaults[key + "_gate"] + return defaults.get(key, "blocked by policy") + +def _refusal_text(v, decision, at_gate, wire=None): + note = _note_for(v, decision, at_gate, decision.updated_input is None) + default = _default_for(v, decision, at_gate, wire) + if note and "%s" in note: + # A template note fills (the reason or its default, the wire event name) itself. + return note % (decision.reason or default, wire) + reason = decision.reason + if v.get("note_style") == "suffix": + reason = reason or default + return "%s (%s)" % (reason, note) if note else reason + text = "%s (%s)" % (reason, note) if reason and note else (note or reason) + return text or default + +def _g1(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 + 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 + if ( + decision.outcome == ESCALATE + and gate["honours_escalate"] + and "escalate" in words + # An escalate the dispatcher degraded a transform into is a block where the entry + # names that degradation (antigravity): prompting would offer the unmodified call. + and not (degraded_from(decision) == TRANSFORM and "escalate_from_transform" in v.get("degrade_notes", {})) + ): + reason = decision.reason or _default_for(v, decision, True, wire) + return _json.dumps({"decision": words["escalate"], "reason": reason}), 0 + out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, False, wire)} + if at_context_event and v.get("context_source") == "context" and decision.context: + out["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": decision.context} + return _json.dumps(out), 0 + +def hj_respond(cfg, decision, event, wire=None): + """(stdout_text, exit_code) in this entry's dialect for the gate the payload names. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + v = cfg["verdicts"] + if wire is None: + wire = _wire_name(cfg, event.raw or {}) + if wire in v.get("empty_object_events", ()): + return _json.dumps({}), 0 + if wire is None: + wire = v.get("default_wire_event") + if wire is None and v.get("missing_wire") == "reverse_map": + wire = hj_reverse(cfg).get(event.event) + name = wire if v.get("echo") == "payload" else hj_reverse(cfg).get(event.event, "PreToolUse") + gate = v["gates"].get(wire) + if gate is None: + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + return "", 0 + if gate["grammar"] == "G2": + return _g2(v, gate, decision, name) + return _g1(v, gate, decision, wire, name) + +_WINDOWS_KEYS = ("commandWindows", "windows") + +def _hook_dict(cfg, command): + entry = {"type": "command", "command": command} + for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): + if key in _WINDOWS_KEYS: + entry[key] = powershell_command(command) + else: + entry[key] = value + return entry + +def 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": + 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) + if not name: + continue + entry = {"hooks": [_hook_dict(cfg, command)]} + if matcher and hook_entry["matcher"]: + entry["matcher"] = matcher + hooks.setdefault(name, []).append(entry) + if hook_entry.get("group"): + return {hook_entry["group"]: hooks} + return hooks if hook_entry.get("bare") else {"hooks": hooks} + + +# ------------------------------------------------------------------------------ +# tabnine vendor config + engine binding + +AGENT = "tabnine" + +VENDOR = {'agent': 'tabnine', 'claims': {'accept_markers': ('timestamp',), 'event_key': ('hook_event_name',), 'mode': 'marker', 'notes': 'timestamp identifies Tabnine but cannot exclude Gemini CLI, which sends it too (tabnine.py notes); detect() declines when both could claim, and the agent must be named explicitly.'}, 'config_format': 'json', 'config_path': '.tabnine/agent/settings.json', 'display': 'Tabnine CLI', 'events': {'AfterAgent': 'stop', 'AfterTool': 'post_tool', 'BeforeAgent': 'prompt_submit', 'BeforeTool': 'pre_tool', 'PreCompress': 'pre_compact', 'SessionEnd': 'session_end', 'SessionStart': 'session_start'}, 'evidence': {'claims': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'flat_decision', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content', 'tool_input.new_string'), 'cwd': ('cwd',), 'output': ('tool_output', 'tool_response'), 'path': ('tool_input.file_path', 'tool_input.path'), 'prompt': ('prompt',), 'session_id': ('session_id',), 'tool': ('tool_name',)}, 'hook_entry': {'entry_extra': {'name': 'agentseam'}, 'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'tools': {}, 'verdicts': {'answer_events': ('AfterAgent', 'AfterTool', 'BeforeAgent', 'BeforeTool'), 'bare_allow': 'unverified', 'degrade_notes': {'escalate': 'Tabnine cannot prompt for confirmation', 'escalate_from_transform': 'Tabnine cannot modify a tool call', 'transform': 'Tabnine cannot modify a tool call'}, 'gates': {'AfterAgent': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'AfterTool': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'BeforeAgent': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'BeforeTool': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'missing_wire': 'reverse_map', 'note_style': 'because', 'vocabulary': ('allow', 'deny'), 'vocabulary_basis': 'unverified', 'words': {'allow': 'allow', 'block': 'deny'}}} + + +def claims(raw): + return hj_claims(VENDOR, raw) + + +def parse(raw): + return hj_parse(VENDOR, raw) + + +def respond(decision, event): + return hj_respond(VENDOR, decision, event) + + +def hook_config(canonical_events, command, matcher=None): + return hook_entry_config(VENDOR, canonical_events, command, matcher) + + +# ------------------------------------------------------------------------------ +# runtime (agentseam dispatch, specialized to tabnine) + +_VOUCH_SPEAKS = False +_WARN_SPEAKS = False +_TRANSFORM_EVENTS = frozenset(()) + + +def degrade(decision, event): + """Reduce a decision to what 'tabnine' can actually honor, honestly. + + This is agentseam.dispatch.degrade(), specialized to one fixed agent so this file needs + no "import agentseam" -- no evidence establishes that an explicit approval word means anything beyond a plain allow here, so vouch degrades to one; see agentseam.allow_semantics. + """ + if decision.outcome == TRANSFORM and event.event not in _TRANSFORM_EVENTS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = TRANSFORM + return Decision.escalate( + decision.reason or "input requires modification before it can run", evidence=evidence + ) + if decision.outcome == VOUCH and not _VOUCH_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = VOUCH + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + if decision.outcome == WARN and not _WARN_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = WARN + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + return decision + + +# >>> agentseam handler >>> +GUARD_VIOLATION = 1 + +_BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') + +GATE_LOG_ENV = 'CHOCK_GATE_LOG' + +_LOG_MAX_BYTES = 1048576 + +_GUARD_TIMEOUT_SECONDS = 30 + +GUARD_BLOCKED = 'blocked' + +GUARD_CLEAN = 'clean' + +GUARD_UNCHECKED = 'unchecked' + +GUARD_ERRORED = 'errored' + +VERDICT_DENY = 'deny' + +VERDICT_ESCALATE = 'escalate' + +def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: + """The `--guard ` argument a vendored runtime was invoked with, or None.""" + if '--guard' in argv: + i = argv.index('--guard') + if i + 1 < len(argv): + return _chock_Path(argv[i + 1]) + return None + +def find_bash(guard: _chock_Path) -> str | None: + """First interpreter that can actually see `guard`, or None.""" + for candidate in _BASH_CANDIDATES: + try: + proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10) + except (OSError, _chock_subprocess.SubprocessError): + continue + if proc.returncode == 0: + return candidate + return None + +def run_guard(guard: _chock_Path, command: str) -> str: + """`GUARD_BLOCKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + try: + args = _chock_shlex.split(command) + except ValueError: + print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) + return GUARD_UNCHECKED + if not args: + return GUARD_UNCHECKED + bash = find_bash(guard) + if bash is None: + print(f'chock: no usable bash found, {guard.name} not checked', file=sys.stderr) + return GUARD_UNCHECKED + try: + env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} + proc = _chock_subprocess.run([bash, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS) + except _chock_subprocess.TimeoutExpired: + print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) + return GUARD_ERRORED + except (OSError, UnicodeError) as exc: + print(f'chock: guard could not run, not checked: {exc}', file=sys.stderr) + return GUARD_ERRORED + if proc.returncode == GUARD_VIOLATION: + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + if not ((proc.stdout or '') + (proc.stderr or '')).strip(): + print(f'chock: blocked by {_chock_Path(guard).name} (guard gave no reason)', file=sys.stderr) + return GUARD_BLOCKED + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout or '').strip().splitlines() + print(f'chock: guard exited {proc.returncode}, not checked' + (f': {detail[0][:120]}' if detail else ''), file=sys.stderr) + return GUARD_ERRORED + return GUARD_CLEAN + +def log_outcome(guard: _chock_Path, tool: str, blocked: bool) -> None: + """Append one outcome record. Best effort: never raises, never changes the verdict.""" + try: + if _chock_os.environ.get(GATE_LOG_ENV) == '0': + return + guard = guard.resolve() + if guard.parent.name != 'implementations': + return + artifact_root = None + for parent in guard.parents: + if (parent / '.chock').is_dir(): + artifact_root = parent / '.chock' + break + if artifact_root is None: + return + log_dir = artifact_root / 'log' + log_dir.mkdir(parents=True, exist_ok=True) + log_path = log_dir / 'gate-events.jsonl' + if log_path.exists() and log_path.stat().st_size > _LOG_MAX_BYTES: + log_path.replace(log_dir / 'gate-events.1.jsonl') + import json + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': 'block' if blocked else 'allow'} + with log_path.open('a', encoding='utf-8') as fh: + fh.write(json.dumps(record, ensure_ascii=False) + '\n') + except Exception: + return + +def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: + """Run the guard named on `argv` (`--guard `) against `command`.""" + guard = guard_path_from_argv(argv) + if guard is None or not guard.exists(): + return None + verdict = run_guard(guard, command) + if verdict in (GUARD_BLOCKED, GUARD_CLEAN): + log_outcome(guard, tool, verdict == GUARD_BLOCKED) + if verdict == GUARD_BLOCKED: + return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + if verdict == GUARD_ERRORED: + return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") + return None + + +def handle(event): + if event.event == "pre_tool" and event.command: + verdict = evaluate(sys.argv[1:], event.command, event.tool or "") + if verdict is not None: + outcome, reason = verdict + return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + return None +# <<< agentseam handler <<< + + +def _coerce(result): + if result is None: + return Decision.allow() + if isinstance(result, Decision): + return result + raise TypeError("handle() must return a Decision or None, got %r" % (type(result),)) + + +def _read_payload(stream): + # Decode bytes ourselves rather than trust the platform locale -- a BOM'd or non-UTF-8 + # stdin must not silently disable the gate. See agentseam.dispatch for the incident + # this guards: a Windows console's cp1252 layer turning a UTF-8 BOM into three bytes + # json cannot parse, with the hook allowing everything while claiming enforcement. + buffer = getattr(stream, "buffer", None) + if buffer is not None: + return buffer.read().decode("utf-8-sig", errors="replace") + return stream.read().lstrip("\ufeff") + + +def _emit(out, text): + buffer = getattr(out, "buffer", None) + if buffer is not None: + buffer.write(text.encode("utf-8")) + buffer.flush() + else: + out.write(text) + out.flush() + + +def main(stdin=None, stdout=None, exit=True): + """Read one payload from stdin, dispatch, emit the 'tabnine' response, exit.""" + stream = stdin if stdin is not None else sys.stdin + out = stdout if stdout is not None else sys.stdout + try: + raw = json.loads(_read_payload(stream)) + except Exception: + # Malformed input is not the agent's fault to pay for: allow, stay silent. + if exit: + sys.exit(0) + return 0 + event = parse(raw) + if event.event == UNKNOWN: + # A vendor event this adapter has no mapping for. handle() is not called: it + # reasons about the canonical vocabulary, and handing it something outside that + # vocabulary invites a decision made on a false premise. + if exit: + sys.exit(0) + return 0 + decision = degrade(_coerce(handle(event)), event) + text, code = respond(decision, event) + if text: + _emit(out, text) + if exit: + sys.exit(code) + return code + + +if __name__ == "__main__": # pragma: no cover + main() + diff --git a/.chock/bin/windsurf.py b/.chock/bin/windsurf.py new file mode 100755 index 0000000..301ebb4 --- /dev/null +++ b/.chock/bin/windsurf.py @@ -0,0 +1,701 @@ +# Generated by agentseam 0.2.0 -- 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. +# +# Self-contained: stdlib only, no "import agentseam" anywhere in this file. Regenerate +# with `agentseam.bundler.bundle("windsurf")` (same agentseam version -> identical bytes, +# except your own edits inside the handler block) rather than patching this by hand. +# https://github.com/open-coder-ai/agentseam + +from __future__ import annotations + +import json +_json = json +import sys + +import os as _chock_os +import shlex as _chock_shlex +import subprocess as _chock_subprocess +from datetime import datetime as _chock_datetime, timezone as _chock_timezone +from pathlib import Path as _chock_Path +import warnings as _warnings + +# ------------------------------------------------------------------------------ +# contract (agentseam 0.2.0) + +"""Canonical event vocabulary, normalized envelope, and decision type.""" + + + +SESSION_START = "session_start" +SESSION_END = "session_end" +PROMPT_SUBMIT = "prompt_submit" +PRE_TOOL = "pre_tool" +POST_TOOL = "post_tool" +TOOL_FAILURE = "tool_failure" +PRE_COMPACT = "pre_compact" +STOP = "stop" +SUBAGENT_START = "subagent_start" +SUBAGENT_STOP = "subagent_stop" +INSTRUCTIONS_LOADED = "instructions_loaded" +FILE_CHANGED = "file_changed" + +UNKNOWN = "unknown" + +EVENTS = ( + SESSION_START, + SESSION_END, + PROMPT_SUBMIT, + PRE_TOOL, + POST_TOOL, + TOOL_FAILURE, + PRE_COMPACT, + STOP, + SUBAGENT_START, + SUBAGENT_STOP, + INSTRUCTIONS_LOADED, + FILE_CHANGED, +) + + +class Event: + """One agent lifecycle event, normalized.""" + + __slots__ = ( + "agent", + "event", + "tool", + "command", + "path", + "content", + "output", + "prompt", + "session_id", + "tool_use_id", + "cwd", + "raw", + ) + + def __init__( + self, + agent, + event, + *, + tool=None, + command=None, + path=None, + content=None, + output=None, + prompt=None, + session_id=None, + tool_use_id=None, + cwd=None, + raw=None, + ): + self.agent = agent + self.event = event + self.tool = tool + self.command = command + self.path = path + self.content = content + self.output = output + self.prompt = prompt + self.session_id = session_id + self.tool_use_id = tool_use_id + self.cwd = cwd + self.raw = raw if raw is not None else {} + + def __repr__(self): # pragma: no cover - debugging aid + return "Event(%s/%s tool=%r path=%r)" % (self.agent, self.event, self.tool, self.path) + + +ALLOW = "allow" +DENY = "deny" +ESCALATE = "escalate" +TRANSFORM = "transform" +WARN = "warn" +VOUCH = "vouch" + +# Pre-ACS-alignment names. Same strings as their ACS-named counterparts, so every existing +# `is`/`==` comparison against the old constant keeps working untouched. +ASK = ESCALATE +REWRITE = TRANSFORM + +_CANONICAL_OUTCOMES = (ALLOW, DENY, ESCALATE, TRANSFORM, WARN, VOUCH) + +# The literal spellings a caller might still pass to Decision(outcome, ...) directly, mapped +# to the value that now backs them. Only needed for the raw-string constructor path -- +# Decision.ask()/.rewrite() below build the canonical outcome themselves. +_LEGACY_SPELLING = {"ask": ESCALATE, "rewrite": TRANSFORM} + + +class Decision: + """What a handler wants to happen. Adapters translate this to vendor dialect.""" + + __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + + #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). + DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) + + def __init__(self, outcome, reason=None, updated_input=None, evidence=None, context=None): + outcome = _LEGACY_SPELLING.get(outcome, outcome) + if outcome not in _CANONICAL_OUTCOMES: + raise ValueError("unknown outcome: %r" % (outcome,)) + self.outcome = outcome + self.reason = reason + self.updated_input = updated_input + self.evidence = evidence or {} + self.context = context + + @classmethod + def allow(cls, reason=None, evidence=None, context=None): + return cls(ALLOW, reason, evidence=evidence, context=context) + + @classmethod + def deny(cls, reason, evidence=None, context=None): + return cls(DENY, reason, evidence=evidence, context=context) + + @classmethod + def escalate(cls, reason, evidence=None, context=None): + """Defer the action to the host's own approval path (ACS `escalate`).""" + return cls(ESCALATE, reason, evidence=evidence, context=context) + + @classmethod + def ask(cls, reason, evidence=None, context=None): + """Deprecated alias of escalate() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.ask() is deprecated; use Decision.escalate()", DeprecationWarning, stacklevel=2) + return cls.escalate(reason, evidence=evidence, context=context) + + @classmethod + def transform(cls, updated_input, reason=None, evidence=None, context=None): + """Replace the tool input wholesale (ACS `transform`, at whole-value granularity).""" + return cls(TRANSFORM, reason, updated_input=updated_input, evidence=evidence, context=context) + + @classmethod + def rewrite(cls, updated_input, reason=None, evidence=None, context=None): + """Deprecated alias of transform() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.rewrite() is deprecated; use Decision.transform()", DeprecationWarning, stacklevel=2) + return cls.transform(updated_input, reason, evidence=evidence, context=context) + + @classmethod + def warn(cls, reason=None, evidence=None, context=None): + """Permit the action with no change, recording a warning (ACS `warn`).""" + return cls(WARN, reason, evidence=evidence, context=context) + + @classmethod + def vouch(cls, reason=None, evidence=None, context=None): + return cls(VOUCH, reason, evidence=evidence, context=context) + + def __repr__(self): # pragma: no cover - debugging aid + return "Decision(%s, %r)" % (self.outcome, self.reason) + + +def degraded_from(decision): + """What this decision was before the dispatcher reduced it, or None.""" + return (decision.evidence or {}).get("degraded_from") + + +def tool_input_of(raw): + """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" + if isinstance(raw, dict): + return raw + if isinstance(raw, str) and raw[:1] == "{": + try: + parsed = _json.loads(raw) + except Exception: + return {} + if isinstance(parsed, dict): + return parsed + return {} + + +# ------------------------------------------------------------------------------ +# windsurf family engine (trimmed to what this entry uses) + +def _wire_name(cfg, raw): + for key in cfg["claims"].get("event_key", ()): + name = raw.get(key) + if name is not None: + return name + return None + +def _segment(node, part): + """One path segment: a dict key, or `key[N]` indexing the list under it.""" + if part.endswith("]") and "[" in part: + key, _, index = part[:-1].partition("[") + items = node.get(key) if isinstance(node, dict) else None + i = int(index) + return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None + return node.get(part) if isinstance(node, dict) else None + +def _walk(node, path): + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + if "[]." in path: + head, sub = path.split("[].", 1) + items = _walk(node, head) + if not isinstance(items, (list, tuple)): + return None + joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) + return joined or None + for part in path.split("."): + node = _segment(node, part) + if node is None: + return None + return node + +def _lookup(raw, ti, key): + """One config key: a `tool_input.` path walks the decoded tool input, else the payload.""" + if key.startswith("tool_input."): + return _walk(ti, key[len("tool_input.") :]) + return _walk(raw, key) + +def _field(raw, ti, chain): + value = None + for key in chain: + if value: + break + value = value or _lookup(raw, ti, key) + return value + +_FIELD_META = ("tool_input", "content_only_for_write_tools", "stringify") + +def _tool_input_raw(cfg, raw): + for key in cfg["fields"].get("tool_input", ("tool_input",)): + value = raw.get(key) + if value is not None: + return value + return None + +def _canonical_of(cfg, name): + """The canonical event for one wire name; an entry with no `events` at all (antigravity, + whose payloads never carry one) maps the shape-inferred name back through `wire_events`.""" + events = cfg["events"] + if not events: + return {wire: canonical for canonical, wire in cfg.get("wire_events", {}).items()}.get(name, UNKNOWN) + return events.get(name, UNKNOWN) + +def hj_parse(cfg, raw, wire=None): + """Normalise one payload along the entry's ordered field-fallback chains. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + ti = tool_input_of(_tool_input_raw(cfg, raw)) + fields = {name: _field(raw, ti, chain) for name, chain in cfg["fields"].items() if name not in _FIELD_META} + if cfg["fields"].get("content_only_for_write_tools") and fields.get("tool") not in cfg["tools"].get("write", ()): + fields["content"] = None + if isinstance(fields.get("output"), (dict, list)): + fields["output"] = _json.dumps(fields["output"]) + for name in cfg["fields"].get("stringify", ()): + if fields.get(name) is not None: + fields[name] = str(fields[name]) + return Event( + cfg["agent"], + _canonical_of(cfg, wire if wire is not None else _wire_name(cfg, raw)), + tool=fields.get("tool"), + command=fields.get("command"), + path=fields.get("path"), + content=fields.get("content"), + output=fields.get("output"), + prompt=fields.get("prompt"), + session_id=fields.get("session_id"), + tool_use_id=fields.get("tool_use_id"), + cwd=fields.get("cwd"), + raw=raw, + ) + +def hj_reverse(cfg): + """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" + reverse = {} + for name, canonical in cfg["events"].items(): + if canonical != UNKNOWN: + reverse[canonical] = name + reverse.update(cfg.get("wire_events", {})) + return reverse + +def _note_for(v, decision, at_gate, missing_input): + notes = v.get("degrade_notes", {}) + if decision.outcome == ESCALATE: + if degraded_from(decision) == TRANSFORM and "escalate_from_transform" in notes: + return notes["escalate_from_transform"] + if at_gate and "escalate_gate" in notes: + return notes["escalate_gate"] + return notes.get("escalate") + if decision.outcome == TRANSFORM: + if missing_input and "transform_missing_input" in notes: + return notes["transform_missing_input"] + return notes.get("transform") + return None + +def _default_for(v, decision, at_gate, wire=None): + gate_defaults = v.get("gate_reason_defaults", {}) + if wire in gate_defaults: + return gate_defaults[wire] + defaults = v.get("reason_defaults", {}) + key = {DENY: "deny", ESCALATE: "escalate", TRANSFORM: "transform"}.get(decision.outcome, "deny") + if at_gate and key + "_gate" in defaults: + return defaults[key + "_gate"] + return defaults.get(key, "blocked by policy") + +def _refusal_text(v, decision, at_gate, wire=None): + note = _note_for(v, decision, at_gate, decision.updated_input is None) + default = _default_for(v, decision, at_gate, wire) + if note and "%s" in note: + # A template note fills (the reason or its default, the wire event name) itself. + return note % (decision.reason or default, wire) + reason = decision.reason + if v.get("note_style") == "suffix": + reason = reason or default + return "%s (%s)" % (reason, note) if note else reason + text = "%s (%s)" % (reason, note) if reason and note else (note or reason) + return text or default + +_WINDOWS_KEYS = ("commandWindows", "windows") + +def _hook_dict(cfg, command): + entry = {"type": "command", "command": command} + for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): + if key in _WINDOWS_KEYS: + entry[key] = powershell_command(command) + else: + entry[key] = value + return entry + +def 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": + 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) + if not name: + continue + entry = {"hooks": [_hook_dict(cfg, command)]} + if matcher and hook_entry["matcher"]: + entry["matcher"] = matcher + hooks.setdefault(name, []).append(entry) + if hook_entry.get("group"): + return {hook_entry["group"]: hooks} + return hooks if hook_entry.get("bare") else {"hooks": hooks} + +_MCP_EVENTS = ("pre_mcp_tool_use", "post_mcp_tool_use") + +def windsurf_wire(raw): + """The wire event name, inferred from `tool_info` when the payload names none.""" + name = raw.get("hook_event_name") + if name is not None: + return name + info = raw.get("tool_info") or {} + return "pre_run_command" if info.get("command_line") else "pre_user_prompt" + +def windsurf_claims(cfg, raw): + if not isinstance(raw, dict): + return False + if raw.get("hook_event_name") in cfg["events"]: + return True + return "trajectory_id" in raw and isinstance(raw.get("tool_info"), dict) + +def windsurf_parse(cfg, raw): + name = windsurf_wire(raw) + event = hj_parse(cfg, raw, wire=name) + info = raw.get("tool_info") or {} + if name in _MCP_EVENTS: + joined = "%s/%s" % (info["server"], info["tool"]) if info.get("server") and info.get("tool") else None + event.tool = joined or info.get("tool") + else: + event.tool = name + return event + +def windsurf_respond(cfg, decision, event): + """Exit code only: 2 blocks at a gate; elsewhere a refusal can only be flagged.""" + v = cfg["verdicts"] + if decision.outcome not in (DENY, ESCALATE, TRANSFORM): + return "", 0 + 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 + + +# ------------------------------------------------------------------------------ +# windsurf vendor config + engine binding + +AGENT = "windsurf" + +VENDOR = {'agent': 'windsurf', 'claims': {'mode': 'shape_inferred'}, 'config_format': 'json', 'config_path': '.windsurf/hooks.json', 'display': 'Windsurf (Cascade)', 'events': {'post_cascade_response': 'stop', 'post_mcp_tool_use': 'post_tool', 'pre_mcp_tool_use': 'pre_tool', 'pre_run_command': 'pre_tool', 'pre_user_prompt': 'prompt_submit'}, 'evidence': {'claims': {'basis': 'third-party-install', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'third-party-install', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'third-party-install', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'third-party-install', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'third-party-install', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'third-party-install', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'third-party-install', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'third-party-install', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'windsurf', 'fields': {'command': ('tool_info.command_line',), 'cwd': ('cwd',), 'output': ('output', 'result'), 'path': ('path', 'tool_info.path'), 'prompt': ('query', 'prompt'), 'session_id': ('trajectory_id',)}, 'hook_entry': {'also_wires': {'pre_tool': 'pre_mcp_tool_use'}, 'matcher': False, 'wrapper': 'flat_entries'}, 'needs_trust': False, 'tools': {}, 'verdicts': {'answer_events': ('pre_mcp_tool_use', 'pre_run_command', 'pre_user_prompt'), 'bare_allow': 'silent', 'degrade_notes': {'escalate': 'this agent cannot prompt for confirmation; blocking instead', 'escalate_from_transform': 'this agent cannot rewrite tool input; blocking instead', 'transform': 'this agent cannot rewrite tool input; blocking instead'}, 'flag_note': 'windsurf: flagged after the fact (%s cannot block): %s', 'flag_note_default': 'policy violation', 'gates': {'pre_mcp_tool_use': {'grammar': 'G5', 'honours_escalate': False, 'honours_transform': False}, 'pre_run_command': {'grammar': 'G5', 'honours_escalate': False, 'honours_transform': False}, 'pre_user_prompt': {'grammar': 'G5', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'suffix', 'reason_defaults': {'escalate': 'confirmation required', 'transform': 'input requires modification'}, 'vocabulary': (), 'vocabulary_basis': 'verified'}, 'wire_events': {'pre_tool': 'pre_run_command'}} + + +def claims(raw): + return windsurf_claims(VENDOR, raw) + + +def parse(raw): + return windsurf_parse(VENDOR, raw) + + +def respond(decision, event): + return windsurf_respond(VENDOR, decision, event) + + +def hook_config(canonical_events, command, matcher=None): + return hook_entry_config(VENDOR, canonical_events, command, matcher) + + +# ------------------------------------------------------------------------------ +# runtime (agentseam dispatch, specialized to windsurf) + +_VOUCH_SPEAKS = False +_WARN_SPEAKS = False +_TRANSFORM_EVENTS = frozenset(()) + + +def degrade(decision, event): + """Reduce a decision to what 'windsurf' can actually honor, honestly. + + This is agentseam.dispatch.degrade(), specialized to one fixed agent so this file needs + no "import agentseam" -- no evidence establishes that an explicit approval word means anything beyond a plain allow here, so vouch degrades to one; see agentseam.allow_semantics. + """ + if decision.outcome == TRANSFORM and event.event not in _TRANSFORM_EVENTS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = TRANSFORM + return Decision.escalate( + decision.reason or "input requires modification before it can run", evidence=evidence + ) + if decision.outcome == VOUCH and not _VOUCH_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = VOUCH + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + if decision.outcome == WARN and not _WARN_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = WARN + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + return decision + + +# >>> agentseam handler >>> +GUARD_VIOLATION = 1 + +_BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') + +GATE_LOG_ENV = 'CHOCK_GATE_LOG' + +_LOG_MAX_BYTES = 1048576 + +_GUARD_TIMEOUT_SECONDS = 30 + +GUARD_BLOCKED = 'blocked' + +GUARD_CLEAN = 'clean' + +GUARD_UNCHECKED = 'unchecked' + +GUARD_ERRORED = 'errored' + +VERDICT_DENY = 'deny' + +VERDICT_ESCALATE = 'escalate' + +def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: + """The `--guard ` argument a vendored runtime was invoked with, or None.""" + if '--guard' in argv: + i = argv.index('--guard') + if i + 1 < len(argv): + return _chock_Path(argv[i + 1]) + return None + +def find_bash(guard: _chock_Path) -> str | None: + """First interpreter that can actually see `guard`, or None.""" + for candidate in _BASH_CANDIDATES: + try: + proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10) + except (OSError, _chock_subprocess.SubprocessError): + continue + if proc.returncode == 0: + return candidate + return None + +def run_guard(guard: _chock_Path, command: str) -> str: + """`GUARD_BLOCKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + try: + args = _chock_shlex.split(command) + except ValueError: + print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) + return GUARD_UNCHECKED + if not args: + return GUARD_UNCHECKED + bash = find_bash(guard) + if bash is None: + print(f'chock: no usable bash found, {guard.name} not checked', file=sys.stderr) + return GUARD_UNCHECKED + try: + env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} + proc = _chock_subprocess.run([bash, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS) + except _chock_subprocess.TimeoutExpired: + print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) + return GUARD_ERRORED + except (OSError, UnicodeError) as exc: + print(f'chock: guard could not run, not checked: {exc}', file=sys.stderr) + return GUARD_ERRORED + if proc.returncode == GUARD_VIOLATION: + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + if not ((proc.stdout or '') + (proc.stderr or '')).strip(): + print(f'chock: blocked by {_chock_Path(guard).name} (guard gave no reason)', file=sys.stderr) + return GUARD_BLOCKED + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout or '').strip().splitlines() + print(f'chock: guard exited {proc.returncode}, not checked' + (f': {detail[0][:120]}' if detail else ''), file=sys.stderr) + return GUARD_ERRORED + return GUARD_CLEAN + +def log_outcome(guard: _chock_Path, tool: str, blocked: bool) -> None: + """Append one outcome record. Best effort: never raises, never changes the verdict.""" + try: + if _chock_os.environ.get(GATE_LOG_ENV) == '0': + return + guard = guard.resolve() + if guard.parent.name != 'implementations': + return + artifact_root = None + for parent in guard.parents: + if (parent / '.chock').is_dir(): + artifact_root = parent / '.chock' + break + if artifact_root is None: + return + log_dir = artifact_root / 'log' + log_dir.mkdir(parents=True, exist_ok=True) + log_path = log_dir / 'gate-events.jsonl' + if log_path.exists() and log_path.stat().st_size > _LOG_MAX_BYTES: + log_path.replace(log_dir / 'gate-events.1.jsonl') + import json + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': 'block' if blocked else 'allow'} + with log_path.open('a', encoding='utf-8') as fh: + fh.write(json.dumps(record, ensure_ascii=False) + '\n') + except Exception: + return + +def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: + """Run the guard named on `argv` (`--guard `) against `command`.""" + guard = guard_path_from_argv(argv) + if guard is None or not guard.exists(): + return None + verdict = run_guard(guard, command) + if verdict in (GUARD_BLOCKED, GUARD_CLEAN): + log_outcome(guard, tool, verdict == GUARD_BLOCKED) + if verdict == GUARD_BLOCKED: + return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + if verdict == GUARD_ERRORED: + return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") + return None + + +def handle(event): + if event.event == "pre_tool" and event.command: + verdict = evaluate(sys.argv[1:], event.command, event.tool or "") + if verdict is not None: + outcome, reason = verdict + return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + return None +# <<< agentseam handler <<< + + +def _coerce(result): + if result is None: + return Decision.allow() + if isinstance(result, Decision): + return result + raise TypeError("handle() must return a Decision or None, got %r" % (type(result),)) + + +def _read_payload(stream): + # Decode bytes ourselves rather than trust the platform locale -- a BOM'd or non-UTF-8 + # stdin must not silently disable the gate. See agentseam.dispatch for the incident + # this guards: a Windows console's cp1252 layer turning a UTF-8 BOM into three bytes + # json cannot parse, with the hook allowing everything while claiming enforcement. + buffer = getattr(stream, "buffer", None) + if buffer is not None: + return buffer.read().decode("utf-8-sig", errors="replace") + return stream.read().lstrip("\ufeff") + + +def _emit(out, text): + buffer = getattr(out, "buffer", None) + if buffer is not None: + buffer.write(text.encode("utf-8")) + buffer.flush() + else: + out.write(text) + out.flush() + + +def main(stdin=None, stdout=None, exit=True): + """Read one payload from stdin, dispatch, emit the 'windsurf' response, exit.""" + stream = stdin if stdin is not None else sys.stdin + out = stdout if stdout is not None else sys.stdout + try: + raw = json.loads(_read_payload(stream)) + except Exception: + # Malformed input is not the agent's fault to pay for: allow, stay silent. + if exit: + sys.exit(0) + return 0 + event = parse(raw) + if event.event == UNKNOWN: + # A vendor event this adapter has no mapping for. handle() is not called: it + # reasons about the canonical vocabulary, and handing it something outside that + # vocabulary invites a decision made on a false premise. + if exit: + sys.exit(0) + return 0 + decision = degrade(_coerce(handle(event)), event) + text, code = respond(decision, event) + if text: + _emit(out, text) + if exit: + sys.exit(code) + return code + + +if __name__ == "__main__": # pragma: no cover + main() + diff --git a/.chock/compiled/block-destructive-commands/pre-tool-use/antigravity-hooks.json b/.chock/compiled/block-destructive-commands/pre-tool-use/antigravity-hooks.json new file mode 100644 index 0000000..df7646d --- /dev/null +++ b/.chock/compiled/block-destructive-commands/pre-tool-use/antigravity-hooks.json @@ -0,0 +1,14 @@ +{ + "agentseam": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/antigravity.py\" --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-destructive-commands/pre-tool-use/codex_cli-hooks.json b/.chock/compiled/block-destructive-commands/pre-tool-use/codex_cli-hooks.json new file mode 100644 index 0000000..74e9cfb --- /dev/null +++ b/.chock/compiled/block-destructive-commands/pre-tool-use/codex_cli-hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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\"" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-destructive-commands/pre-tool-use/devin-hooks.json b/.chock/compiled/block-destructive-commands/pre-tool-use/devin-hooks.json new file mode 100644 index 0000000..b4635d3 --- /dev/null +++ b/.chock/compiled/block-destructive-commands/pre-tool-use/devin-hooks.json @@ -0,0 +1,12 @@ +{ + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/devin.py\" --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + } + ] + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/block-destructive-commands/pre-tool-use/gemini_cli-hooks.json b/.chock/compiled/block-destructive-commands/pre-tool-use/gemini_cli-hooks.json new file mode 100644 index 0000000..1e0bb4f --- /dev/null +++ b/.chock/compiled/block-destructive-commands/pre-tool-use/gemini_cli-hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "BeforeTool": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/gemini_cli.py\" --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + } + ], + "matcher": "run_shell_command" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-destructive-commands/pre-tool-use/grok-hooks.json b/.chock/compiled/block-destructive-commands/pre-tool-use/grok-hooks.json new file mode 100644 index 0000000..74adcdd --- /dev/null +++ b/.chock/compiled/block-destructive-commands/pre-tool-use/grok-hooks.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/grok.py\" --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-destructive-commands/pre-tool-use/tabnine-hooks.json b/.chock/compiled/block-destructive-commands/pre-tool-use/tabnine-hooks.json new file mode 100644 index 0000000..fcf4b83 --- /dev/null +++ b/.chock/compiled/block-destructive-commands/pre-tool-use/tabnine-hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "BeforeTool": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/tabnine.py\" --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", + "name": "agentseam" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-destructive-commands/pre-tool-use/windsurf-hooks.json b/.chock/compiled/block-destructive-commands/pre-tool-use/windsurf-hooks.json new file mode 100644 index 0000000..5501b31 --- /dev/null +++ b/.chock/compiled/block-destructive-commands/pre-tool-use/windsurf-hooks.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "pre_run_command": [ + { + "command": "@CHOCK_PYTHON@ \".chock/bin/windsurf.py\" --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + } + ], + "pre_mcp_tool_use": [ + { + "command": "@CHOCK_PYTHON@ \".chock/bin/windsurf.py\" --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-no-verify/pre-tool-use/antigravity-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/antigravity-hooks.json new file mode 100644 index 0000000..5de15fe --- /dev/null +++ b/.chock/compiled/block-no-verify/pre-tool-use/antigravity-hooks.json @@ -0,0 +1,14 @@ +{ + "agentseam": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/antigravity.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-no-verify/pre-tool-use/codex_cli-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/codex_cli-hooks.json new file mode 100644 index 0000000..0935a85 --- /dev/null +++ b/.chock/compiled/block-no-verify/pre-tool-use/codex_cli-hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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\"" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-no-verify/pre-tool-use/devin-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/devin-hooks.json new file mode 100644 index 0000000..773dc47 --- /dev/null +++ b/.chock/compiled/block-no-verify/pre-tool-use/devin-hooks.json @@ -0,0 +1,12 @@ +{ + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/devin.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + } + ] + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/block-no-verify/pre-tool-use/gemini_cli-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/gemini_cli-hooks.json new file mode 100644 index 0000000..f5e839d --- /dev/null +++ b/.chock/compiled/block-no-verify/pre-tool-use/gemini_cli-hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "BeforeTool": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/gemini_cli.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + } + ], + "matcher": "run_shell_command" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-no-verify/pre-tool-use/grok-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/grok-hooks.json new file mode 100644 index 0000000..2126899 --- /dev/null +++ b/.chock/compiled/block-no-verify/pre-tool-use/grok-hooks.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/grok.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-no-verify/pre-tool-use/tabnine-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/tabnine-hooks.json new file mode 100644 index 0000000..6a9fd70 --- /dev/null +++ b/.chock/compiled/block-no-verify/pre-tool-use/tabnine-hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "BeforeTool": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/tabnine.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "name": "agentseam" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-no-verify/pre-tool-use/windsurf-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/windsurf-hooks.json new file mode 100644 index 0000000..5e47270 --- /dev/null +++ b/.chock/compiled/block-no-verify/pre-tool-use/windsurf-hooks.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "pre_run_command": [ + { + "command": "@CHOCK_PYTHON@ \".chock/bin/windsurf.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + } + ], + "pre_mcp_tool_use": [ + { + "command": "@CHOCK_PYTHON@ \".chock/bin/windsurf.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/antigravity-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/antigravity-hooks.json new file mode 100644 index 0000000..fd03142 --- /dev/null +++ b/.chock/compiled/protect-agent-config/pre-tool-use/antigravity-hooks.json @@ -0,0 +1,14 @@ +{ + "agentseam": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/antigravity.py\" --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/codex_cli-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/codex_cli-hooks.json new file mode 100644 index 0000000..e719fa7 --- /dev/null +++ b/.chock/compiled/protect-agent-config/pre-tool-use/codex_cli-hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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\"" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/devin-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/devin-hooks.json new file mode 100644 index 0000000..5e49f06 --- /dev/null +++ b/.chock/compiled/protect-agent-config/pre-tool-use/devin-hooks.json @@ -0,0 +1,12 @@ +{ + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/devin.py\" --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + } + ] + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/gemini_cli-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/gemini_cli-hooks.json new file mode 100644 index 0000000..26410ef --- /dev/null +++ b/.chock/compiled/protect-agent-config/pre-tool-use/gemini_cli-hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "BeforeTool": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/gemini_cli.py\" --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + } + ], + "matcher": "run_shell_command" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/grok-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/grok-hooks.json new file mode 100644 index 0000000..a91eb2f --- /dev/null +++ b/.chock/compiled/protect-agent-config/pre-tool-use/grok-hooks.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/grok.py\" --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/tabnine-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/tabnine-hooks.json new file mode 100644 index 0000000..3449fbc --- /dev/null +++ b/.chock/compiled/protect-agent-config/pre-tool-use/tabnine-hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "BeforeTool": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/tabnine.py\" --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", + "name": "agentseam" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/windsurf-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/windsurf-hooks.json new file mode 100644 index 0000000..2d6e6c4 --- /dev/null +++ b/.chock/compiled/protect-agent-config/pre-tool-use/windsurf-hooks.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "pre_run_command": [ + { + "command": "@CHOCK_PYTHON@ \".chock/bin/windsurf.py\" --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + } + ], + "pre_mcp_tool_use": [ + { + "command": "@CHOCK_PYTHON@ \".chock/bin/windsurf.py\" --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/protect-commit-privacy/pre-tool-use/antigravity-hooks.json b/.chock/compiled/protect-commit-privacy/pre-tool-use/antigravity-hooks.json new file mode 100644 index 0000000..3c9c9df --- /dev/null +++ b/.chock/compiled/protect-commit-privacy/pre-tool-use/antigravity-hooks.json @@ -0,0 +1,14 @@ +{ + "agentseam": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/antigravity.py\" --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/protect-commit-privacy/pre-tool-use/codex_cli-hooks.json b/.chock/compiled/protect-commit-privacy/pre-tool-use/codex_cli-hooks.json new file mode 100644 index 0000000..e907d1a --- /dev/null +++ b/.chock/compiled/protect-commit-privacy/pre-tool-use/codex_cli-hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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\"" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/protect-commit-privacy/pre-tool-use/devin-hooks.json b/.chock/compiled/protect-commit-privacy/pre-tool-use/devin-hooks.json new file mode 100644 index 0000000..e240927 --- /dev/null +++ b/.chock/compiled/protect-commit-privacy/pre-tool-use/devin-hooks.json @@ -0,0 +1,12 @@ +{ + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/devin.py\" --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + } + ] + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/protect-commit-privacy/pre-tool-use/gemini_cli-hooks.json b/.chock/compiled/protect-commit-privacy/pre-tool-use/gemini_cli-hooks.json new file mode 100644 index 0000000..c424885 --- /dev/null +++ b/.chock/compiled/protect-commit-privacy/pre-tool-use/gemini_cli-hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "BeforeTool": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/gemini_cli.py\" --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + } + ], + "matcher": "run_shell_command" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/protect-commit-privacy/pre-tool-use/grok-hooks.json b/.chock/compiled/protect-commit-privacy/pre-tool-use/grok-hooks.json new file mode 100644 index 0000000..c774838 --- /dev/null +++ b/.chock/compiled/protect-commit-privacy/pre-tool-use/grok-hooks.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/grok.py\" --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/protect-commit-privacy/pre-tool-use/tabnine-hooks.json b/.chock/compiled/protect-commit-privacy/pre-tool-use/tabnine-hooks.json new file mode 100644 index 0000000..ca4f949 --- /dev/null +++ b/.chock/compiled/protect-commit-privacy/pre-tool-use/tabnine-hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "BeforeTool": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/tabnine.py\" --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", + "name": "agentseam" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/protect-commit-privacy/pre-tool-use/windsurf-hooks.json b/.chock/compiled/protect-commit-privacy/pre-tool-use/windsurf-hooks.json new file mode 100644 index 0000000..e66c257 --- /dev/null +++ b/.chock/compiled/protect-commit-privacy/pre-tool-use/windsurf-hooks.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "pre_run_command": [ + { + "command": "@CHOCK_PYTHON@ \".chock/bin/windsurf.py\" --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + } + ], + "pre_mcp_tool_use": [ + { + "command": "@CHOCK_PYTHON@ \".chock/bin/windsurf.py\" --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + } + ] + } +} \ No newline at end of file diff --git a/.chock/coverage.json b/.chock/coverage.json index 3a36315..5b72d9c 100644 --- a/.chock/coverage.json +++ b/.chock/coverage.json @@ -78,8 +78,8 @@ "witnessed": false }, "codex": { - "level": "advisory", - "basis": null, + "level": "best-effort", + "basis": "live-run-partial", "witnessed": false }, "copilot": { @@ -93,18 +93,18 @@ "witnessed": false }, "devin": { - "level": "advisory", - "basis": null, + "level": "best-effort", + "basis": "vendor-docs", "witnessed": false }, "gemini": { - "level": "advisory", - "basis": null, + "level": "best-effort", + "basis": "vendor-source", "witnessed": false }, "grok": { - "level": "advisory", - "basis": null, + "level": "best-effort", + "basis": "vendor-docs", "witnessed": false }, "kimi-code": { @@ -118,8 +118,8 @@ "witnessed": false }, "tabnine": { - "level": "advisory", - "basis": null, + "level": "best-effort", + "basis": "vendor-docs", "witnessed": false }, "vscode": { @@ -128,8 +128,8 @@ "witnessed": true }, "windsurf": { - "level": "advisory", - "basis": null, + "level": "best-effort", + "basis": "third-party-install", "witnessed": false } }, @@ -212,8 +212,8 @@ "witnessed": false }, "codex": { - "level": "advisory", - "basis": null, + "level": "best-effort", + "basis": "live-run-partial", "witnessed": false }, "copilot": { @@ -227,18 +227,18 @@ "witnessed": false }, "devin": { - "level": "advisory", - "basis": null, + "level": "best-effort", + "basis": "vendor-docs", "witnessed": false }, "gemini": { - "level": "advisory", - "basis": null, + "level": "best-effort", + "basis": "vendor-source", "witnessed": false }, "grok": { - "level": "advisory", - "basis": null, + "level": "best-effort", + "basis": "vendor-docs", "witnessed": false }, "kimi-code": { @@ -252,8 +252,8 @@ "witnessed": false }, "tabnine": { - "level": "advisory", - "basis": null, + "level": "best-effort", + "basis": "vendor-docs", "witnessed": false }, "vscode": { @@ -262,8 +262,8 @@ "witnessed": true }, "windsurf": { - "level": "advisory", - "basis": null, + "level": "best-effort", + "basis": "third-party-install", "witnessed": false } }, @@ -815,8 +815,8 @@ "witnessed": false }, "codex": { - "level": "advisory", - "basis": null, + "level": "best-effort", + "basis": "live-run-partial", "witnessed": false }, "copilot": { @@ -830,18 +830,18 @@ "witnessed": false }, "devin": { - "level": "advisory", - "basis": null, + "level": "best-effort", + "basis": "vendor-docs", "witnessed": false }, "gemini": { - "level": "advisory", - "basis": null, + "level": "best-effort", + "basis": "vendor-source", "witnessed": false }, "grok": { - "level": "advisory", - "basis": null, + "level": "best-effort", + "basis": "vendor-docs", "witnessed": false }, "kimi-code": { @@ -855,8 +855,8 @@ "witnessed": false }, "tabnine": { - "level": "advisory", - "basis": null, + "level": "best-effort", + "basis": "vendor-docs", "witnessed": false }, "vscode": { @@ -865,8 +865,8 @@ "witnessed": true }, "windsurf": { - "level": "advisory", - "basis": null, + "level": "best-effort", + "basis": "third-party-install", "witnessed": false } }, @@ -882,8 +882,8 @@ "witnessed": false }, "codex": { - "level": "advisory", - "basis": null, + "level": "best-effort", + "basis": "live-run-partial", "witnessed": false }, "copilot": { @@ -897,18 +897,18 @@ "witnessed": false }, "devin": { - "level": "advisory", - "basis": null, + "level": "best-effort", + "basis": "vendor-docs", "witnessed": false }, "gemini": { - "level": "advisory", - "basis": null, + "level": "best-effort", + "basis": "vendor-source", "witnessed": false }, "grok": { - "level": "advisory", - "basis": null, + "level": "best-effort", + "basis": "vendor-docs", "witnessed": false }, "kimi-code": { @@ -922,8 +922,8 @@ "witnessed": false }, "tabnine": { - "level": "advisory", - "basis": null, + "level": "best-effort", + "basis": "vendor-docs", "witnessed": false }, "vscode": { @@ -932,8 +932,8 @@ "witnessed": true }, "windsurf": { - "level": "advisory", - "basis": null, + "level": "best-effort", + "basis": "third-party-install", "witnessed": false } }, diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 0000000..f3a0583 --- /dev/null +++ b/.codex/hooks.json @@ -0,0 +1,42 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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\"" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "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\"" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "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\"" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "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\"" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.devin/hooks.v1.json b/.devin/hooks.v1.json new file mode 100644 index 0000000..6c28ea6 --- /dev/null +++ b/.devin/hooks.v1.json @@ -0,0 +1,36 @@ +{ + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python\" \".chock/bin/devin.py\" --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python\" \".chock/bin/devin.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python\" \".chock/bin/devin.py\" --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python\" \".chock/bin/devin.py\" --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + } + ] + } + ] +} \ No newline at end of file diff --git a/.gemini/settings.json b/.gemini/settings.json new file mode 100644 index 0000000..8468b79 --- /dev/null +++ b/.gemini/settings.json @@ -0,0 +1,42 @@ +{ + "hooks": { + "BeforeTool": [ + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python\" \".chock/bin/gemini_cli.py\" --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + } + ], + "matcher": "run_shell_command" + }, + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python\" \".chock/bin/gemini_cli.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + } + ], + "matcher": "run_shell_command" + }, + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python\" \".chock/bin/gemini_cli.py\" --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + } + ], + "matcher": "run_shell_command" + }, + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python\" \".chock/bin/gemini_cli.py\" --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + } + ], + "matcher": "run_shell_command" + } + ] + } +} \ No newline at end of file diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml index 45307e3..67d4f0e 100644 --- a/.github/codeql/codeql-config.yml +++ b/.github/codeql/codeql-config.yml @@ -15,3 +15,6 @@ name: "Chock CodeQL config" # is tracked upstream, not fixed here: https://github.com/open-coder-ai/agentseam/issues/85 paths-ignore: - ".chock/bin/**" + # The same bundler output, frozen per vendor as golden fixtures (tests/test_runtime_goldens.py); + # ruff excludes it for the identical reason. + - "tests/fixtures/runtime_goldens/**" diff --git a/.grok/hooks/agentseam.json b/.grok/hooks/agentseam.json new file mode 100644 index 0000000..e56ef93 --- /dev/null +++ b/.grok/hooks/agentseam.json @@ -0,0 +1,38 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python\" \".chock/bin/grok.py\" --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python\" \".chock/bin/grok.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python\" \".chock/bin/grok.py\" --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python\" \".chock/bin/grok.py\" --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.tabnine/agent/settings.json b/.tabnine/agent/settings.json new file mode 100644 index 0000000..c944cfd --- /dev/null +++ b/.tabnine/agent/settings.json @@ -0,0 +1,42 @@ +{ + "hooks": { + "BeforeTool": [ + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python\" \".chock/bin/tabnine.py\" --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"", + "name": "agentseam" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python\" \".chock/bin/tabnine.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"", + "name": "agentseam" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python\" \".chock/bin/tabnine.py\" --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"", + "name": "agentseam" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "\"/usr/local/bin/python\" \".chock/bin/tabnine.py\" --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", + "name": "agentseam" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.windsurf/hooks.json b/.windsurf/hooks.json new file mode 100644 index 0000000..442b4f3 --- /dev/null +++ b/.windsurf/hooks.json @@ -0,0 +1,32 @@ +{ + "hooks": { + "pre_run_command": [ + { + "command": "\"/usr/local/bin/python\" \".chock/bin/windsurf.py\" --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + }, + { + "command": "\"/usr/local/bin/python\" \".chock/bin/windsurf.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + }, + { + "command": "\"/usr/local/bin/python\" \".chock/bin/windsurf.py\" --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + }, + { + "command": "\"/usr/local/bin/python\" \".chock/bin/windsurf.py\" --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + } + ], + "pre_mcp_tool_use": [ + { + "command": "\"/usr/local/bin/python\" \".chock/bin/windsurf.py\" --guard \".agents/policies/block-destructive-commands/implementations/block-destructive.sh\"" + }, + { + "command": "\"/usr/local/bin/python\" \".chock/bin/windsurf.py\" --guard \".agents/policies/block-no-verify/implementations/block-no-verify.sh\"" + }, + { + "command": "\"/usr/local/bin/python\" \".chock/bin/windsurf.py\" --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.sh\"" + }, + { + "command": "\"/usr/local/bin/python\" \".chock/bin/windsurf.py\" --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"" + } + ] + } +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e52bcf..2f188ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,35 @@ ## Unreleased +- **In-agent membership derives from agentseam's capability matrix, and the surface + extends to seven new vendors** (design C3, `docs/design/derive-from-vendor-config.md`). + `IN_AGENT_TODAY`, `SURFACE_AGENTS`, `RUNTIME_AGENTS` and `VENDORED_RUNTIMES` stop being + hand lists: membership is `matrix.can_block(V, PRE_TOOL)` capped by what the repo-scoped + installer may touch (a repo-relative JSON config), computed in `chock.vendors`. + antigravity, codex_cli (repo-level, beside its existing plugin store), devin, gemini_cli, + grok, tabnine and windsurf now get per-policy pre-tool fragments rendered by agentseam's + own `hook_config` (`compile/emitters/in_agent.py`), one shape-agnostic config-merge + installer (`hooks/in_agent_generic.py`: strip-ours/deep-merge keyed on the vendored + runtime path, interpreter baking as before), and vendored runtimes from `bundle()`. + junie and kimi_code can block per the matrix but their recorded hook configs are + home-anchored (`~/.junie/...`, `~/.kimi-code/config.toml` -- TOML at that), outside what + `chock sync --repo` may write, so they stay advisory-only; a pinned test fails the day + upstream records repo-level JSON configs for them. junie (absent from chock entirely + before) joins the alias table, advisory surfaces and both published matrices. Day-one + coverage for every new vendor is the matrix word under its per-claim basis cap -- + `best-effort (vendor-docs|vendor-source|third-party-install|live-run-partial)`, + `witnessed: false` everywhere (no live run exists) -- and the four previously enforced + vendors' artifacts are byte-identical (before/after tree diff; only new-vendor + coverage cells moved). New evidence: six `honours_ask` claim rows tested against the + bundled runtimes (`block` and `exit-2` join the wire-verdict vocabulary for + devin's spelling and windsurf's G5 exit-code grammar). New goldens: per-vendor fragment + fixtures in the emitter-stability tree and frozen per-vendor runtime bytes + (`tests/fixtures/runtime_goldens/`, regenerated only via `CHOCK_REGEN_GOLDENS=1`). + Fragment commands for the new vendors use repo-relative paths -- no repo-root token is + recorded upstream for them (the `${CLAUDE_PROJECT_DIR}` gap, filed) -- so the hooks + resolve where the vendor runs them from the repo root, and installs stay unwitnessed + best-effort claims until a real client run lands in the witness ledger. + - **Per-vendor wire facts are now reads of agentseam 0.2.0's vendor config, and the vendor emitters/installers collapse into one of each.** Config paths (`.claude/settings.json`, `.cursor/hooks.json`, the `.github/hooks/` directory), pre-tool event spellings diff --git a/README.md b/README.md index 21c6ea6..e69c248 100644 --- a/README.md +++ b/README.md @@ -186,22 +186,22 @@ guarantee holds. Read the full [architecture overview](docs/architecture.md). | **Cursor** | ✅ | ✅ | ✅ | ✅ | — | | Copilot | ✅ | ✅ | ✅ | — | ✅ | | VS Code | ✅ | ✅ | ✅ | — | ✅ | -| Codex | ✅ | ✅ | ✅ | — | — | -| Gemini | ✅ | ✅ | ✅ | — | — | -| Windsurf | ✅ | ✅ | ✅ | — | — | -| Devin | ✅ | ✅ | ✅ | — | — | +| Codex | ✅ | ✅ | ✅ | ✅ | — | +| Gemini | ✅ | ✅ | ✅ | ✅ | — | +| Windsurf | ✅ | ✅ | ✅ | ✅ | — | +| Devin | ✅ | ✅ | ✅ | ✅ | — | | Aider | ✅ | ✅ | ✅ | — | — | -| Grok | ✅ | ✅ | ✅ | — | — | +| Grok | ✅ | ✅ | ✅ | ✅ | — | +| Junie | ✅ | ✅ | ✅ | — | — | | Kimi Code | ✅ | ✅ | ✅ | — | — | | Replit | ✅ | ✅ | ✅ | — | — | -| Tabnine | ✅ | ✅ | ✅ | — | — | -| Antigravity CLI | ✅ | ✅ | ✅ | — | — | - -A checkmark is support, not installation: coverage credits `ci-gate` only once -`chock sync --ci` has written the workflow. Three of the eight surfaces are absent because -they credit no agent today — `managed-setting` is compiled but not installed, `gateway` is -modelled but not yet emitted, and `mcp-gateway` credits nothing until its per-client witness -ships. [Enforcement surfaces](docs/enforcement-surfaces.md) carries all eight with their +| Tabnine | ✅ | ✅ | ✅ | ✅ | — | +| Antigravity CLI | ✅ | ✅ | ✅ | ✅ | — | + +A checkmark is support, not installation: coverage credits `ci-gate` only once `chock +sync --ci` has written the workflow. Three of the eight surfaces are absent because they +credit no agent today — `managed-setting` is compiled but not installed, `gateway` is +modelled but not yet emitted, and `mcp-gateway` credits nothing until its per-client witness ships. [Enforcement surfaces](docs/enforcement-surfaces.md) carries all eight with their caveats and the coverage level each can earn. ## ✍️ Author your own policy diff --git a/chock.lock b/chock.lock index 73a9b27..dc7dba1 100644 --- a/chock.lock +++ b/chock.lock @@ -16,7 +16,7 @@ "managed": false, "sha256": "59fffb91c6f65710b461e87782d355c1403953f36700e05369fca065683821c0", "source": "local", - "artifacts_sha256": "c7f65d88dc4b43e77bad7df4cd01e70d0a6297a519fdc4a4377bad03abc9fc7e" + "artifacts_sha256": "62845a8986c3feb8b100891a62e528a91d5158b8e59e500f525e83e702ed7190" }, { "id": "block-invisible-unicode", @@ -32,7 +32,7 @@ "managed": false, "sha256": "ef0d729c413086bd4ce77c7191bcc1393f4e0cdfcfc959b33a1ddb771afdf2b0", "source": "local", - "artifacts_sha256": "34dc5ad058acc1ae96e65780f3877aa86c8409027af17f0903d05d62c879ca1b" + "artifacts_sha256": "7660929f39693e3e05b1612d42432a4c6abebe7ba742eb7579a68ecf2be476f7" }, { "id": "block-wildcard-agent-permissions", @@ -104,7 +104,7 @@ "managed": false, "sha256": "b98d5472c534d381b87e253086642675c71ce3947e2cfe01ed9fec5ed8b66295", "source": "local", - "artifacts_sha256": "d7ae0b1194518570c1103f7d4fbc41b9f49db5beac230e9ee6881aca11246e6f" + "artifacts_sha256": "28d9b17c15b12698125832b7674f4840714413756eebe66e53c60f508a77989f" }, { "id": "protect-commit-privacy", @@ -112,7 +112,7 @@ "managed": false, "sha256": "281522db2b259ea9a12d76b30d57ed9993882089b04a1fb4ce9932bc15a3388f", "source": "local", - "artifacts_sha256": "2d7dc910b77cc7703daefc25711036be9045244bc575ff6bee531ccf7441ba7d" + "artifacts_sha256": "e06649fe5879f5b97c347d99eebc5663cd0cc43a07eb0ccf5a9d3e5dd1fecb8b" }, { "id": "protect-main-branch", diff --git a/docs/assets/social-preview.png b/docs/assets/social-preview.png index fd4a9a8..2b95c2a 100644 Binary files a/docs/assets/social-preview.png and b/docs/assets/social-preview.png differ diff --git a/docs/assets/social-preview.svg b/docs/assets/social-preview.svg index f57bfa2..9c37342 100644 --- a/docs/assets/social-preview.svg +++ b/docs/assets/social-preview.svg @@ -1,5 +1,5 @@ + role="img" aria-label="chock — governance-as-code for AI coding agents: write a rule once and every agent obeys it. A policy compiles to whatever control each agent actually supports. 15 agents (claude, aider, antigravity, codex, copilot, cursor, devin, gemini, grok, junie, kimi-code, replit, tabnine, vscode, windsurf); 8 enforcement surfaces (ambient-rule, git-hook, ci-gate, pre-tool-use, managed-setting, gateway, mcp-gateway, agent-hooks), of which 3 are installed by chock sync (ambient-rule, ci-gate, git-hook); 8 everyday commands (init, add, remove, sync, check, status, enable, disable). Python 3.11–3.13, Apache-2.0, on PyPI as chock, version 0.7.0."> @@ -26,7 +26,7 @@ AGENTS - 14 + 15 claude @@ -47,10 +47,10 @@ grok - kimi-code + junie - replit - + 3 more + kimi-code + + 4 more SURFACES @@ -94,7 +94,7 @@ disable - 14 + 15 AGENTS 8 SURFACES diff --git a/docs/enforcement-surfaces.md b/docs/enforcement-surfaces.md index 1478115..0836847 100644 --- a/docs/enforcement-surfaces.md +++ b/docs/enforcement-surfaces.md @@ -11,7 +11,7 @@ each guarantee holds. | `git-hook` | Hard, at commit/push | Yes (`--no-verify`) | Pre-commit / pre-merge-commit / pre-push guard | | `ci-gate` | Hard, un-bypassable | No | The backstop for a skipped git hook | | `ambient-rule` | Advisory | Yes | Compiled `AGENTS.md` block the agent is asked to follow | -| `pre-tool-use` | Hard, pre-execution | No | Blocks a command **before** the agent runs it, in each client's own deny dialect — Claude Code + Cursor (see the Cursor caveat) | +| `pre-tool-use` | Hard, pre-execution | No | Blocks a command **before** the agent runs it, in each client's own deny dialect — every matrix-blocking vendor with a repo-level config (see the Cursor caveat) | | `agent-hooks` | Hard, pre-execution | No | The same exit-2 deny for Copilot CLI + VS Code agent mode, from `.github/hooks/chock.json` (witnessed blocking on both, 2026-08-23) | | `managed-setting` | Hard, org-level | No | Admin-deployed allow/ask/deny rules | | `gateway` | Hard, un-circumventable | No | Budget/egress backstop — *modeled now, emitted later* | @@ -24,13 +24,13 @@ each guarantee holds. > bash-syntax commands but not PowerShell-native destructive syntax. 0.0.6 closes that gap > with a PowerShell/cmd guard matched against the raw command (`CHOCK_RAW_COMMAND`); other > guards remain pattern filters, so the "non-standard shell" bypass class they document -> still applies to them. The hook's -> interpreter is resolved at run time (skipping the Windows Store `python3` alias stub) and -> the repo root via `git rev-parse`, so the committed file is portable with no baked path. +> still applies to them. The hook's interpreter is resolved at run time (skipping the +> Windows Store `python3` alias stub) and the repo root via `git rev-parse`, so the +> committed file is portable with no baked path. **`git-hook` + `ci-gate` are the universal hard floor** every agent shares. `pre-tool-use` and -`agent-hooks` are the premium tier available on agents that expose native controls — Claude -Code and Cursor via `pre-tool-use`, Copilot CLI and VS Code via `agent-hooks`. +`agent-hooks` are the premium tier on agents that expose native controls; membership +derives from agentseam's matrix (see the coverage matrix below). `gateway` is reserved for cost/egress controls on the roadmap. `mcp-gateway` ([#32](https://github.com/open-coder-ai/chock/issues/32)) governs exactly the MCP slice: tool calls routed through the proxy. An agent's native shell and file tools never cross @@ -95,30 +95,32 @@ Which surfaces each agent supports today (from `src/chock/compile/surfaces.py`): | **Claude Code** | ✅ | ✅ | ✅ | ✅ | ✅ | — | | **Cursor** | ✅ | ✅ | ✅ | ✅ | — | — | | Copilot | ✅ | ✅ | ✅ | — | — | ✅ | -| Codex | ✅ | ✅ | ✅ | — | — | — | -| Gemini | ✅ | ✅ | ✅ | — | — | — | -| Windsurf | ✅ | ✅ | ✅ | — | — | — | -| Devin | ✅ | ✅ | ✅ | — | — | — | +| Codex | ✅ | ✅ | ✅ | ✅ | — | — | +| Gemini | ✅ | ✅ | ✅ | ✅ | — | — | +| Windsurf | ✅ | ✅ | ✅ | ✅ | — | — | +| Devin | ✅ | ✅ | ✅ | ✅ | — | — | | Aider | ✅ | ✅ | ✅ | — | — | — | -| Grok | ✅ | ✅ | ✅ | — | — | — | +| Grok | ✅ | ✅ | ✅ | ✅ | — | — | +| Junie | ✅ | ✅ | ✅ | — | — | — | | Kimi Code | ✅ | ✅ | ✅ | — | — | — | | Replit | ✅ | ✅ | ✅ | — | — | — | -| Tabnine | ✅ | ✅ | ✅ | — | — | — | +| Tabnine | ✅ | ✅ | ✅ | ✅ | — | — | | VS Code | ✅ | ✅ | ✅ | — | — | ✅ | -| Antigravity CLI | ✅ | ✅ | ✅ | — | — | — | +| Antigravity CLI | ✅ | ✅ | ✅ | ✅ | — | — | -Claude Code and Cursor get `pre-tool-use`, Copilot CLI and VS Code get `agent-hooks`; -the rest get the shared hard floor plus advisory rules until their own native controls -are wired in. +In-agent membership derives from agentseam's matrix: every adapted vendor whose row can block +a pre-tool call from a repo-level JSON hook config gets `pre-tool-use` (Copilot CLI and VS Code: +`agent-hooks`, chock's owned file). Junie and Kimi Code block only via home-level configs (Kimi +Code's in TOML), outside what `chock sync --repo` may write; Aider and Replit cannot block. A +checkmark is a wiring claim: new-vendor cells stay `witnessed: false` until a real client run is recorded. ## Coverage levels For each policy × agent, the compiler records one of eight levels in `.chock/coverage.json`. -The first four come from the **in-agent ladder** — agentseam's own honest, per-agent -vocabulary (`agentseam.matrix.enforcement_level`, owner decision #9) for an installed, -in-agent pre-execution control, plus one level of chock's own — because they are not the -same claim: a hook that fails OPEN on a crash is a materially weaker promise than one that -fails closed, and an adopter deciding whether to trust a control needs to see the difference. +The first four come from the **in-agent ladder** — agentseam's own honest, per-agent vocabulary +(`agentseam.matrix.enforcement_level`, owner decision #9) for an installed, in-agent pre-execution +control, plus one level of chock's own — because a hook that fails OPEN on a crash is a materially +weaker promise than one that fails closed, and an adopter deciding on trust needs to see the difference. | Level | Meaning | | :--- | :--- | @@ -263,7 +265,6 @@ recheck it rather than take this table's word: rejected, and `codex-rs/hooks/src/events/pre_tool_use.rs:234-244` sets `should_block` in the non-rejected arm alone — so a literal `ask` there would let the call through. - **This raises no coverage grade.** A control is only as strong as its worst degradation, and three of the five causes above still allow — so chock's in-agent controls stay at the level the ladder gives a control that degrades to allowing. The ask is a real improvement on two diff --git a/pyproject.toml b/pyproject.toml index db64380..2edd248 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -132,7 +132,7 @@ target-version = "py311" # composed source-level into one file. Composition can duplicate a module-level import # two of those sources both happen to make (e.g. `import json as _json`) -- harmless at # runtime, but not something either source module did wrong, so not a lint finding here. -extend-exclude = ["*.md", ".chock/bin/"] +extend-exclude = ["*.md", ".chock/bin/", "tests/fixtures/runtime_goldens/"] [tool.ruff.lint] select = ["E", "F", "W", "I"] diff --git a/src/chock/compile/emitters/in_agent.py b/src/chock/compile/emitters/in_agent.py index d23e7aa..6dded83 100644 --- a/src/chock/compile/emitters/in_agent.py +++ b/src/chock/compile/emitters/in_agent.py @@ -66,6 +66,25 @@ def _adapter_rel(vendor: str) -> str: return f".chock/bin/{vendor}.py" +#: Vendors wired through hand-shaped fragments that predate the derivation (claude/cursor +#: entry shapes, the witnessed agent-hooks override). Everyone else the membership +#: predicate admits renders through agentseam's own hook_config -- no per-vendor emitter. +BESPOKE_VENDORS = ("claude_code", "cursor", "vscode_copilot") + +GENERIC_VENDORS = tuple(v for v in vendors.in_agent_vendors() if v not in BESPOKE_VENDORS) + + +def generic_hooks_file(vendor: str, command: str) -> dict[str, Any]: + """`vendor`'s full hook-config document for one guard command, agentseam's rendering. + + Paths inside `command` are repo-relative: no repo-root token is recorded upstream for + these vendors (the `${CLAUDE_PROJECT_DIR}` gap), so the entry resolves only where the + vendor runs hooks from the repo root -- the same condition under which the relative + adapter path resolves at all. + """ + return vendors.pre_tool_hook_config(vendor, command, matcher=vendors.shell_matcher(vendor)) + + def hook_entry(command: str, *, matcher: str | None = None) -> dict[str, Any]: """One hooks-map entry (agentseam's `hooks_map` wrapper shape) plus chock's timeout.""" entry: dict[str, Any] = {} @@ -114,6 +133,11 @@ def emit_pre_tool_use(policy_dir: Path, output_dir: Path, manifest: dict[str, An dest = output_dir / name write_generated_json(dest, build(command)) written.append(dest) + for vendor in GENERIC_VENDORS: + command = f'@CHOCK_PYTHON@ "{_adapter_rel(vendor)}" --guard "{rel}/implementations/{script}"' + dest = output_dir / f"{vendor}-hooks.json" + write_generated_json(dest, generic_hooks_file(vendor, command)) + written.append(dest) return written diff --git a/src/chock/compile/levels.py b/src/chock/compile/levels.py index 96bf385..695379e 100644 --- a/src/chock/compile/levels.py +++ b/src/chock/compile/levels.py @@ -9,12 +9,12 @@ from agentseam import matrix_terms as _terms from chock import evidence -from chock.vendors import CHOCK_AGENT +from chock.vendors import CHOCK_AGENT, in_agent_vendors -#: Chock agents with an in-agent pre-tool surface today. A narrower concept than -#: `CHOCK_AGENT` (adapter-instruction coverage) -- both read the same alias table, -#: so this is a membership set, not a second copy of the vendor-id mapping. -IN_AGENT_TODAY = ("claude", "cursor", "copilot", "vscode") +#: Chock agents with an in-agent pre-tool surface. A narrower concept than `CHOCK_AGENT` +#: (adapter-instruction coverage): derived by scoping the alias table to the vendors the +#: matrix blocking predicate admits (chock.vendors.in_agent_vendors), never hand-listed. +IN_AGENT_TODAY = tuple(sorted(a for a, v in CHOCK_AGENT.items() if v in in_agent_vendors())) def _mapped_vendor(chock_agent: str) -> str | None: diff --git a/src/chock/compile/surfaces.py b/src/chock/compile/surfaces.py index 89f72af..1844536 100644 --- a/src/chock/compile/surfaces.py +++ b/src/chock/compile/surfaces.py @@ -5,6 +5,8 @@ from enum import Enum from chock.compile.levels import IN_AGENT_TODAY, Grade, _matrix_can_block, in_agent_grade +from chock.hooks.in_agent_install import AGENT_HOOKS_VENDORS +from chock.vendors import CHOCK_AGENT class Surface(str, Enum): @@ -18,40 +20,22 @@ class Surface(str, Enum): AGENT_HOOKS = "agent-hooks" +#: Derived, never hand-rowed: every aliased agent gets the advisory floor, claude keeps +#: its managed-setting arm (chock policy), and in-agent membership comes from the matrix +#: blocking predicate via IN_AGENT_TODAY -- agent-hooks where the vendor is wired through +#: chock's owned agent-hooks file, pre-tool-use everywhere else. SURFACE_AGENTS: dict[str, set[Surface]] = { - "claude": { - Surface.AMBIENT_RULE, - Surface.GIT_HOOK, - Surface.CI_GATE, - Surface.MANAGED_SETTING, - }, - "cursor": {Surface.AMBIENT_RULE, Surface.GIT_HOOK, Surface.CI_GATE}, - "copilot": {Surface.AMBIENT_RULE, Surface.GIT_HOOK, Surface.CI_GATE}, - "windsurf": {Surface.AMBIENT_RULE, Surface.GIT_HOOK, Surface.CI_GATE}, - "devin": {Surface.AMBIENT_RULE, Surface.GIT_HOOK, Surface.CI_GATE}, - "codex": {Surface.AMBIENT_RULE, Surface.GIT_HOOK, Surface.CI_GATE}, - "grok": {Surface.AMBIENT_RULE, Surface.GIT_HOOK, Surface.CI_GATE}, - "kimi-code": {Surface.AMBIENT_RULE, Surface.GIT_HOOK, Surface.CI_GATE}, - "aider": {Surface.AMBIENT_RULE, Surface.GIT_HOOK, Surface.CI_GATE}, - "gemini": {Surface.AMBIENT_RULE, Surface.GIT_HOOK, Surface.CI_GATE}, - "replit": {Surface.AMBIENT_RULE, Surface.GIT_HOOK, Surface.CI_GATE}, - "tabnine": {Surface.AMBIENT_RULE, Surface.GIT_HOOK, Surface.CI_GATE}, - "vscode": {Surface.AMBIENT_RULE, Surface.GIT_HOOK, Surface.CI_GATE}, - "antigravity": {Surface.AMBIENT_RULE, Surface.GIT_HOOK, Surface.CI_GATE}, + agent: {Surface.AMBIENT_RULE, Surface.GIT_HOOK, Surface.CI_GATE} for agent in CHOCK_AGENT } +SURFACE_AGENTS["claude"].add(Surface.MANAGED_SETTING) - -for _agent, _surface in ( - ("claude", Surface.PRE_TOOL_USE), - ("cursor", Surface.PRE_TOOL_USE), - ("copilot", Surface.AGENT_HOOKS), - ("vscode", Surface.AGENT_HOOKS), -): - if not _matrix_can_block(_agent): +for _agent in IN_AGENT_TODAY: + if not _matrix_can_block(_agent): # pragma: no cover - membership already derives from can_block raise AssertionError( f"agentseam's matrix no longer confirms {_agent!r} can block a pre-tool call; " - f"{_surface.value} membership here must be re-reviewed, not silently kept or dropped" + "in-agent membership must be re-derived, not silently kept" ) + _surface = Surface.AGENT_HOOKS if CHOCK_AGENT[_agent] in AGENT_HOOKS_VENDORS else Surface.PRE_TOOL_USE SURFACE_AGENTS[_agent].add(_surface) del _agent, _surface diff --git a/src/chock/data/claims.json b/src/chock/data/claims.json index 6444ef5..91a9553 100644 --- a/src/chock/data/claims.json +++ b/src/chock/data/claims.json @@ -30,5 +30,53 @@ "honours": false, "evidence": "tested", "test": "tests/test_guard_fail_to_ask.py::test_a_crashed_guard_asks_rather_than_allowing" + }, + { + "agent": "antigravity", + "claim": "honours_ask", + "verdict": "ask", + "honours": true, + "evidence": "tested", + "test": "tests/test_guard_fail_to_ask.py::test_a_crashed_guard_asks_rather_than_allowing" + }, + { + "agent": "devin", + "claim": "honours_ask", + "verdict": "block", + "honours": false, + "evidence": "tested", + "test": "tests/test_guard_fail_to_ask.py::test_a_crashed_guard_asks_rather_than_allowing" + }, + { + "agent": "gemini_cli", + "claim": "honours_ask", + "verdict": "ask", + "honours": true, + "evidence": "tested", + "test": "tests/test_guard_fail_to_ask.py::test_a_crashed_guard_asks_rather_than_allowing" + }, + { + "agent": "grok", + "claim": "honours_ask", + "verdict": "deny", + "honours": false, + "evidence": "tested", + "test": "tests/test_guard_fail_to_ask.py::test_a_crashed_guard_asks_rather_than_allowing" + }, + { + "agent": "tabnine", + "claim": "honours_ask", + "verdict": "deny", + "honours": false, + "evidence": "tested", + "test": "tests/test_guard_fail_to_ask.py::test_a_crashed_guard_asks_rather_than_allowing" + }, + { + "agent": "windsurf", + "claim": "honours_ask", + "verdict": "exit-2", + "honours": false, + "evidence": "tested", + "test": "tests/test_guard_fail_to_ask.py::test_a_crashed_guard_asks_rather_than_allowing" } ] diff --git a/src/chock/evidence.py b/src/chock/evidence.py index 0d4a3ee..34697e2 100644 --- a/src/chock/evidence.py +++ b/src/chock/evidence.py @@ -33,9 +33,12 @@ # A claim's `verdict` is the word witnessed on the vendor's wire, never agentseam's # canonical outcome vocabulary; tests/test_guard_fail_to_ask.py ties it to live fixtures. +# `block` is devin/junie-family wire spelling; `exit-2` is windsurf's wordless exit-code grammar. WIRE_ASK = "ask" WIRE_DENY = "deny" -WIRE_VERDICTS = (WIRE_ASK, WIRE_DENY) +WIRE_BLOCK = "block" +WIRE_EXIT_2 = "exit-2" +WIRE_VERDICTS = (WIRE_ASK, WIRE_DENY, WIRE_BLOCK, WIRE_EXIT_2) _DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$") diff --git a/src/chock/gate/runtime_bundle.py b/src/chock/gate/runtime_bundle.py index a7ffbea..b788307 100644 --- a/src/chock/gate/runtime_bundle.py +++ b/src/chock/gate/runtime_bundle.py @@ -7,6 +7,8 @@ from agentseam import bundler +from chock.vendors import in_agent_vendors + from . import guard_runner, sessionstart BEGIN = "# >>> agentseam handler >>>" @@ -14,7 +16,7 @@ _SESSION_START_AGENTS = frozenset({"claude_code"}) -RUNTIME_AGENTS = ("claude_code", "codex_cli", "cursor", "vscode_copilot") +RUNTIME_AGENTS = in_agent_vendors() _IMPORTS = """\ import os as _chock_os diff --git a/src/chock/hooks/in_agent_generic.py b/src/chock/hooks/in_agent_generic.py new file mode 100644 index 0000000..385eb64 --- /dev/null +++ b/src/chock/hooks/in_agent_generic.py @@ -0,0 +1,190 @@ +"""Generic in-agent install: merge agentseam-rendered hook fragments into vendor configs.""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path +from typing import Any + +from chock import vendors +from chock.emit import write_generated_json +from chock.hooks.runtime_vendor import runtime_rel, vendor_runtime + +INTERPRETER_PLACEHOLDER = "@CHOCK_PYTHON@" + +_INTERP_RE = re.compile(r'(^|&\s+)("[^"]+"|\S+)(?=\s+"\.chock/bin/)') + + +def load_config(path: Path) -> dict: + """The vendor's config file as a dict; a file that is not readable JSON is refused.""" + settings: dict = {} + if path.exists(): + try: + loaded = json.loads(path.read_text(encoding="utf-8")) + if isinstance(loaded, dict): + settings = loaded + except (json.JSONDecodeError, OSError): + raise ValueError(f"{path} is not readable JSON; leaving it untouched") from None + return settings + + +def _marker(vendor: str) -> str: + return runtime_rel(vendor).as_posix() + + +def _ours(node: Any, marker: str) -> bool: + return marker in json.dumps(node) + + +def _map_strings(node: Any, fn) -> Any: + if isinstance(node, dict): + return {key: _map_strings(value, fn) for key, value in node.items()} + if isinstance(node, list): + return [_map_strings(value, fn) for value in node] + return fn(node) if isinstance(node, str) else node + + +def _bake(node: Any) -> Any: + exe = f'"{sys.executable}"' + return _map_strings(node, lambda s: s.replace(INTERPRETER_PLACEHOLDER, exe)) + + +def _normalize(node: Any) -> Any: + return _map_strings(node, lambda s: _INTERP_RE.sub(rf"\g<1>{INTERPRETER_PLACEHOLDER}", s)) + + +def _norm_key(entry: dict) -> str: + return json.dumps(_normalize(entry), sort_keys=True) + + +def _interpreter_runs(entry: dict) -> bool: + """Whether every baked interpreter in `entry` still resolves on this machine.""" + stale = [] + + def _probe(value: str) -> str: + match = _INTERP_RE.search(value) + if match: + interpreter = match.group(2).strip('"') + if interpreter != INTERPRETER_PLACEHOLDER and not Path(interpreter).is_file(): + stale.append(interpreter) + return value + + _map_strings(entry, _probe) + return not stale + + +def _collect_ours(node: Any, marker: str, into: dict[str, dict]) -> None: + """Every list-borne entry of ours anywhere under `node`, keyed by its normalized form.""" + if isinstance(node, dict): + for value in node.values(): + _collect_ours(value, marker, into) + elif isinstance(node, list): + for entry in node: + if isinstance(entry, dict) and _ours(entry, marker): + into[_norm_key(entry)] = entry + + +def _strip_ours(node: dict, marker: str) -> None: + """Remove our entries in place; drop only keys that held nothing but ours.""" + for key in list(node): + value = node[key] + if isinstance(value, list): + kept = [entry for entry in value if not _ours(entry, marker)] + if kept: + node[key] = kept + elif kept != value: + del node[key] + elif isinstance(value, dict) and value: + _strip_ours(value, marker) + if not value: + del node[key] + + +def _merge(settings: dict, fragment: dict, prior: dict[str, dict]) -> None: + """Deep-merge one rendered fragment: append entries, keep the vendor's own keys.""" + for key, value in fragment.items(): + if isinstance(value, dict): + if not isinstance(settings.get(key), dict): + settings[key] = {} + _merge(settings[key], value, prior) + elif isinstance(value, list): + existing = settings.get(key) + base = existing if isinstance(existing, list) else [] + settings[key] = base + [_install_form(entry, prior) for entry in value] + else: + settings.setdefault(key, value) + + +def _install_form(entry: Any, prior: dict[str, dict]) -> Any: + if not isinstance(entry, dict): + return entry + installed = prior.get(_norm_key(entry)) + if installed is not None and _interpreter_runs(installed): + return installed + return _bake(entry) + + +def _fragments(repo_root: Path, vendor: str) -> list[tuple[str, dict]]: + found: list[tuple[str, dict]] = [] + for path in sorted((repo_root / ".chock" / "compiled").glob(f"*/pre-tool-use/{vendor}-hooks.json")): + try: + fragment = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + continue + if isinstance(fragment, dict): + found.append((path.parent.parent.name, fragment)) + return found + + +def install_generic(repo_root: Path, vendor: str) -> list[str]: + """Merge `vendor`'s compiled fragments into its recorded config file, keeping entries not ours.""" + repo_root = Path(repo_root) + marker = _marker(vendor) + fragments = _fragments(repo_root, vendor) + config_path = repo_root / vendors.config_path(vendor) + settings = load_config(config_path) + prior: dict[str, dict] = {} + _collect_ours(settings, marker, prior) + _strip_ours(settings, marker) + + if not fragments: + vendored = repo_root / runtime_rel(vendor) + if vendored.exists(): + vendored.unlink() + if config_path.exists() and prior: + if settings: + write_generated_json(config_path, settings) + else: + config_path.unlink() + return [] + + vendor_runtime(repo_root, vendor) + for _policy_id, fragment in fragments: + _merge(settings, fragment, prior) + config_path.parent.mkdir(parents=True, exist_ok=True) + write_generated_json(config_path, settings) + return [policy_id for policy_id, _ in fragments] + + +def installed_generic_ids(repo_root: Path, vendor: str) -> set[str]: + """Policy ids whose fragment entries are all present in `vendor`'s config file.""" + repo_root = Path(repo_root) + marker = _marker(vendor) + config_path = repo_root / vendors.config_path(vendor) + if not config_path.exists(): + return set() + try: + settings = json.loads(config_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return set() + present: dict[str, dict] = {} + _collect_ours(settings if isinstance(settings, dict) else {}, marker, present) + installed: set[str] = set() + for policy_id, fragment in _fragments(repo_root, vendor): + wanted: dict[str, dict] = {} + _collect_ours(fragment, marker, wanted) + if wanted and set(wanted) <= set(present): + installed.add(policy_id) + return installed diff --git a/src/chock/hooks/in_agent_install.py b/src/chock/hooks/in_agent_install.py index b12bf8f..410d4d0 100644 --- a/src/chock/hooks/in_agent_install.py +++ b/src/chock/hooks/in_agent_install.py @@ -10,8 +10,10 @@ from typing import NamedTuple from chock import vendors -from chock.compile.emitters.in_agent import AGENT_HOOKS_ENVELOPE, AGENT_HOOKS_EVENT +from chock.compile.emitters.in_agent import AGENT_HOOKS_ENVELOPE, AGENT_HOOKS_EVENT, GENERIC_VENDORS from chock.emit import write_generated_json +from chock.hooks.in_agent_generic import install_generic, installed_generic_ids +from chock.hooks.in_agent_generic import load_config as _load_config from chock.hooks.runtime_vendor import runtime_rel, vendor_runtime INTERPRETER_PLACEHOLDER = "@CHOCK_PYTHON@" @@ -91,11 +93,18 @@ class _Wiring(NamedTuple): _OWNED_FILE_LABEL = "agent hook(s) in .github/hooks/chock.json" _AGENT_HOOKS_GLOB = "*/agent-hooks/agent-hooks.json" -WIRED_VENDORS = (*_MERGED, _OWNED_FILE_VENDOR) +#: Vendors wired through chock's owned agent-hooks file rather than the vendor's config. +AGENT_HOOKS_VENDORS = (_OWNED_FILE_VENDOR,) + +WIRED_VENDORS = (*_MERGED, *GENERIC_VENDORS, _OWNED_FILE_VENDOR) def install_label(vendor: str) -> str: - return _MERGED[vendor].label if vendor in _MERGED else _OWNED_FILE_LABEL + if vendor in _MERGED: + return _MERGED[vendor].label + if vendor in GENERIC_VENDORS: + return f"hook entr(y/ies) in {vendors.config_path(vendor)}" + return _OWNED_FILE_LABEL def agent_hooks_rel(vendor: str = _OWNED_FILE_VENDOR) -> Path: @@ -111,18 +120,6 @@ def _wrap(entry: dict) -> dict: return {"hooks": [copy.deepcopy(entry)]} -def _load_config(path: Path) -> dict: - settings: dict = {} - if path.exists(): - try: - loaded = json.loads(path.read_text(encoding="utf-8")) - if isinstance(loaded, dict): - settings = loaded - except (json.JSONDecodeError, OSError): - raise ValueError(f"{path} is not readable JSON; leaving it untouched") from None - return settings - - def _compiled_merged(repo_root: Path, vendor: str, event: str) -> list[dict]: """Compiled fragments (claude shape) or entries (cursor shape), ordered by policy id.""" wiring = _MERGED[vendor] @@ -241,6 +238,8 @@ def install_hooks(repo_root: Path, vendor: str) -> list[str]: """Install `vendor`'s compiled in-agent hooks. Returns one item per entry installed.""" if vendor in _MERGED: return _install_merged(repo_root, vendor) + if vendor in GENERIC_VENDORS: + return install_generic(repo_root, vendor) if vendor == _OWNED_FILE_VENDOR: return _install_agent_hooks(repo_root) raise ValueError(f"no in-agent wiring for vendor {vendor!r}; wired: {WIRED_VENDORS}") @@ -262,6 +261,8 @@ def _installed_agent_hooks_entries(repo_root: Path) -> list[dict]: def installed_policy_ids(repo_root: Path, vendor: str) -> set[str]: """Policy ids whose compiled entries are actually present in `vendor`'s config file.""" repo_root = Path(repo_root) + if vendor in GENERIC_VENDORS: + return installed_generic_ids(repo_root, vendor) if vendor == _OWNED_FILE_VENDOR: installed = _installed_agent_hooks_entries(repo_root) if not installed: diff --git a/src/chock/vendored.py b/src/chock/vendored.py index d117c1a..f6724ab 100644 --- a/src/chock/vendored.py +++ b/src/chock/vendored.py @@ -4,11 +4,11 @@ from pathlib import Path +from chock.vendors import in_agent_vendors + VENDORED_RUNTIMES = { "gate.py": ("static", ("chock.gate", "runner.py")), - "claude_code.py": ("bundle", "claude_code"), - "cursor.py": ("bundle", "cursor"), - "vscode_copilot.py": ("bundle", "vscode_copilot"), + **{f"{agent}.py": ("bundle", agent) for agent in in_agent_vendors()}, } diff --git a/src/chock/vendors.py b/src/chock/vendors.py index 26407f3..cc998ad 100644 --- a/src/chock/vendors.py +++ b/src/chock/vendors.py @@ -6,6 +6,7 @@ from agentseam import adapters as _adapters from agentseam import contract as _contract +from agentseam import matrix as _matrix from agentseam.vendor_config import VENDOR_CONFIG CHOCK_AGENT: dict[str, str] = { @@ -15,6 +16,7 @@ "devin": "devin", "codex": "codex_cli", "grok": "grok", + "junie": "junie", "kimi-code": "kimi_code", "copilot": "vscode_copilot", "gemini": "gemini_cli", @@ -31,6 +33,26 @@ def entry(vendor: str) -> dict[str, Any]: return VENDOR_CONFIG[vendor] +def repo_wirable(vendor: str) -> bool: + """Whether `chock sync --repo` can reach the vendor's hook config: a repo-relative JSON file. + + A home-anchored config path (junie, kimi_code) or a non-JSON format is outside the + repo-scoped install model, not outside the vendor's capability; the matrix row still + says what the vendor could do, this says what chock's installer may touch. + """ + facts = entry(vendor) + return facts["config_format"] == "json" and not str(facts["config_path"]).startswith("~") + + +def in_agent_vendors() -> tuple[str, ...]: + """Vendors the in-agent surface covers: the matrix blocking predicate, install-capped. + + Capability enters from `agentseam.matrix` only; the vendor entry contributes wire + facts (and the install cap above), never capability. + """ + return tuple(sorted(v for v in VENDOR_CONFIG if _matrix.can_block(v, _contract.PRE_TOOL) and repo_wirable(v))) + + def config_path(vendor: str) -> str: """Repo-relative path of the file the vendor reads hook wiring from.""" return str(entry(vendor)["config_path"]) @@ -58,6 +80,11 @@ def shell_matcher(vendor: str) -> str | None: return "|".join(tools) if tools else None +def pre_tool_hook_config(vendor: str, command: str, matcher: str | None = None) -> dict[str, Any]: + """The vendor's complete hook-config document gating pre-tool with `command`.""" + return _adapters.get(vendor).hook_config((_contract.PRE_TOOL,), command, matcher) + + def config_envelope(vendor: str) -> dict[str, Any]: """Wrapper keys the vendor's hook config carries beside its hooks table.""" return {key: value for key, value in _adapters.get(vendor).hook_config((), "").items() if key != "hooks"} diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/antigravity-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/antigravity-hooks.json new file mode 100644 index 0000000..136e014 --- /dev/null +++ b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/antigravity-hooks.json @@ -0,0 +1,14 @@ +{ + "agentseam": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/antigravity.py\" --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/codex_cli-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/codex_cli-hooks.json new file mode 100644 index 0000000..3a6d88a --- /dev/null +++ b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/codex_cli-hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "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\"" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/devin-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/devin-hooks.json new file mode 100644 index 0000000..e5e6b1f --- /dev/null +++ b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/devin-hooks.json @@ -0,0 +1,12 @@ +{ + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/devin.py\" --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/gemini_cli-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/gemini_cli-hooks.json new file mode 100644 index 0000000..64afae7 --- /dev/null +++ b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/gemini_cli-hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "BeforeTool": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/gemini_cli.py\" --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" + } + ], + "matcher": "run_shell_command" + } + ] + } +} \ No newline at end of file diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/grok-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/grok-hooks.json new file mode 100644 index 0000000..28fef9f --- /dev/null +++ b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/grok-hooks.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/grok.py\" --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/tabnine-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/tabnine-hooks.json new file mode 100644 index 0000000..d0dc7fe --- /dev/null +++ b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/tabnine-hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "BeforeTool": [ + { + "hooks": [ + { + "type": "command", + "command": "@CHOCK_PYTHON@ \".chock/bin/tabnine.py\" --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", + "name": "agentseam" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/windsurf-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/windsurf-hooks.json new file mode 100644 index 0000000..d40f39a --- /dev/null +++ b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/windsurf-hooks.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "pre_run_command": [ + { + "command": "@CHOCK_PYTHON@ \".chock/bin/windsurf.py\" --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" + } + ], + "pre_mcp_tool_use": [ + { + "command": "@CHOCK_PYTHON@ \".chock/bin/windsurf.py\" --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"" + } + ] + } +} \ No newline at end of file diff --git a/tests/fixtures/runtime_goldens/antigravity.py b/tests/fixtures/runtime_goldens/antigravity.py new file mode 100644 index 0000000..e08fd43 --- /dev/null +++ b/tests/fixtures/runtime_goldens/antigravity.py @@ -0,0 +1,763 @@ +# Generated by agentseam 0.2.0 -- 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. +# +# Self-contained: stdlib only, no "import agentseam" anywhere in this file. Regenerate +# with `agentseam.bundler.bundle("antigravity")` (same agentseam version -> identical bytes, +# except your own edits inside the handler block) rather than patching this by hand. +# https://github.com/open-coder-ai/agentseam + +from __future__ import annotations + +import json +_json = json +import sys + +import os as _chock_os +import shlex as _chock_shlex +import subprocess as _chock_subprocess +from datetime import datetime as _chock_datetime, timezone as _chock_timezone +from pathlib import Path as _chock_Path +import warnings as _warnings + +# ------------------------------------------------------------------------------ +# contract (agentseam 0.2.0) + +"""Canonical event vocabulary, normalized envelope, and decision type.""" + + + +SESSION_START = "session_start" +SESSION_END = "session_end" +PROMPT_SUBMIT = "prompt_submit" +PRE_TOOL = "pre_tool" +POST_TOOL = "post_tool" +TOOL_FAILURE = "tool_failure" +PRE_COMPACT = "pre_compact" +STOP = "stop" +SUBAGENT_START = "subagent_start" +SUBAGENT_STOP = "subagent_stop" +INSTRUCTIONS_LOADED = "instructions_loaded" +FILE_CHANGED = "file_changed" + +UNKNOWN = "unknown" + +EVENTS = ( + SESSION_START, + SESSION_END, + PROMPT_SUBMIT, + PRE_TOOL, + POST_TOOL, + TOOL_FAILURE, + PRE_COMPACT, + STOP, + SUBAGENT_START, + SUBAGENT_STOP, + INSTRUCTIONS_LOADED, + FILE_CHANGED, +) + + +class Event: + """One agent lifecycle event, normalized.""" + + __slots__ = ( + "agent", + "event", + "tool", + "command", + "path", + "content", + "output", + "prompt", + "session_id", + "tool_use_id", + "cwd", + "raw", + ) + + def __init__( + self, + agent, + event, + *, + tool=None, + command=None, + path=None, + content=None, + output=None, + prompt=None, + session_id=None, + tool_use_id=None, + cwd=None, + raw=None, + ): + self.agent = agent + self.event = event + self.tool = tool + self.command = command + self.path = path + self.content = content + self.output = output + self.prompt = prompt + self.session_id = session_id + self.tool_use_id = tool_use_id + self.cwd = cwd + self.raw = raw if raw is not None else {} + + def __repr__(self): # pragma: no cover - debugging aid + return "Event(%s/%s tool=%r path=%r)" % (self.agent, self.event, self.tool, self.path) + + +ALLOW = "allow" +DENY = "deny" +ESCALATE = "escalate" +TRANSFORM = "transform" +WARN = "warn" +VOUCH = "vouch" + +# Pre-ACS-alignment names. Same strings as their ACS-named counterparts, so every existing +# `is`/`==` comparison against the old constant keeps working untouched. +ASK = ESCALATE +REWRITE = TRANSFORM + +_CANONICAL_OUTCOMES = (ALLOW, DENY, ESCALATE, TRANSFORM, WARN, VOUCH) + +# The literal spellings a caller might still pass to Decision(outcome, ...) directly, mapped +# to the value that now backs them. Only needed for the raw-string constructor path -- +# Decision.ask()/.rewrite() below build the canonical outcome themselves. +_LEGACY_SPELLING = {"ask": ESCALATE, "rewrite": TRANSFORM} + + +class Decision: + """What a handler wants to happen. Adapters translate this to vendor dialect.""" + + __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + + #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). + DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) + + def __init__(self, outcome, reason=None, updated_input=None, evidence=None, context=None): + outcome = _LEGACY_SPELLING.get(outcome, outcome) + if outcome not in _CANONICAL_OUTCOMES: + raise ValueError("unknown outcome: %r" % (outcome,)) + self.outcome = outcome + self.reason = reason + self.updated_input = updated_input + self.evidence = evidence or {} + self.context = context + + @classmethod + def allow(cls, reason=None, evidence=None, context=None): + return cls(ALLOW, reason, evidence=evidence, context=context) + + @classmethod + def deny(cls, reason, evidence=None, context=None): + return cls(DENY, reason, evidence=evidence, context=context) + + @classmethod + def escalate(cls, reason, evidence=None, context=None): + """Defer the action to the host's own approval path (ACS `escalate`).""" + return cls(ESCALATE, reason, evidence=evidence, context=context) + + @classmethod + def ask(cls, reason, evidence=None, context=None): + """Deprecated alias of escalate() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.ask() is deprecated; use Decision.escalate()", DeprecationWarning, stacklevel=2) + return cls.escalate(reason, evidence=evidence, context=context) + + @classmethod + def transform(cls, updated_input, reason=None, evidence=None, context=None): + """Replace the tool input wholesale (ACS `transform`, at whole-value granularity).""" + return cls(TRANSFORM, reason, updated_input=updated_input, evidence=evidence, context=context) + + @classmethod + def rewrite(cls, updated_input, reason=None, evidence=None, context=None): + """Deprecated alias of transform() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.rewrite() is deprecated; use Decision.transform()", DeprecationWarning, stacklevel=2) + return cls.transform(updated_input, reason, evidence=evidence, context=context) + + @classmethod + def warn(cls, reason=None, evidence=None, context=None): + """Permit the action with no change, recording a warning (ACS `warn`).""" + return cls(WARN, reason, evidence=evidence, context=context) + + @classmethod + def vouch(cls, reason=None, evidence=None, context=None): + return cls(VOUCH, reason, evidence=evidence, context=context) + + def __repr__(self): # pragma: no cover - debugging aid + return "Decision(%s, %r)" % (self.outcome, self.reason) + + +def degraded_from(decision): + """What this decision was before the dispatcher reduced it, or None.""" + return (decision.evidence or {}).get("degraded_from") + + +def tool_input_of(raw): + """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" + if isinstance(raw, dict): + return raw + if isinstance(raw, str) and raw[:1] == "{": + try: + parsed = _json.loads(raw) + except Exception: + return {} + if isinstance(parsed, dict): + return parsed + return {} + + +# ------------------------------------------------------------------------------ +# antigravity family engine (trimmed to what this entry uses) + +def _wire_name(cfg, raw): + for key in cfg["claims"].get("event_key", ()): + name = raw.get(key) + if name is not None: + return name + return None + +def _segment(node, part): + """One path segment: a dict key, or `key[N]` indexing the list under it.""" + if part.endswith("]") and "[" in part: + key, _, index = part[:-1].partition("[") + items = node.get(key) if isinstance(node, dict) else None + i = int(index) + return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None + return node.get(part) if isinstance(node, dict) else None + +def _walk(node, path): + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + if "[]." in path: + head, sub = path.split("[].", 1) + items = _walk(node, head) + if not isinstance(items, (list, tuple)): + return None + joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) + return joined or None + for part in path.split("."): + node = _segment(node, part) + if node is None: + return None + return node + +def _lookup(raw, ti, key): + """One config key: a `tool_input.` path walks the decoded tool input, else the payload.""" + if key.startswith("tool_input."): + return _walk(ti, key[len("tool_input.") :]) + return _walk(raw, key) + +def _field(raw, ti, chain): + value = None + for key in chain: + if value: + break + value = value or _lookup(raw, ti, key) + return value + +_FIELD_META = ("tool_input", "content_only_for_write_tools", "stringify") + +def _tool_input_raw(cfg, raw): + for key in cfg["fields"].get("tool_input", ("tool_input",)): + value = raw.get(key) + if value is not None: + return value + return None + +def _canonical_of(cfg, name): + """The canonical event for one wire name; an entry with no `events` at all (antigravity, + whose payloads never carry one) maps the shape-inferred name back through `wire_events`.""" + events = cfg["events"] + if not events: + return {wire: canonical for canonical, wire in cfg.get("wire_events", {}).items()}.get(name, UNKNOWN) + return events.get(name, UNKNOWN) + +def hj_parse(cfg, raw, wire=None): + """Normalise one payload along the entry's ordered field-fallback chains. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + ti = tool_input_of(_tool_input_raw(cfg, raw)) + fields = {name: _field(raw, ti, chain) for name, chain in cfg["fields"].items() if name not in _FIELD_META} + if cfg["fields"].get("content_only_for_write_tools") and fields.get("tool") not in cfg["tools"].get("write", ()): + fields["content"] = None + if isinstance(fields.get("output"), (dict, list)): + fields["output"] = _json.dumps(fields["output"]) + for name in cfg["fields"].get("stringify", ()): + if fields.get(name) is not None: + fields[name] = str(fields[name]) + return Event( + cfg["agent"], + _canonical_of(cfg, wire if wire is not None else _wire_name(cfg, raw)), + tool=fields.get("tool"), + command=fields.get("command"), + path=fields.get("path"), + content=fields.get("content"), + output=fields.get("output"), + prompt=fields.get("prompt"), + session_id=fields.get("session_id"), + tool_use_id=fields.get("tool_use_id"), + cwd=fields.get("cwd"), + raw=raw, + ) + +def hj_reverse(cfg): + """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" + reverse = {} + for name, canonical in cfg["events"].items(): + if canonical != UNKNOWN: + reverse[canonical] = name + reverse.update(cfg.get("wire_events", {})) + return reverse + +def _context_value(v, decision): + if v.get("context_source") == "context": + return decision.context + if v.get("context_source") == "reason": + return decision.reason + return None + +def _context_body(name, value): + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "additionalContext": value}}), 0 + +def _note_for(v, decision, at_gate, missing_input): + notes = v.get("degrade_notes", {}) + if decision.outcome == ESCALATE: + if degraded_from(decision) == TRANSFORM and "escalate_from_transform" in notes: + return notes["escalate_from_transform"] + if at_gate and "escalate_gate" in notes: + return notes["escalate_gate"] + return notes.get("escalate") + if decision.outcome == TRANSFORM: + if missing_input and "transform_missing_input" in notes: + return notes["transform_missing_input"] + return notes.get("transform") + return None + +def _default_for(v, decision, at_gate, wire=None): + gate_defaults = v.get("gate_reason_defaults", {}) + if wire in gate_defaults: + return gate_defaults[wire] + defaults = v.get("reason_defaults", {}) + key = {DENY: "deny", ESCALATE: "escalate", TRANSFORM: "transform"}.get(decision.outcome, "deny") + if at_gate and key + "_gate" in defaults: + return defaults[key + "_gate"] + return defaults.get(key, "blocked by policy") + +def _refusal_text(v, decision, at_gate, wire=None): + note = _note_for(v, decision, at_gate, decision.updated_input is None) + default = _default_for(v, decision, at_gate, wire) + if note and "%s" in note: + # A template note fills (the reason or its default, the wire event name) itself. + return note % (decision.reason or default, wire) + reason = decision.reason + if v.get("note_style") == "suffix": + reason = reason or default + return "%s (%s)" % (reason, note) if note else reason + text = "%s (%s)" % (reason, note) if reason and note else (note or reason) + return text or default + +def _g1(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 + 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 + if ( + decision.outcome == ESCALATE + and gate["honours_escalate"] + and "escalate" in words + # An escalate the dispatcher degraded a transform into is a block where the entry + # names that degradation (antigravity): prompting would offer the unmodified call. + and not (degraded_from(decision) == TRANSFORM and "escalate_from_transform" in v.get("degrade_notes", {})) + ): + reason = decision.reason or _default_for(v, decision, True, wire) + return _json.dumps({"decision": words["escalate"], "reason": reason}), 0 + out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, False, wire)} + if at_context_event and v.get("context_source") == "context" and decision.context: + out["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": decision.context} + return _json.dumps(out), 0 + +def hj_respond(cfg, decision, event, wire=None): + """(stdout_text, exit_code) in this entry's dialect for the gate the payload names. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + v = cfg["verdicts"] + if wire is None: + wire = _wire_name(cfg, event.raw or {}) + if wire in v.get("empty_object_events", ()): + return _json.dumps({}), 0 + if wire is None: + wire = v.get("default_wire_event") + if wire is None and v.get("missing_wire") == "reverse_map": + wire = hj_reverse(cfg).get(event.event) + name = wire if v.get("echo") == "payload" else hj_reverse(cfg).get(event.event, "PreToolUse") + gate = v["gates"].get(wire) + if gate is None: + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + return "", 0 + if gate["grammar"] == "G2": + return _g2(v, gate, decision, name) + return _g1(v, gate, decision, wire, name) + +_WINDOWS_KEYS = ("commandWindows", "windows") + +def _hook_dict(cfg, command): + entry = {"type": "command", "command": command} + for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): + if key in _WINDOWS_KEYS: + entry[key] = powershell_command(command) + else: + entry[key] = value + return entry + +def 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": + 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) + if not name: + continue + entry = {"hooks": [_hook_dict(cfg, command)]} + if matcher and hook_entry["matcher"]: + entry["matcher"] = matcher + hooks.setdefault(name, []).append(entry) + if hook_entry.get("group"): + return {hook_entry["group"]: hooks} + return hooks if hook_entry.get("bare") else {"hooks": hooks} + +def 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: + return "Stop" + if isinstance(raw.get("toolCall"), dict): + return "PostToolUse" if "error" in raw else "PreToolUse" + return None + +def antigravity_claims(cfg, raw): + """Structural: `conversationId` with `workspacePaths` is Antigravity's own envelope.""" + if not isinstance(raw, dict): + return False + return "conversationId" in raw and isinstance(raw.get("workspacePaths"), list) + +def antigravity_parse(cfg, raw): + return hj_parse(cfg, raw, wire=antigravity_wire(raw)) + +def antigravity_respond(cfg, decision, event): + return hj_respond(cfg, decision, event, wire=antigravity_wire(event.raw or {})) + + +# ------------------------------------------------------------------------------ +# antigravity vendor config + engine binding + +AGENT = "antigravity" + +VENDOR = {'agent': 'antigravity', 'claims': {'mode': 'shape_inferred'}, 'config_format': 'json', 'config_path': '.agents/hooks.json', 'display': 'Antigravity', 'events': {}, 'evidence': {'claims': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'antigravity', 'fields': {'command': ('toolCall.args.CommandLine',), 'content': ('toolCall.args.CodeContent', 'toolCall.args.ReplacementContent', 'toolCall.args.ReplacementChunks[].ReplacementContent'), 'cwd': ('toolCall.args.Cwd', 'workspacePaths[0]'), 'output': ('error',), 'path': ('toolCall.args.TargetFile', 'toolCall.args.AbsolutePath'), 'session_id': ('conversationId',), 'stringify': ('tool_use_id',), 'tool': ('toolCall.name',), 'tool_use_id': ('stepIdx',)}, 'hook_entry': {'group': 'agentseam', 'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'tools': {}, 'verdicts': {'answer_events': ('PreToolUse', 'Stop'), 'bare_allow': 'unverified', 'default_wire_event': 'PreToolUse', 'degrade_notes': {'escalate': 'Antigravity cannot prompt at Stop', 'escalate_from_transform': 'Antigravity cannot modify a tool call', 'transform': 'Antigravity cannot modify a tool call'}, 'empty_object_events': ('PostToolUse',), 'gate_reason_defaults': {'Stop': 'policy requires more work'}, 'gates': {'PreToolUse': {'grammar': 'G1', 'honours_escalate': True, 'honours_transform': False}, 'Stop': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'because', 'reason_defaults': {'escalate': 'confirmation required'}, 'vocabulary': ('allow', 'ask', 'continue', 'deny', 'deny_unless_prior_grant', 'force_ask', 'stop'), 'vocabulary_basis': 'verified', 'words': {'allow': 'allow', 'block': 'deny', 'escalate': 'ask'}, 'words_at': {'Stop': {'allow': 'stop', 'block': 'continue'}}}, 'wire_events': {'post_tool': 'PostToolUse', 'pre_tool': 'PreToolUse', 'stop': 'Stop'}} + + +def claims(raw): + return antigravity_claims(VENDOR, raw) + + +def parse(raw): + return antigravity_parse(VENDOR, raw) + + +def respond(decision, event): + return antigravity_respond(VENDOR, decision, event) + + +def hook_config(canonical_events, command, matcher=None): + return hook_entry_config(VENDOR, canonical_events, command, matcher) + + +# ------------------------------------------------------------------------------ +# runtime (agentseam dispatch, specialized to antigravity) + +_VOUCH_SPEAKS = False +_WARN_SPEAKS = False +_TRANSFORM_EVENTS = frozenset(()) + + +def degrade(decision, event): + """Reduce a decision to what 'antigravity' can actually honor, honestly. + + This is agentseam.dispatch.degrade(), specialized to one fixed agent so this file needs + no "import agentseam" -- no evidence establishes that an explicit approval word means anything beyond a plain allow here, so vouch degrades to one; see agentseam.allow_semantics. + """ + if decision.outcome == TRANSFORM and event.event not in _TRANSFORM_EVENTS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = TRANSFORM + return Decision.escalate( + decision.reason or "input requires modification before it can run", evidence=evidence + ) + if decision.outcome == VOUCH and not _VOUCH_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = VOUCH + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + if decision.outcome == WARN and not _WARN_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = WARN + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + return decision + + +# >>> agentseam handler >>> +GUARD_VIOLATION = 1 + +_BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') + +GATE_LOG_ENV = 'CHOCK_GATE_LOG' + +_LOG_MAX_BYTES = 1048576 + +_GUARD_TIMEOUT_SECONDS = 30 + +GUARD_BLOCKED = 'blocked' + +GUARD_CLEAN = 'clean' + +GUARD_UNCHECKED = 'unchecked' + +GUARD_ERRORED = 'errored' + +VERDICT_DENY = 'deny' + +VERDICT_ESCALATE = 'escalate' + +def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: + """The `--guard ` argument a vendored runtime was invoked with, or None.""" + if '--guard' in argv: + i = argv.index('--guard') + if i + 1 < len(argv): + return _chock_Path(argv[i + 1]) + return None + +def find_bash(guard: _chock_Path) -> str | None: + """First interpreter that can actually see `guard`, or None.""" + for candidate in _BASH_CANDIDATES: + try: + proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10) + except (OSError, _chock_subprocess.SubprocessError): + continue + if proc.returncode == 0: + return candidate + return None + +def run_guard(guard: _chock_Path, command: str) -> str: + """`GUARD_BLOCKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + try: + args = _chock_shlex.split(command) + except ValueError: + print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) + return GUARD_UNCHECKED + if not args: + return GUARD_UNCHECKED + bash = find_bash(guard) + if bash is None: + print(f'chock: no usable bash found, {guard.name} not checked', file=sys.stderr) + return GUARD_UNCHECKED + try: + env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} + proc = _chock_subprocess.run([bash, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS) + except _chock_subprocess.TimeoutExpired: + print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) + return GUARD_ERRORED + except (OSError, UnicodeError) as exc: + print(f'chock: guard could not run, not checked: {exc}', file=sys.stderr) + return GUARD_ERRORED + if proc.returncode == GUARD_VIOLATION: + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + if not ((proc.stdout or '') + (proc.stderr or '')).strip(): + print(f'chock: blocked by {_chock_Path(guard).name} (guard gave no reason)', file=sys.stderr) + return GUARD_BLOCKED + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout or '').strip().splitlines() + print(f'chock: guard exited {proc.returncode}, not checked' + (f': {detail[0][:120]}' if detail else ''), file=sys.stderr) + return GUARD_ERRORED + return GUARD_CLEAN + +def log_outcome(guard: _chock_Path, tool: str, blocked: bool) -> None: + """Append one outcome record. Best effort: never raises, never changes the verdict.""" + try: + if _chock_os.environ.get(GATE_LOG_ENV) == '0': + return + guard = guard.resolve() + if guard.parent.name != 'implementations': + return + artifact_root = None + for parent in guard.parents: + if (parent / '.chock').is_dir(): + artifact_root = parent / '.chock' + break + if artifact_root is None: + return + log_dir = artifact_root / 'log' + log_dir.mkdir(parents=True, exist_ok=True) + log_path = log_dir / 'gate-events.jsonl' + if log_path.exists() and log_path.stat().st_size > _LOG_MAX_BYTES: + log_path.replace(log_dir / 'gate-events.1.jsonl') + import json + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': 'block' if blocked else 'allow'} + with log_path.open('a', encoding='utf-8') as fh: + fh.write(json.dumps(record, ensure_ascii=False) + '\n') + except Exception: + return + +def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: + """Run the guard named on `argv` (`--guard `) against `command`.""" + guard = guard_path_from_argv(argv) + if guard is None or not guard.exists(): + return None + verdict = run_guard(guard, command) + if verdict in (GUARD_BLOCKED, GUARD_CLEAN): + log_outcome(guard, tool, verdict == GUARD_BLOCKED) + if verdict == GUARD_BLOCKED: + return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + if verdict == GUARD_ERRORED: + return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") + return None + + +def handle(event): + if event.event == "pre_tool" and event.command: + verdict = evaluate(sys.argv[1:], event.command, event.tool or "") + if verdict is not None: + outcome, reason = verdict + return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + return None +# <<< agentseam handler <<< + + +def _coerce(result): + if result is None: + return Decision.allow() + if isinstance(result, Decision): + return result + raise TypeError("handle() must return a Decision or None, got %r" % (type(result),)) + + +def _read_payload(stream): + # Decode bytes ourselves rather than trust the platform locale -- a BOM'd or non-UTF-8 + # stdin must not silently disable the gate. See agentseam.dispatch for the incident + # this guards: a Windows console's cp1252 layer turning a UTF-8 BOM into three bytes + # json cannot parse, with the hook allowing everything while claiming enforcement. + buffer = getattr(stream, "buffer", None) + if buffer is not None: + return buffer.read().decode("utf-8-sig", errors="replace") + return stream.read().lstrip("\ufeff") + + +def _emit(out, text): + buffer = getattr(out, "buffer", None) + if buffer is not None: + buffer.write(text.encode("utf-8")) + buffer.flush() + else: + out.write(text) + out.flush() + + +def main(stdin=None, stdout=None, exit=True): + """Read one payload from stdin, dispatch, emit the 'antigravity' response, exit.""" + stream = stdin if stdin is not None else sys.stdin + out = stdout if stdout is not None else sys.stdout + try: + raw = json.loads(_read_payload(stream)) + except Exception: + # Malformed input is not the agent's fault to pay for: allow, stay silent. + if exit: + sys.exit(0) + return 0 + event = parse(raw) + if event.event == UNKNOWN: + # A vendor event this adapter has no mapping for. handle() is not called: it + # reasons about the canonical vocabulary, and handing it something outside that + # vocabulary invites a decision made on a false premise. + if exit: + sys.exit(0) + return 0 + decision = degrade(_coerce(handle(event)), event) + text, code = respond(decision, event) + if text: + _emit(out, text) + if exit: + sys.exit(code) + return code + + +if __name__ == "__main__": # pragma: no cover + main() + diff --git a/tests/fixtures/runtime_goldens/claude_code.py b/tests/fixtures/runtime_goldens/claude_code.py new file mode 100644 index 0000000..5211c41 --- /dev/null +++ b/tests/fixtures/runtime_goldens/claude_code.py @@ -0,0 +1,884 @@ +# Generated by agentseam 0.2.0 -- 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. +# +# Self-contained: stdlib only, no "import agentseam" anywhere in this file. Regenerate +# with `agentseam.bundler.bundle("claude_code")` (same agentseam version -> identical bytes, +# except your own edits inside the handler block) rather than patching this by hand. +# https://github.com/open-coder-ai/agentseam + +from __future__ import annotations + +import json +_json = json +import sys + +import os as _chock_os +import shlex as _chock_shlex +import subprocess as _chock_subprocess +from datetime import datetime as _chock_datetime, timezone as _chock_timezone +from pathlib import Path as _chock_Path +import warnings as _warnings + +# ------------------------------------------------------------------------------ +# contract (agentseam 0.2.0) + +"""Canonical event vocabulary, normalized envelope, and decision type.""" + + + +SESSION_START = "session_start" +SESSION_END = "session_end" +PROMPT_SUBMIT = "prompt_submit" +PRE_TOOL = "pre_tool" +POST_TOOL = "post_tool" +TOOL_FAILURE = "tool_failure" +PRE_COMPACT = "pre_compact" +STOP = "stop" +SUBAGENT_START = "subagent_start" +SUBAGENT_STOP = "subagent_stop" +INSTRUCTIONS_LOADED = "instructions_loaded" +FILE_CHANGED = "file_changed" + +UNKNOWN = "unknown" + +EVENTS = ( + SESSION_START, + SESSION_END, + PROMPT_SUBMIT, + PRE_TOOL, + POST_TOOL, + TOOL_FAILURE, + PRE_COMPACT, + STOP, + SUBAGENT_START, + SUBAGENT_STOP, + INSTRUCTIONS_LOADED, + FILE_CHANGED, +) + + +class Event: + """One agent lifecycle event, normalized.""" + + __slots__ = ( + "agent", + "event", + "tool", + "command", + "path", + "content", + "output", + "prompt", + "session_id", + "tool_use_id", + "cwd", + "raw", + ) + + def __init__( + self, + agent, + event, + *, + tool=None, + command=None, + path=None, + content=None, + output=None, + prompt=None, + session_id=None, + tool_use_id=None, + cwd=None, + raw=None, + ): + self.agent = agent + self.event = event + self.tool = tool + self.command = command + self.path = path + self.content = content + self.output = output + self.prompt = prompt + self.session_id = session_id + self.tool_use_id = tool_use_id + self.cwd = cwd + self.raw = raw if raw is not None else {} + + def __repr__(self): # pragma: no cover - debugging aid + return "Event(%s/%s tool=%r path=%r)" % (self.agent, self.event, self.tool, self.path) + + +ALLOW = "allow" +DENY = "deny" +ESCALATE = "escalate" +TRANSFORM = "transform" +WARN = "warn" +VOUCH = "vouch" + +# Pre-ACS-alignment names. Same strings as their ACS-named counterparts, so every existing +# `is`/`==` comparison against the old constant keeps working untouched. +ASK = ESCALATE +REWRITE = TRANSFORM + +_CANONICAL_OUTCOMES = (ALLOW, DENY, ESCALATE, TRANSFORM, WARN, VOUCH) + +# The literal spellings a caller might still pass to Decision(outcome, ...) directly, mapped +# to the value that now backs them. Only needed for the raw-string constructor path -- +# Decision.ask()/.rewrite() below build the canonical outcome themselves. +_LEGACY_SPELLING = {"ask": ESCALATE, "rewrite": TRANSFORM} + + +class Decision: + """What a handler wants to happen. Adapters translate this to vendor dialect.""" + + __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + + #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). + DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) + + def __init__(self, outcome, reason=None, updated_input=None, evidence=None, context=None): + outcome = _LEGACY_SPELLING.get(outcome, outcome) + if outcome not in _CANONICAL_OUTCOMES: + raise ValueError("unknown outcome: %r" % (outcome,)) + self.outcome = outcome + self.reason = reason + self.updated_input = updated_input + self.evidence = evidence or {} + self.context = context + + @classmethod + def allow(cls, reason=None, evidence=None, context=None): + return cls(ALLOW, reason, evidence=evidence, context=context) + + @classmethod + def deny(cls, reason, evidence=None, context=None): + return cls(DENY, reason, evidence=evidence, context=context) + + @classmethod + def escalate(cls, reason, evidence=None, context=None): + """Defer the action to the host's own approval path (ACS `escalate`).""" + return cls(ESCALATE, reason, evidence=evidence, context=context) + + @classmethod + def ask(cls, reason, evidence=None, context=None): + """Deprecated alias of escalate() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.ask() is deprecated; use Decision.escalate()", DeprecationWarning, stacklevel=2) + return cls.escalate(reason, evidence=evidence, context=context) + + @classmethod + def transform(cls, updated_input, reason=None, evidence=None, context=None): + """Replace the tool input wholesale (ACS `transform`, at whole-value granularity).""" + return cls(TRANSFORM, reason, updated_input=updated_input, evidence=evidence, context=context) + + @classmethod + def rewrite(cls, updated_input, reason=None, evidence=None, context=None): + """Deprecated alias of transform() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.rewrite() is deprecated; use Decision.transform()", DeprecationWarning, stacklevel=2) + return cls.transform(updated_input, reason, evidence=evidence, context=context) + + @classmethod + def warn(cls, reason=None, evidence=None, context=None): + """Permit the action with no change, recording a warning (ACS `warn`).""" + return cls(WARN, reason, evidence=evidence, context=context) + + @classmethod + def vouch(cls, reason=None, evidence=None, context=None): + return cls(VOUCH, reason, evidence=evidence, context=context) + + def __repr__(self): # pragma: no cover - debugging aid + return "Decision(%s, %r)" % (self.outcome, self.reason) + + +def degraded_from(decision): + """What this decision was before the dispatcher reduced it, or None.""" + return (decision.evidence or {}).get("degraded_from") + + +def tool_input_of(raw): + """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" + if isinstance(raw, dict): + return raw + if isinstance(raw, str) and raw[:1] == "{": + try: + parsed = _json.loads(raw) + except Exception: + return {} + if isinstance(parsed, dict): + return parsed + return {} + + +# ------------------------------------------------------------------------------ +# hook_json family engine (trimmed to what this entry uses) + +OBSERVED_MARKERS = ( + "transcript_path", + "permission_mode", + "stop_hook_active", + "agent_transcript_path", + "background_tasks", + "session_crons", + "custom_instructions", + "effort", +) + +def looks_like_claude_code(raw): + """True when the payload carries a field only Claude Code has been seen to send.""" + return isinstance(raw, dict) and any(marker in raw for marker in OBSERVED_MARKERS) + +PROBES = {"looks_like_claude_code": looks_like_claude_code} + +def _wire_name(cfg, raw): + for key in cfg["claims"].get("event_key", ()): + name = raw.get(key) + if name is not None: + return name + return None + +def hj_claims(cfg, raw): + """True when this payload matches the entry's marker discipline.""" + if not isinstance(raw, dict): + return False + c = cfg["claims"] + name = _wire_name(cfg, raw) + if name in c.get("accept_names", ()): + return True + if name not in cfg["events"]: + 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 + return False + return True + +def _segment(node, part): + """One path segment: a dict key, or `key[N]` indexing the list under it.""" + if part.endswith("]") and "[" in part: + key, _, index = part[:-1].partition("[") + items = node.get(key) if isinstance(node, dict) else None + i = int(index) + return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None + return node.get(part) if isinstance(node, dict) else None + +def _walk(node, path): + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + if "[]." in path: + head, sub = path.split("[].", 1) + items = _walk(node, head) + if not isinstance(items, (list, tuple)): + return None + joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) + return joined or None + for part in path.split("."): + node = _segment(node, part) + if node is None: + return None + return node + +def _lookup(raw, ti, key): + """One config key: a `tool_input.` path walks the decoded tool input, else the payload.""" + if key.startswith("tool_input."): + return _walk(ti, key[len("tool_input.") :]) + return _walk(raw, key) + +def _field(raw, ti, chain): + value = None + for key in chain: + if value: + break + value = value or _lookup(raw, ti, key) + return value + +_FIELD_META = ("tool_input", "content_only_for_write_tools", "stringify") + +def _tool_input_raw(cfg, raw): + for key in cfg["fields"].get("tool_input", ("tool_input",)): + value = raw.get(key) + if value is not None: + return value + return None + +def _canonical_of(cfg, name): + """The canonical event for one wire name; an entry with no `events` at all (antigravity, + whose payloads never carry one) maps the shape-inferred name back through `wire_events`.""" + events = cfg["events"] + if not events: + return {wire: canonical for canonical, wire in cfg.get("wire_events", {}).items()}.get(name, UNKNOWN) + return events.get(name, UNKNOWN) + +def hj_parse(cfg, raw, wire=None): + """Normalise one payload along the entry's ordered field-fallback chains. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + ti = tool_input_of(_tool_input_raw(cfg, raw)) + fields = {name: _field(raw, ti, chain) for name, chain in cfg["fields"].items() if name not in _FIELD_META} + if cfg["fields"].get("content_only_for_write_tools") and fields.get("tool") not in cfg["tools"].get("write", ()): + fields["content"] = None + if isinstance(fields.get("output"), (dict, list)): + fields["output"] = _json.dumps(fields["output"]) + for name in cfg["fields"].get("stringify", ()): + if fields.get(name) is not None: + fields[name] = str(fields[name]) + return Event( + cfg["agent"], + _canonical_of(cfg, wire if wire is not None else _wire_name(cfg, raw)), + tool=fields.get("tool"), + command=fields.get("command"), + path=fields.get("path"), + content=fields.get("content"), + output=fields.get("output"), + prompt=fields.get("prompt"), + session_id=fields.get("session_id"), + tool_use_id=fields.get("tool_use_id"), + cwd=fields.get("cwd"), + raw=raw, + ) + +def hj_reverse(cfg): + """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" + reverse = {} + for name, canonical in cfg["events"].items(): + if canonical != UNKNOWN: + reverse[canonical] = name + reverse.update(cfg.get("wire_events", {})) + return reverse + +def _context_value(v, decision): + if v.get("context_source") == "context": + return decision.context + if v.get("context_source") == "reason": + return decision.reason + return None + +def _context_body(name, value): + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "additionalContext": value}}), 0 + +def _note_for(v, decision, at_gate, missing_input): + notes = v.get("degrade_notes", {}) + if decision.outcome == ESCALATE: + if degraded_from(decision) == TRANSFORM and "escalate_from_transform" in notes: + return notes["escalate_from_transform"] + if at_gate and "escalate_gate" in notes: + return notes["escalate_gate"] + return notes.get("escalate") + if decision.outcome == TRANSFORM: + if missing_input and "transform_missing_input" in notes: + return notes["transform_missing_input"] + return notes.get("transform") + return None + +def _default_for(v, decision, at_gate, wire=None): + gate_defaults = v.get("gate_reason_defaults", {}) + if wire in gate_defaults: + return gate_defaults[wire] + defaults = v.get("reason_defaults", {}) + key = {DENY: "deny", ESCALATE: "escalate", TRANSFORM: "transform"}.get(decision.outcome, "deny") + if at_gate and key + "_gate" in defaults: + return defaults[key + "_gate"] + return defaults.get(key, "blocked by policy") + +def _refusal_text(v, decision, at_gate, wire=None): + note = _note_for(v, decision, at_gate, decision.updated_input is None) + default = _default_for(v, decision, at_gate, wire) + if note and "%s" in note: + # A template note fills (the reason or its default, the wire event name) itself. + return note % (decision.reason or default, wire) + reason = decision.reason + if v.get("note_style") == "suffix": + reason = reason or default + return "%s (%s)" % (reason, note) if note else reason + text = "%s (%s)" % (reason, note) if reason and note else (note or reason) + return text or default + +def _g1(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 + 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 + if ( + decision.outcome == ESCALATE + and gate["honours_escalate"] + and "escalate" in words + # An escalate the dispatcher degraded a transform into is a block where the entry + # names that degradation (antigravity): prompting would offer the unmodified call. + and not (degraded_from(decision) == TRANSFORM and "escalate_from_transform" in v.get("degrade_notes", {})) + ): + reason = decision.reason or _default_for(v, decision, True, wire) + return _json.dumps({"decision": words["escalate"], "reason": reason}), 0 + out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, False, wire)} + if at_context_event and v.get("context_source") == "context" and decision.context: + out["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": decision.context} + return _json.dumps(out), 0 + +def _g2(v, gate, decision, name): + """Permission gate: `hookSpecificOutput.permissionDecision`, filled from the word table.""" + if decision.outcome in (ALLOW, WARN): + return "", 0 + words = v.get("words", {}) + out = {"hookEventName": name} + if decision.outcome == VOUCH: + if "vouch" not in words: + return "", 0 + out["permissionDecision"] = words["vouch"] + if decision.reason: + out["permissionDecisionReason"] = decision.reason + elif ( + decision.outcome == TRANSFORM + and gate["honours_transform"] + and not (decision.updated_input is None and "transform_missing_input" in v.get("degrade_notes", {})) + ): + out["permissionDecision"] = words.get("transform", "allow") + out["updatedInput"] = decision.updated_input + if decision.reason: + out["permissionDecisionReason"] = 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) + else: + out["permissionDecision"] = words.get("deny", "deny") + out["permissionDecisionReason"] = _refusal_text(v, decision, True) + return _json.dumps({"hookSpecificOutput": out}), 0 + +def hj_respond(cfg, decision, event, wire=None): + """(stdout_text, exit_code) in this entry's dialect for the gate the payload names. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + v = cfg["verdicts"] + if wire is None: + wire = _wire_name(cfg, event.raw or {}) + if wire in v.get("empty_object_events", ()): + return _json.dumps({}), 0 + if wire is None: + wire = v.get("default_wire_event") + if wire is None and v.get("missing_wire") == "reverse_map": + wire = hj_reverse(cfg).get(event.event) + name = wire if v.get("echo") == "payload" else hj_reverse(cfg).get(event.event, "PreToolUse") + gate = v["gates"].get(wire) + if gate is None: + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + return "", 0 + if gate["grammar"] == "G2": + return _g2(v, gate, decision, name) + return _g1(v, gate, decision, wire, name) + +_WINDOWS_KEYS = ("commandWindows", "windows") + +def _hook_dict(cfg, command): + entry = {"type": "command", "command": command} + for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): + if key in _WINDOWS_KEYS: + entry[key] = powershell_command(command) + else: + entry[key] = value + return entry + +def 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": + 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) + if not name: + continue + entry = {"hooks": [_hook_dict(cfg, command)]} + if matcher and hook_entry["matcher"]: + entry["matcher"] = matcher + hooks.setdefault(name, []).append(entry) + if hook_entry.get("group"): + return {hook_entry["group"]: hooks} + return hooks if hook_entry.get("bare") else {"hooks": hooks} + + +# ------------------------------------------------------------------------------ +# 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'}}} + + +def claims(raw): + return hj_claims(VENDOR, raw) + + +def parse(raw): + return hj_parse(VENDOR, raw) + + +def respond(decision, event): + return hj_respond(VENDOR, decision, event) + + +def hook_config(canonical_events, command, matcher=None): + return hook_entry_config(VENDOR, canonical_events, command, matcher) + + +# ------------------------------------------------------------------------------ +# runtime (agentseam dispatch, specialized to claude_code) + +_VOUCH_SPEAKS = True +_WARN_SPEAKS = False +_TRANSFORM_EVENTS = frozenset(('pre_tool',)) + + +def degrade(decision, event): + """Reduce a decision to what 'claude_code' can actually honor, honestly. + + This is agentseam.dispatch.degrade(), specialized to one fixed agent so this file needs + no "import agentseam" -- claude_code and vscode_copilot are the only agents with real evidence that an explicit approval word means "skip confirmation"; see agentseam.allow_semantics. + """ + if decision.outcome == TRANSFORM and event.event not in _TRANSFORM_EVENTS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = TRANSFORM + return Decision.escalate( + decision.reason or "input requires modification before it can run", evidence=evidence + ) + if decision.outcome == VOUCH and not _VOUCH_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = VOUCH + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + if decision.outcome == WARN and not _WARN_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = WARN + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + return decision + + +# >>> agentseam handler >>> +GUARD_VIOLATION = 1 + +_BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') + +GATE_LOG_ENV = 'CHOCK_GATE_LOG' + +_LOG_MAX_BYTES = 1048576 + +_GUARD_TIMEOUT_SECONDS = 30 + +GUARD_BLOCKED = 'blocked' + +GUARD_CLEAN = 'clean' + +GUARD_UNCHECKED = 'unchecked' + +GUARD_ERRORED = 'errored' + +VERDICT_DENY = 'deny' + +VERDICT_ESCALATE = 'escalate' + +def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: + """The `--guard ` argument a vendored runtime was invoked with, or None.""" + if '--guard' in argv: + i = argv.index('--guard') + if i + 1 < len(argv): + return _chock_Path(argv[i + 1]) + return None + +def find_bash(guard: _chock_Path) -> str | None: + """First interpreter that can actually see `guard`, or None.""" + for candidate in _BASH_CANDIDATES: + try: + proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10) + except (OSError, _chock_subprocess.SubprocessError): + continue + if proc.returncode == 0: + return candidate + return None + +def run_guard(guard: _chock_Path, command: str) -> str: + """`GUARD_BLOCKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + try: + args = _chock_shlex.split(command) + except ValueError: + print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) + return GUARD_UNCHECKED + if not args: + return GUARD_UNCHECKED + bash = find_bash(guard) + if bash is None: + print(f'chock: no usable bash found, {guard.name} not checked', file=sys.stderr) + return GUARD_UNCHECKED + try: + env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} + proc = _chock_subprocess.run([bash, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS) + except _chock_subprocess.TimeoutExpired: + print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) + return GUARD_ERRORED + except (OSError, UnicodeError) as exc: + print(f'chock: guard could not run, not checked: {exc}', file=sys.stderr) + return GUARD_ERRORED + if proc.returncode == GUARD_VIOLATION: + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + if not ((proc.stdout or '') + (proc.stderr or '')).strip(): + print(f'chock: blocked by {_chock_Path(guard).name} (guard gave no reason)', file=sys.stderr) + return GUARD_BLOCKED + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout or '').strip().splitlines() + print(f'chock: guard exited {proc.returncode}, not checked' + (f': {detail[0][:120]}' if detail else ''), file=sys.stderr) + return GUARD_ERRORED + return GUARD_CLEAN + +def log_outcome(guard: _chock_Path, tool: str, blocked: bool) -> None: + """Append one outcome record. Best effort: never raises, never changes the verdict.""" + try: + if _chock_os.environ.get(GATE_LOG_ENV) == '0': + return + guard = guard.resolve() + if guard.parent.name != 'implementations': + return + artifact_root = None + for parent in guard.parents: + if (parent / '.chock').is_dir(): + artifact_root = parent / '.chock' + break + if artifact_root is None: + return + log_dir = artifact_root / 'log' + log_dir.mkdir(parents=True, exist_ok=True) + log_path = log_dir / 'gate-events.jsonl' + if log_path.exists() and log_path.stat().st_size > _LOG_MAX_BYTES: + log_path.replace(log_dir / 'gate-events.1.jsonl') + import json + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': 'block' if blocked else 'allow'} + with log_path.open('a', encoding='utf-8') as fh: + fh.write(json.dumps(record, ensure_ascii=False) + '\n') + except Exception: + return + +def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: + """Run the guard named on `argv` (`--guard `) against `command`.""" + guard = guard_path_from_argv(argv) + if guard is None or not guard.exists(): + return None + verdict = run_guard(guard, command) + if verdict in (GUARD_BLOCKED, GUARD_CLEAN): + log_outcome(guard, tool, verdict == GUARD_BLOCKED) + if verdict == GUARD_BLOCKED: + return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + if verdict == GUARD_ERRORED: + return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") + return None + +_INSTRUCTION = "Chock: this clone's git hooks are NOT installed -- git never clones hooks, so commit-time gates will not run locally until someone runs:\n pip install chock && chock sync --repo .\nRun that before the first commit. (The repo's CI gate, where wired, enforces regardless.)" + +def _repo_root() -> _chock_Path: + root = _chock_os.environ.get('CLAUDE_PROJECT_DIR') + return _chock_Path(root) if root else _chock_Path.cwd() + +def _hooks_pre_commit(repo_root: _chock_Path) -> _chock_Path | None: + """The active pre-commit hook path, honouring core.hooksPath. None when git is absent.""" + try: + proc = _chock_subprocess.run(['git', 'rev-parse', '--git-path', 'hooks'], cwd=repo_root, capture_output=True, text=True, timeout=15) + except (OSError, _chock_subprocess.TimeoutExpired): + return None + if proc.returncode != 0: + return None + hooks = _chock_Path(proc.stdout.strip()) + if not hooks.is_absolute(): + hooks = repo_root / hooks + return hooks / 'pre-commit' + +def _armed(repo_root: _chock_Path) -> bool: + pre_commit = _hooks_pre_commit(repo_root) + if pre_commit is None: + return True + try: + return pre_commit.exists() and 'chock' in pre_commit.read_text(encoding='utf-8', errors='replace').lower() + except OSError: + return False + +def _chock_importable() -> bool: + import importlib.util + try: + return importlib.util.find_spec('chock') is not None + except (ImportError, ValueError): + return False + + +def _chock_handle_session_start(event): + repo_root = _repo_root() + if not (repo_root / ".chock").is_dir(): + return None # not a chock-managed repo + if _armed(repo_root): + return None + + if _chock_importable(): + try: + proc = _chock_subprocess.run( + [sys.executable, "-m", "chock", "sync", "--repo", str(repo_root)], + cwd=repo_root, + capture_output=True, + text=True, + timeout=240, + ) + except (OSError, _chock_subprocess.TimeoutExpired): + proc = None + if proc is not None and proc.returncode == 0 and _armed(repo_root): + return Decision.allow( + context=( + "Chock: this clone's git hooks were not installed (git never clones hooks); " + "armed them now with `chock sync`." + ) + ) + + return Decision.allow(context=_INSTRUCTION) + + +def handle(event): + if event.event == "pre_tool" and event.command: + verdict = evaluate(sys.argv[1:], event.command, event.tool or "") + if verdict is not None: + outcome, reason = verdict + return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + if event.event == "session_start": + return _chock_handle_session_start(event) + return None +# <<< agentseam handler <<< + + +def _coerce(result): + if result is None: + return Decision.allow() + if isinstance(result, Decision): + return result + raise TypeError("handle() must return a Decision or None, got %r" % (type(result),)) + + +def _read_payload(stream): + # Decode bytes ourselves rather than trust the platform locale -- a BOM'd or non-UTF-8 + # stdin must not silently disable the gate. See agentseam.dispatch for the incident + # this guards: a Windows console's cp1252 layer turning a UTF-8 BOM into three bytes + # json cannot parse, with the hook allowing everything while claiming enforcement. + buffer = getattr(stream, "buffer", None) + if buffer is not None: + return buffer.read().decode("utf-8-sig", errors="replace") + return stream.read().lstrip("\ufeff") + + +def _emit(out, text): + buffer = getattr(out, "buffer", None) + if buffer is not None: + buffer.write(text.encode("utf-8")) + buffer.flush() + else: + out.write(text) + out.flush() + + +def main(stdin=None, stdout=None, exit=True): + """Read one payload from stdin, dispatch, emit the 'claude_code' response, exit.""" + stream = stdin if stdin is not None else sys.stdin + out = stdout if stdout is not None else sys.stdout + try: + raw = json.loads(_read_payload(stream)) + except Exception: + # Malformed input is not the agent's fault to pay for: allow, stay silent. + if exit: + sys.exit(0) + return 0 + event = parse(raw) + if event.event == UNKNOWN: + # A vendor event this adapter has no mapping for. handle() is not called: it + # reasons about the canonical vocabulary, and handing it something outside that + # vocabulary invites a decision made on a false premise. + if exit: + sys.exit(0) + return 0 + decision = degrade(_coerce(handle(event)), event) + text, code = respond(decision, event) + if text: + _emit(out, text) + if exit: + sys.exit(code) + return code + + +if __name__ == "__main__": # pragma: no cover + main() + diff --git a/tests/fixtures/runtime_goldens/codex_cli.py b/tests/fixtures/runtime_goldens/codex_cli.py new file mode 100644 index 0000000..3b5a068 --- /dev/null +++ b/tests/fixtures/runtime_goldens/codex_cli.py @@ -0,0 +1,805 @@ +# Generated by agentseam 0.2.0 -- 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. +# +# Self-contained: stdlib only, no "import agentseam" anywhere in this file. Regenerate +# with `agentseam.bundler.bundle("codex_cli")` (same agentseam version -> identical bytes, +# except your own edits inside the handler block) rather than patching this by hand. +# https://github.com/open-coder-ai/agentseam + +from __future__ import annotations + +import json +_json = json +import sys + +import os as _chock_os +import shlex as _chock_shlex +import subprocess as _chock_subprocess +from datetime import datetime as _chock_datetime, timezone as _chock_timezone +from pathlib import Path as _chock_Path +import warnings as _warnings + +# ------------------------------------------------------------------------------ +# contract (agentseam 0.2.0) + +"""Canonical event vocabulary, normalized envelope, and decision type.""" + + + +SESSION_START = "session_start" +SESSION_END = "session_end" +PROMPT_SUBMIT = "prompt_submit" +PRE_TOOL = "pre_tool" +POST_TOOL = "post_tool" +TOOL_FAILURE = "tool_failure" +PRE_COMPACT = "pre_compact" +STOP = "stop" +SUBAGENT_START = "subagent_start" +SUBAGENT_STOP = "subagent_stop" +INSTRUCTIONS_LOADED = "instructions_loaded" +FILE_CHANGED = "file_changed" + +UNKNOWN = "unknown" + +EVENTS = ( + SESSION_START, + SESSION_END, + PROMPT_SUBMIT, + PRE_TOOL, + POST_TOOL, + TOOL_FAILURE, + PRE_COMPACT, + STOP, + SUBAGENT_START, + SUBAGENT_STOP, + INSTRUCTIONS_LOADED, + FILE_CHANGED, +) + + +class Event: + """One agent lifecycle event, normalized.""" + + __slots__ = ( + "agent", + "event", + "tool", + "command", + "path", + "content", + "output", + "prompt", + "session_id", + "tool_use_id", + "cwd", + "raw", + ) + + def __init__( + self, + agent, + event, + *, + tool=None, + command=None, + path=None, + content=None, + output=None, + prompt=None, + session_id=None, + tool_use_id=None, + cwd=None, + raw=None, + ): + self.agent = agent + self.event = event + self.tool = tool + self.command = command + self.path = path + self.content = content + self.output = output + self.prompt = prompt + self.session_id = session_id + self.tool_use_id = tool_use_id + self.cwd = cwd + self.raw = raw if raw is not None else {} + + def __repr__(self): # pragma: no cover - debugging aid + return "Event(%s/%s tool=%r path=%r)" % (self.agent, self.event, self.tool, self.path) + + +ALLOW = "allow" +DENY = "deny" +ESCALATE = "escalate" +TRANSFORM = "transform" +WARN = "warn" +VOUCH = "vouch" + +# Pre-ACS-alignment names. Same strings as their ACS-named counterparts, so every existing +# `is`/`==` comparison against the old constant keeps working untouched. +ASK = ESCALATE +REWRITE = TRANSFORM + +_CANONICAL_OUTCOMES = (ALLOW, DENY, ESCALATE, TRANSFORM, WARN, VOUCH) + +# The literal spellings a caller might still pass to Decision(outcome, ...) directly, mapped +# to the value that now backs them. Only needed for the raw-string constructor path -- +# Decision.ask()/.rewrite() below build the canonical outcome themselves. +_LEGACY_SPELLING = {"ask": ESCALATE, "rewrite": TRANSFORM} + + +class Decision: + """What a handler wants to happen. Adapters translate this to vendor dialect.""" + + __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + + #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). + DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) + + def __init__(self, outcome, reason=None, updated_input=None, evidence=None, context=None): + outcome = _LEGACY_SPELLING.get(outcome, outcome) + if outcome not in _CANONICAL_OUTCOMES: + raise ValueError("unknown outcome: %r" % (outcome,)) + self.outcome = outcome + self.reason = reason + self.updated_input = updated_input + self.evidence = evidence or {} + self.context = context + + @classmethod + def allow(cls, reason=None, evidence=None, context=None): + return cls(ALLOW, reason, evidence=evidence, context=context) + + @classmethod + def deny(cls, reason, evidence=None, context=None): + return cls(DENY, reason, evidence=evidence, context=context) + + @classmethod + def escalate(cls, reason, evidence=None, context=None): + """Defer the action to the host's own approval path (ACS `escalate`).""" + return cls(ESCALATE, reason, evidence=evidence, context=context) + + @classmethod + def ask(cls, reason, evidence=None, context=None): + """Deprecated alias of escalate() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.ask() is deprecated; use Decision.escalate()", DeprecationWarning, stacklevel=2) + return cls.escalate(reason, evidence=evidence, context=context) + + @classmethod + def transform(cls, updated_input, reason=None, evidence=None, context=None): + """Replace the tool input wholesale (ACS `transform`, at whole-value granularity).""" + return cls(TRANSFORM, reason, updated_input=updated_input, evidence=evidence, context=context) + + @classmethod + def rewrite(cls, updated_input, reason=None, evidence=None, context=None): + """Deprecated alias of transform() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.rewrite() is deprecated; use Decision.transform()", DeprecationWarning, stacklevel=2) + return cls.transform(updated_input, reason, evidence=evidence, context=context) + + @classmethod + def warn(cls, reason=None, evidence=None, context=None): + """Permit the action with no change, recording a warning (ACS `warn`).""" + return cls(WARN, reason, evidence=evidence, context=context) + + @classmethod + def vouch(cls, reason=None, evidence=None, context=None): + return cls(VOUCH, reason, evidence=evidence, context=context) + + def __repr__(self): # pragma: no cover - debugging aid + return "Decision(%s, %r)" % (self.outcome, self.reason) + + +def degraded_from(decision): + """What this decision was before the dispatcher reduced it, or None.""" + return (decision.evidence or {}).get("degraded_from") + + +def tool_input_of(raw): + """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" + if isinstance(raw, dict): + return raw + if isinstance(raw, str) and raw[:1] == "{": + try: + parsed = _json.loads(raw) + except Exception: + return {} + if isinstance(parsed, dict): + return parsed + return {} + + +# ------------------------------------------------------------------------------ +# hook_json family engine (trimmed to what this entry uses) + +def powershell_command(command): + """`command` rewritten so PowerShell will actually run it.""" + return command if command.lstrip().startswith("&") else "& " + command + +def _wire_name(cfg, raw): + for key in cfg["claims"].get("event_key", ()): + name = raw.get(key) + if name is not None: + return name + return None + +def hj_claims(cfg, raw): + """True when this payload matches the entry's marker discipline.""" + if not isinstance(raw, dict): + return False + c = cfg["claims"] + name = _wire_name(cfg, raw) + if name in c.get("accept_names", ()): + return True + if name not in cfg["events"]: + 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 + return False + return True + +def _segment(node, part): + """One path segment: a dict key, or `key[N]` indexing the list under it.""" + if part.endswith("]") and "[" in part: + key, _, index = part[:-1].partition("[") + items = node.get(key) if isinstance(node, dict) else None + i = int(index) + return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None + return node.get(part) if isinstance(node, dict) else None + +def _walk(node, path): + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + if "[]." in path: + head, sub = path.split("[].", 1) + items = _walk(node, head) + if not isinstance(items, (list, tuple)): + return None + joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) + return joined or None + for part in path.split("."): + node = _segment(node, part) + if node is None: + return None + return node + +def _lookup(raw, ti, key): + """One config key: a `tool_input.` path walks the decoded tool input, else the payload.""" + if key.startswith("tool_input."): + return _walk(ti, key[len("tool_input.") :]) + return _walk(raw, key) + +def _field(raw, ti, chain): + value = None + for key in chain: + if value: + break + value = value or _lookup(raw, ti, key) + return value + +_FIELD_META = ("tool_input", "content_only_for_write_tools", "stringify") + +def _tool_input_raw(cfg, raw): + for key in cfg["fields"].get("tool_input", ("tool_input",)): + value = raw.get(key) + if value is not None: + return value + return None + +def _canonical_of(cfg, name): + """The canonical event for one wire name; an entry with no `events` at all (antigravity, + whose payloads never carry one) maps the shape-inferred name back through `wire_events`.""" + events = cfg["events"] + if not events: + return {wire: canonical for canonical, wire in cfg.get("wire_events", {}).items()}.get(name, UNKNOWN) + return events.get(name, UNKNOWN) + +def hj_parse(cfg, raw, wire=None): + """Normalise one payload along the entry's ordered field-fallback chains. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + ti = tool_input_of(_tool_input_raw(cfg, raw)) + fields = {name: _field(raw, ti, chain) for name, chain in cfg["fields"].items() if name not in _FIELD_META} + if cfg["fields"].get("content_only_for_write_tools") and fields.get("tool") not in cfg["tools"].get("write", ()): + fields["content"] = None + if isinstance(fields.get("output"), (dict, list)): + fields["output"] = _json.dumps(fields["output"]) + for name in cfg["fields"].get("stringify", ()): + if fields.get(name) is not None: + fields[name] = str(fields[name]) + return Event( + cfg["agent"], + _canonical_of(cfg, wire if wire is not None else _wire_name(cfg, raw)), + tool=fields.get("tool"), + command=fields.get("command"), + path=fields.get("path"), + content=fields.get("content"), + output=fields.get("output"), + prompt=fields.get("prompt"), + session_id=fields.get("session_id"), + tool_use_id=fields.get("tool_use_id"), + cwd=fields.get("cwd"), + raw=raw, + ) + +def hj_reverse(cfg): + """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" + reverse = {} + for name, canonical in cfg["events"].items(): + if canonical != UNKNOWN: + reverse[canonical] = name + reverse.update(cfg.get("wire_events", {})) + return reverse + +def _context_value(v, decision): + if v.get("context_source") == "context": + return decision.context + if v.get("context_source") == "reason": + return decision.reason + return None + +def _context_body(name, value): + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "additionalContext": value}}), 0 + +def _note_for(v, decision, at_gate, missing_input): + notes = v.get("degrade_notes", {}) + if decision.outcome == ESCALATE: + if degraded_from(decision) == TRANSFORM and "escalate_from_transform" in notes: + return notes["escalate_from_transform"] + if at_gate and "escalate_gate" in notes: + return notes["escalate_gate"] + return notes.get("escalate") + if decision.outcome == TRANSFORM: + if missing_input and "transform_missing_input" in notes: + return notes["transform_missing_input"] + return notes.get("transform") + return None + +def _default_for(v, decision, at_gate, wire=None): + gate_defaults = v.get("gate_reason_defaults", {}) + if wire in gate_defaults: + return gate_defaults[wire] + defaults = v.get("reason_defaults", {}) + key = {DENY: "deny", ESCALATE: "escalate", TRANSFORM: "transform"}.get(decision.outcome, "deny") + if at_gate and key + "_gate" in defaults: + return defaults[key + "_gate"] + return defaults.get(key, "blocked by policy") + +def _refusal_text(v, decision, at_gate, wire=None): + note = _note_for(v, decision, at_gate, decision.updated_input is None) + default = _default_for(v, decision, at_gate, wire) + if note and "%s" in note: + # A template note fills (the reason or its default, the wire event name) itself. + return note % (decision.reason or default, wire) + reason = decision.reason + if v.get("note_style") == "suffix": + reason = reason or default + return "%s (%s)" % (reason, note) if note else reason + text = "%s (%s)" % (reason, note) if reason and note else (note or reason) + return text or default + +def _g1(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 + 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 + if ( + decision.outcome == ESCALATE + and gate["honours_escalate"] + and "escalate" in words + # An escalate the dispatcher degraded a transform into is a block where the entry + # names that degradation (antigravity): prompting would offer the unmodified call. + and not (degraded_from(decision) == TRANSFORM and "escalate_from_transform" in v.get("degrade_notes", {})) + ): + reason = decision.reason or _default_for(v, decision, True, wire) + return _json.dumps({"decision": words["escalate"], "reason": reason}), 0 + out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, False, wire)} + if at_context_event and v.get("context_source") == "context" and decision.context: + out["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": decision.context} + return _json.dumps(out), 0 + +def _g2(v, gate, decision, name): + """Permission gate: `hookSpecificOutput.permissionDecision`, filled from the word table.""" + if decision.outcome in (ALLOW, WARN): + return "", 0 + words = v.get("words", {}) + out = {"hookEventName": name} + if decision.outcome == VOUCH: + if "vouch" not in words: + return "", 0 + out["permissionDecision"] = words["vouch"] + if decision.reason: + out["permissionDecisionReason"] = decision.reason + elif ( + decision.outcome == TRANSFORM + and gate["honours_transform"] + and not (decision.updated_input is None and "transform_missing_input" in v.get("degrade_notes", {})) + ): + out["permissionDecision"] = words.get("transform", "allow") + out["updatedInput"] = decision.updated_input + if decision.reason: + out["permissionDecisionReason"] = 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) + else: + out["permissionDecision"] = words.get("deny", "deny") + out["permissionDecisionReason"] = _refusal_text(v, decision, True) + return _json.dumps({"hookSpecificOutput": out}), 0 + +def hj_respond(cfg, decision, event, wire=None): + """(stdout_text, exit_code) in this entry's dialect for the gate the payload names. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + v = cfg["verdicts"] + if wire is None: + wire = _wire_name(cfg, event.raw or {}) + if wire in v.get("empty_object_events", ()): + return _json.dumps({}), 0 + if wire is None: + wire = v.get("default_wire_event") + if wire is None and v.get("missing_wire") == "reverse_map": + wire = hj_reverse(cfg).get(event.event) + name = wire if v.get("echo") == "payload" else hj_reverse(cfg).get(event.event, "PreToolUse") + gate = v["gates"].get(wire) + if gate is None: + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + return "", 0 + if gate["grammar"] == "G2": + return _g2(v, gate, decision, name) + return _g1(v, gate, decision, wire, name) + +_WINDOWS_KEYS = ("commandWindows", "windows") + +def _hook_dict(cfg, command): + entry = {"type": "command", "command": command} + for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): + if key in _WINDOWS_KEYS: + entry[key] = powershell_command(command) + else: + entry[key] = value + return entry + +def 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": + 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) + if not name: + continue + entry = {"hooks": [_hook_dict(cfg, command)]} + if matcher and hook_entry["matcher"]: + entry["matcher"] = matcher + hooks.setdefault(name, []).append(entry) + if hook_entry.get("group"): + return {hook_entry["group"]: hooks} + return hooks if hook_entry.get("bare") else {"hooks": hooks} + + +# ------------------------------------------------------------------------------ +# 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'}}} + + +def claims(raw): + return hj_claims(VENDOR, raw) + + +def parse(raw): + return hj_parse(VENDOR, raw) + + +def respond(decision, event): + return hj_respond(VENDOR, decision, event) + + +def hook_config(canonical_events, command, matcher=None): + return hook_entry_config(VENDOR, canonical_events, command, matcher) + + +# ------------------------------------------------------------------------------ +# runtime (agentseam dispatch, specialized to codex_cli) + +_VOUCH_SPEAKS = False +_WARN_SPEAKS = False +_TRANSFORM_EVENTS = frozenset(('pre_tool',)) + + +def degrade(decision, event): + """Reduce a decision to what 'codex_cli' can actually honor, honestly. + + This is agentseam.dispatch.degrade(), specialized to one fixed agent so this file needs + no "import agentseam" -- no evidence establishes that an explicit approval word means anything beyond a plain allow here, so vouch degrades to one; see agentseam.allow_semantics. + """ + if decision.outcome == TRANSFORM and event.event not in _TRANSFORM_EVENTS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = TRANSFORM + return Decision.escalate( + decision.reason or "input requires modification before it can run", evidence=evidence + ) + if decision.outcome == VOUCH and not _VOUCH_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = VOUCH + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + if decision.outcome == WARN and not _WARN_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = WARN + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + return decision + + +# >>> agentseam handler >>> +GUARD_VIOLATION = 1 + +_BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') + +GATE_LOG_ENV = 'CHOCK_GATE_LOG' + +_LOG_MAX_BYTES = 1048576 + +_GUARD_TIMEOUT_SECONDS = 30 + +GUARD_BLOCKED = 'blocked' + +GUARD_CLEAN = 'clean' + +GUARD_UNCHECKED = 'unchecked' + +GUARD_ERRORED = 'errored' + +VERDICT_DENY = 'deny' + +VERDICT_ESCALATE = 'escalate' + +def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: + """The `--guard ` argument a vendored runtime was invoked with, or None.""" + if '--guard' in argv: + i = argv.index('--guard') + if i + 1 < len(argv): + return _chock_Path(argv[i + 1]) + return None + +def find_bash(guard: _chock_Path) -> str | None: + """First interpreter that can actually see `guard`, or None.""" + for candidate in _BASH_CANDIDATES: + try: + proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10) + except (OSError, _chock_subprocess.SubprocessError): + continue + if proc.returncode == 0: + return candidate + return None + +def run_guard(guard: _chock_Path, command: str) -> str: + """`GUARD_BLOCKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + try: + args = _chock_shlex.split(command) + except ValueError: + print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) + return GUARD_UNCHECKED + if not args: + return GUARD_UNCHECKED + bash = find_bash(guard) + if bash is None: + print(f'chock: no usable bash found, {guard.name} not checked', file=sys.stderr) + return GUARD_UNCHECKED + try: + env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} + proc = _chock_subprocess.run([bash, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS) + except _chock_subprocess.TimeoutExpired: + print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) + return GUARD_ERRORED + except (OSError, UnicodeError) as exc: + print(f'chock: guard could not run, not checked: {exc}', file=sys.stderr) + return GUARD_ERRORED + if proc.returncode == GUARD_VIOLATION: + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + if not ((proc.stdout or '') + (proc.stderr or '')).strip(): + print(f'chock: blocked by {_chock_Path(guard).name} (guard gave no reason)', file=sys.stderr) + return GUARD_BLOCKED + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout or '').strip().splitlines() + print(f'chock: guard exited {proc.returncode}, not checked' + (f': {detail[0][:120]}' if detail else ''), file=sys.stderr) + return GUARD_ERRORED + return GUARD_CLEAN + +def log_outcome(guard: _chock_Path, tool: str, blocked: bool) -> None: + """Append one outcome record. Best effort: never raises, never changes the verdict.""" + try: + if _chock_os.environ.get(GATE_LOG_ENV) == '0': + return + guard = guard.resolve() + if guard.parent.name != 'implementations': + return + artifact_root = None + for parent in guard.parents: + if (parent / '.chock').is_dir(): + artifact_root = parent / '.chock' + break + if artifact_root is None: + return + log_dir = artifact_root / 'log' + log_dir.mkdir(parents=True, exist_ok=True) + log_path = log_dir / 'gate-events.jsonl' + if log_path.exists() and log_path.stat().st_size > _LOG_MAX_BYTES: + log_path.replace(log_dir / 'gate-events.1.jsonl') + import json + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': 'block' if blocked else 'allow'} + with log_path.open('a', encoding='utf-8') as fh: + fh.write(json.dumps(record, ensure_ascii=False) + '\n') + except Exception: + return + +def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: + """Run the guard named on `argv` (`--guard `) against `command`.""" + guard = guard_path_from_argv(argv) + if guard is None or not guard.exists(): + return None + verdict = run_guard(guard, command) + if verdict in (GUARD_BLOCKED, GUARD_CLEAN): + log_outcome(guard, tool, verdict == GUARD_BLOCKED) + if verdict == GUARD_BLOCKED: + return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + if verdict == GUARD_ERRORED: + return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") + return None + + +def handle(event): + if event.event == "pre_tool" and event.command: + verdict = evaluate(sys.argv[1:], event.command, event.tool or "") + if verdict is not None: + outcome, reason = verdict + return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + return None +# <<< agentseam handler <<< + + +def _coerce(result): + if result is None: + return Decision.allow() + if isinstance(result, Decision): + return result + raise TypeError("handle() must return a Decision or None, got %r" % (type(result),)) + + +def _read_payload(stream): + # Decode bytes ourselves rather than trust the platform locale -- a BOM'd or non-UTF-8 + # stdin must not silently disable the gate. See agentseam.dispatch for the incident + # this guards: a Windows console's cp1252 layer turning a UTF-8 BOM into three bytes + # json cannot parse, with the hook allowing everything while claiming enforcement. + buffer = getattr(stream, "buffer", None) + if buffer is not None: + return buffer.read().decode("utf-8-sig", errors="replace") + return stream.read().lstrip("\ufeff") + + +def _emit(out, text): + buffer = getattr(out, "buffer", None) + if buffer is not None: + buffer.write(text.encode("utf-8")) + buffer.flush() + else: + out.write(text) + out.flush() + + +def main(stdin=None, stdout=None, exit=True): + """Read one payload from stdin, dispatch, emit the 'codex_cli' response, exit.""" + stream = stdin if stdin is not None else sys.stdin + out = stdout if stdout is not None else sys.stdout + try: + raw = json.loads(_read_payload(stream)) + except Exception: + # Malformed input is not the agent's fault to pay for: allow, stay silent. + if exit: + sys.exit(0) + return 0 + event = parse(raw) + if event.event == UNKNOWN: + # A vendor event this adapter has no mapping for. handle() is not called: it + # reasons about the canonical vocabulary, and handing it something outside that + # vocabulary invites a decision made on a false premise. + if exit: + sys.exit(0) + return 0 + decision = degrade(_coerce(handle(event)), event) + text, code = respond(decision, event) + if text: + _emit(out, text) + if exit: + sys.exit(code) + return code + + +if __name__ == "__main__": # pragma: no cover + main() + diff --git a/tests/fixtures/runtime_goldens/cursor.py b/tests/fixtures/runtime_goldens/cursor.py new file mode 100644 index 0000000..c1a44ed --- /dev/null +++ b/tests/fixtures/runtime_goldens/cursor.py @@ -0,0 +1,728 @@ +# Generated by agentseam 0.2.0 -- 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. +# +# Self-contained: stdlib only, no "import agentseam" anywhere in this file. Regenerate +# with `agentseam.bundler.bundle("cursor")` (same agentseam version -> identical bytes, +# except your own edits inside the handler block) rather than patching this by hand. +# https://github.com/open-coder-ai/agentseam + +from __future__ import annotations + +import json +_json = json +import sys + +import os as _chock_os +import shlex as _chock_shlex +import subprocess as _chock_subprocess +from datetime import datetime as _chock_datetime, timezone as _chock_timezone +from pathlib import Path as _chock_Path +import warnings as _warnings + +# ------------------------------------------------------------------------------ +# contract (agentseam 0.2.0) + +"""Canonical event vocabulary, normalized envelope, and decision type.""" + + + +SESSION_START = "session_start" +SESSION_END = "session_end" +PROMPT_SUBMIT = "prompt_submit" +PRE_TOOL = "pre_tool" +POST_TOOL = "post_tool" +TOOL_FAILURE = "tool_failure" +PRE_COMPACT = "pre_compact" +STOP = "stop" +SUBAGENT_START = "subagent_start" +SUBAGENT_STOP = "subagent_stop" +INSTRUCTIONS_LOADED = "instructions_loaded" +FILE_CHANGED = "file_changed" + +UNKNOWN = "unknown" + +EVENTS = ( + SESSION_START, + SESSION_END, + PROMPT_SUBMIT, + PRE_TOOL, + POST_TOOL, + TOOL_FAILURE, + PRE_COMPACT, + STOP, + SUBAGENT_START, + SUBAGENT_STOP, + INSTRUCTIONS_LOADED, + FILE_CHANGED, +) + + +class Event: + """One agent lifecycle event, normalized.""" + + __slots__ = ( + "agent", + "event", + "tool", + "command", + "path", + "content", + "output", + "prompt", + "session_id", + "tool_use_id", + "cwd", + "raw", + ) + + def __init__( + self, + agent, + event, + *, + tool=None, + command=None, + path=None, + content=None, + output=None, + prompt=None, + session_id=None, + tool_use_id=None, + cwd=None, + raw=None, + ): + self.agent = agent + self.event = event + self.tool = tool + self.command = command + self.path = path + self.content = content + self.output = output + self.prompt = prompt + self.session_id = session_id + self.tool_use_id = tool_use_id + self.cwd = cwd + self.raw = raw if raw is not None else {} + + def __repr__(self): # pragma: no cover - debugging aid + return "Event(%s/%s tool=%r path=%r)" % (self.agent, self.event, self.tool, self.path) + + +ALLOW = "allow" +DENY = "deny" +ESCALATE = "escalate" +TRANSFORM = "transform" +WARN = "warn" +VOUCH = "vouch" + +# Pre-ACS-alignment names. Same strings as their ACS-named counterparts, so every existing +# `is`/`==` comparison against the old constant keeps working untouched. +ASK = ESCALATE +REWRITE = TRANSFORM + +_CANONICAL_OUTCOMES = (ALLOW, DENY, ESCALATE, TRANSFORM, WARN, VOUCH) + +# The literal spellings a caller might still pass to Decision(outcome, ...) directly, mapped +# to the value that now backs them. Only needed for the raw-string constructor path -- +# Decision.ask()/.rewrite() below build the canonical outcome themselves. +_LEGACY_SPELLING = {"ask": ESCALATE, "rewrite": TRANSFORM} + + +class Decision: + """What a handler wants to happen. Adapters translate this to vendor dialect.""" + + __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + + #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). + DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) + + def __init__(self, outcome, reason=None, updated_input=None, evidence=None, context=None): + outcome = _LEGACY_SPELLING.get(outcome, outcome) + if outcome not in _CANONICAL_OUTCOMES: + raise ValueError("unknown outcome: %r" % (outcome,)) + self.outcome = outcome + self.reason = reason + self.updated_input = updated_input + self.evidence = evidence or {} + self.context = context + + @classmethod + def allow(cls, reason=None, evidence=None, context=None): + return cls(ALLOW, reason, evidence=evidence, context=context) + + @classmethod + def deny(cls, reason, evidence=None, context=None): + return cls(DENY, reason, evidence=evidence, context=context) + + @classmethod + def escalate(cls, reason, evidence=None, context=None): + """Defer the action to the host's own approval path (ACS `escalate`).""" + return cls(ESCALATE, reason, evidence=evidence, context=context) + + @classmethod + def ask(cls, reason, evidence=None, context=None): + """Deprecated alias of escalate() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.ask() is deprecated; use Decision.escalate()", DeprecationWarning, stacklevel=2) + return cls.escalate(reason, evidence=evidence, context=context) + + @classmethod + def transform(cls, updated_input, reason=None, evidence=None, context=None): + """Replace the tool input wholesale (ACS `transform`, at whole-value granularity).""" + return cls(TRANSFORM, reason, updated_input=updated_input, evidence=evidence, context=context) + + @classmethod + def rewrite(cls, updated_input, reason=None, evidence=None, context=None): + """Deprecated alias of transform() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.rewrite() is deprecated; use Decision.transform()", DeprecationWarning, stacklevel=2) + return cls.transform(updated_input, reason, evidence=evidence, context=context) + + @classmethod + def warn(cls, reason=None, evidence=None, context=None): + """Permit the action with no change, recording a warning (ACS `warn`).""" + return cls(WARN, reason, evidence=evidence, context=context) + + @classmethod + def vouch(cls, reason=None, evidence=None, context=None): + return cls(VOUCH, reason, evidence=evidence, context=context) + + def __repr__(self): # pragma: no cover - debugging aid + return "Decision(%s, %r)" % (self.outcome, self.reason) + + +def degraded_from(decision): + """What this decision was before the dispatcher reduced it, or None.""" + return (decision.evidence or {}).get("degraded_from") + + +def tool_input_of(raw): + """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" + if isinstance(raw, dict): + return raw + if isinstance(raw, str) and raw[:1] == "{": + try: + parsed = _json.loads(raw) + except Exception: + return {} + if isinstance(parsed, dict): + return parsed + return {} + + +# ------------------------------------------------------------------------------ +# cursor family engine (trimmed to what this entry uses) + +def _wire_name(cfg, raw): + for key in cfg["claims"].get("event_key", ()): + name = raw.get(key) + if name is not None: + return name + return None + +def _segment(node, part): + """One path segment: a dict key, or `key[N]` indexing the list under it.""" + if part.endswith("]") and "[" in part: + key, _, index = part[:-1].partition("[") + items = node.get(key) if isinstance(node, dict) else None + i = int(index) + return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None + return node.get(part) if isinstance(node, dict) else None + +def _walk(node, path): + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + if "[]." in path: + head, sub = path.split("[].", 1) + items = _walk(node, head) + if not isinstance(items, (list, tuple)): + return None + joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) + return joined or None + for part in path.split("."): + node = _segment(node, part) + if node is None: + return None + return node + +def _lookup(raw, ti, key): + """One config key: a `tool_input.` path walks the decoded tool input, else the payload.""" + if key.startswith("tool_input."): + return _walk(ti, key[len("tool_input.") :]) + return _walk(raw, key) + +def _field(raw, ti, chain): + value = None + for key in chain: + if value: + break + value = value or _lookup(raw, ti, key) + return value + +_FIELD_META = ("tool_input", "content_only_for_write_tools", "stringify") + +def _tool_input_raw(cfg, raw): + for key in cfg["fields"].get("tool_input", ("tool_input",)): + value = raw.get(key) + if value is not None: + return value + return None + +def _canonical_of(cfg, name): + """The canonical event for one wire name; an entry with no `events` at all (antigravity, + whose payloads never carry one) maps the shape-inferred name back through `wire_events`.""" + events = cfg["events"] + if not events: + return {wire: canonical for canonical, wire in cfg.get("wire_events", {}).items()}.get(name, UNKNOWN) + return events.get(name, UNKNOWN) + +def hj_parse(cfg, raw, wire=None): + """Normalise one payload along the entry's ordered field-fallback chains. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + ti = tool_input_of(_tool_input_raw(cfg, raw)) + fields = {name: _field(raw, ti, chain) for name, chain in cfg["fields"].items() if name not in _FIELD_META} + if cfg["fields"].get("content_only_for_write_tools") and fields.get("tool") not in cfg["tools"].get("write", ()): + fields["content"] = None + if isinstance(fields.get("output"), (dict, list)): + fields["output"] = _json.dumps(fields["output"]) + for name in cfg["fields"].get("stringify", ()): + if fields.get(name) is not None: + fields[name] = str(fields[name]) + return Event( + cfg["agent"], + _canonical_of(cfg, wire if wire is not None else _wire_name(cfg, raw)), + tool=fields.get("tool"), + command=fields.get("command"), + path=fields.get("path"), + content=fields.get("content"), + output=fields.get("output"), + prompt=fields.get("prompt"), + session_id=fields.get("session_id"), + tool_use_id=fields.get("tool_use_id"), + cwd=fields.get("cwd"), + raw=raw, + ) + +def hj_reverse(cfg): + """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" + reverse = {} + for name, canonical in cfg["events"].items(): + if canonical != UNKNOWN: + reverse[canonical] = name + reverse.update(cfg.get("wire_events", {})) + return reverse + +_WINDOWS_KEYS = ("commandWindows", "windows") + +def _hook_dict(cfg, command): + entry = {"type": "command", "command": command} + for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): + if key in _WINDOWS_KEYS: + entry[key] = powershell_command(command) + else: + entry[key] = value + return entry + +def 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": + 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) + if not name: + continue + entry = {"hooks": [_hook_dict(cfg, command)]} + if matcher and hook_entry["matcher"]: + entry["matcher"] = matcher + hooks.setdefault(name, []).append(entry) + if hook_entry.get("group"): + return {hook_entry["group"]: hooks} + return hooks if hook_entry.get("bare") else {"hooks": hooks} + +_AMBIGUOUS_NAMES = ( + "preToolUse", + "postToolUse", + "sessionStart", + "sessionEnd", + "preCompact", + "stop", + "subagentStart", + "subagentStop", +) + +_MARKERS = ("conversation_id", "generation_id", "cursor_version", "workspace_roots") + +def cursor_wire(raw): + """The wire event name, inferred from shape when the payload names none.""" + name = raw.get("hook_event_name") + if name is None: + return "afterFileEdit" if isinstance(raw.get("edits"), list) else "beforeShellExecution" + return name + +def cursor_claims(cfg, raw): + """True when this payload looks like Cursor's shape.""" + if not isinstance(raw, dict): + return False + name = raw.get("hook_event_name") + if name in cfg["events"]: + if name in _AMBIGUOUS_NAMES: + return any(k in raw for k in _MARKERS) + return True + if isinstance(raw.get("command"), str) and ("sandbox" in raw or "cwd" in raw) and "tool_input" not in raw: + return True + return "file_path" in raw and isinstance(raw.get("edits"), list) and "tool_name" not in raw + +def cursor_parse(cfg, raw): + name = cursor_wire(raw) + event = hj_parse(cfg, raw, wire=name) + event.tool = event.tool or name + return event + +def _because(reason, note): + """Keep the handler's own reason and add why the outcome changed shape.""" + return "%s (%s)" % (reason, note) if reason else note + +def _wire_of(cfg, event): + """The wire name to answer at: the payload's own, `tool` where `parse` kept it there, + else the entry's default gate.""" + name = (event.raw or {}).get("hook_event_name") + if name in cfg["events"]: + return name + return event.tool if event.tool in cfg["events"] else cfg["verdicts"].get("default_wire_event") + +def cursor_respond(cfg, decision, event): + v = cfg["verdicts"] + name = _wire_of(cfg, event) + canonical = cfg["events"].get(name) + + 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 + + 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 + + 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["user_message"] = reason + payload["agent_message"] = reason + return _json.dumps(payload), 0 + + +# ------------------------------------------------------------------------------ +# cursor vendor config + engine binding + +AGENT = "cursor" + +VENDOR = {'agent': 'cursor', 'claims': {'mode': 'shape_inferred'}, 'config_format': 'json', 'config_path': '.cursor/hooks.json', 'display': 'Cursor', 'events': {'afterFileEdit': 'file_changed', 'afterMCPExecution': 'post_tool', 'afterShellExecution': 'post_tool', 'afterTabFileEdit': 'file_changed', 'beforeMCPExecution': 'pre_tool', 'beforeReadFile': 'pre_tool', 'beforeShellExecution': 'pre_tool', 'beforeSubmitPrompt': 'prompt_submit', 'beforeTabFileRead': 'pre_tool', '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'}, 'evidence': {'claims': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'live-run-partial', 'date': '2026-08-27', '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-27', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'cursor', 'fields': {'command': ('command', 'tool_input.command'), 'content': ('edits[].new_string', 'tool_input.content', 'tool_input.new_string', 'content'), 'cwd': ('cwd',), 'output': ('tool_output', 'output', 'result_json'), 'path': ('file_path', 'tool_input.file_path', 'tool_input.path'), 'prompt': ('prompt',), 'session_id': ('conversation_id',), 'tool': ('tool_name',), 'tool_use_id': ('tool_use_id',)}, 'hook_entry': {'matcher': False, 'wrapper': 'cursor'}, 'needs_trust': False, 'tools': {}, 'verdicts': {'answer_events': ('beforeMCPExecution', 'beforeReadFile', 'beforeShellExecution', 'beforeSubmitPrompt', 'beforeTabFileRead', 'preToolUse'), 'bare_allow': 'required', 'default_wire_event': 'beforeShellExecution', 'degrade_notes': {'escalate': '%s cannot prompt for confirmation, so this is a block', 'escalate_from_transform': '%s cannot modify a tool call, so this is a block', 'transform': 'input requires modification, which this gate cannot express'}, 'flag_note': 'observed after the fact (%s cannot prevent it): %s', 'flag_note_default': 'policy violation', 'gates': {'beforeMCPExecution': {'grammar': 'G4', 'honours_escalate': True, 'honours_transform': False}, 'beforeReadFile': {'grammar': 'G4', 'honours_escalate': False, 'honours_transform': False}, 'beforeShellExecution': {'grammar': 'G4', 'honours_escalate': True, 'honours_transform': False}, 'beforeSubmitPrompt': {'grammar': 'G4', 'honours_escalate': False, 'honours_transform': False}, 'beforeTabFileRead': {'grammar': 'G4', 'honours_escalate': False, 'honours_transform': False}, 'preToolUse': {'grammar': 'G4', 'honours_escalate': False, 'honours_transform': True}}, 'transform_grammar': 'permission_updated_input', 'vocabulary': ('allow', 'ask', 'deny'), 'vocabulary_basis': 'verified', 'words': {'allow': 'allow', 'block': 'deny', 'escalate': 'ask'}}, 'wire_events': {'file_changed': 'afterFileEdit', 'post_tool': 'postToolUse', 'pre_tool': 'preToolUse'}} + + +def claims(raw): + return cursor_claims(VENDOR, raw) + + +def parse(raw): + return cursor_parse(VENDOR, raw) + + +def respond(decision, event): + return cursor_respond(VENDOR, decision, event) + + +def hook_config(canonical_events, command, matcher=None): + return hook_entry_config(VENDOR, canonical_events, command, matcher) + + +# ------------------------------------------------------------------------------ +# runtime (agentseam dispatch, specialized to cursor) + +_VOUCH_SPEAKS = False +_WARN_SPEAKS = False +_TRANSFORM_EVENTS = frozenset(('pre_tool',)) + + +def degrade(decision, event): + """Reduce a decision to what 'cursor' can actually honor, honestly. + + This is agentseam.dispatch.degrade(), specialized to one fixed agent so this file needs + no "import agentseam" -- no evidence establishes that an explicit approval word means anything beyond a plain allow here, so vouch degrades to one; see agentseam.allow_semantics. + """ + if decision.outcome == TRANSFORM and event.event not in _TRANSFORM_EVENTS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = TRANSFORM + return Decision.escalate( + decision.reason or "input requires modification before it can run", evidence=evidence + ) + if decision.outcome == VOUCH and not _VOUCH_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = VOUCH + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + if decision.outcome == WARN and not _WARN_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = WARN + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + return decision + + +# >>> agentseam handler >>> +GUARD_VIOLATION = 1 + +_BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') + +GATE_LOG_ENV = 'CHOCK_GATE_LOG' + +_LOG_MAX_BYTES = 1048576 + +_GUARD_TIMEOUT_SECONDS = 30 + +GUARD_BLOCKED = 'blocked' + +GUARD_CLEAN = 'clean' + +GUARD_UNCHECKED = 'unchecked' + +GUARD_ERRORED = 'errored' + +VERDICT_DENY = 'deny' + +VERDICT_ESCALATE = 'escalate' + +def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: + """The `--guard ` argument a vendored runtime was invoked with, or None.""" + if '--guard' in argv: + i = argv.index('--guard') + if i + 1 < len(argv): + return _chock_Path(argv[i + 1]) + return None + +def find_bash(guard: _chock_Path) -> str | None: + """First interpreter that can actually see `guard`, or None.""" + for candidate in _BASH_CANDIDATES: + try: + proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10) + except (OSError, _chock_subprocess.SubprocessError): + continue + if proc.returncode == 0: + return candidate + return None + +def run_guard(guard: _chock_Path, command: str) -> str: + """`GUARD_BLOCKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + try: + args = _chock_shlex.split(command) + except ValueError: + print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) + return GUARD_UNCHECKED + if not args: + return GUARD_UNCHECKED + bash = find_bash(guard) + if bash is None: + print(f'chock: no usable bash found, {guard.name} not checked', file=sys.stderr) + return GUARD_UNCHECKED + try: + env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} + proc = _chock_subprocess.run([bash, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS) + except _chock_subprocess.TimeoutExpired: + print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) + return GUARD_ERRORED + except (OSError, UnicodeError) as exc: + print(f'chock: guard could not run, not checked: {exc}', file=sys.stderr) + return GUARD_ERRORED + if proc.returncode == GUARD_VIOLATION: + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + if not ((proc.stdout or '') + (proc.stderr or '')).strip(): + print(f'chock: blocked by {_chock_Path(guard).name} (guard gave no reason)', file=sys.stderr) + return GUARD_BLOCKED + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout or '').strip().splitlines() + print(f'chock: guard exited {proc.returncode}, not checked' + (f': {detail[0][:120]}' if detail else ''), file=sys.stderr) + return GUARD_ERRORED + return GUARD_CLEAN + +def log_outcome(guard: _chock_Path, tool: str, blocked: bool) -> None: + """Append one outcome record. Best effort: never raises, never changes the verdict.""" + try: + if _chock_os.environ.get(GATE_LOG_ENV) == '0': + return + guard = guard.resolve() + if guard.parent.name != 'implementations': + return + artifact_root = None + for parent in guard.parents: + if (parent / '.chock').is_dir(): + artifact_root = parent / '.chock' + break + if artifact_root is None: + return + log_dir = artifact_root / 'log' + log_dir.mkdir(parents=True, exist_ok=True) + log_path = log_dir / 'gate-events.jsonl' + if log_path.exists() and log_path.stat().st_size > _LOG_MAX_BYTES: + log_path.replace(log_dir / 'gate-events.1.jsonl') + import json + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': 'block' if blocked else 'allow'} + with log_path.open('a', encoding='utf-8') as fh: + fh.write(json.dumps(record, ensure_ascii=False) + '\n') + except Exception: + return + +def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: + """Run the guard named on `argv` (`--guard `) against `command`.""" + guard = guard_path_from_argv(argv) + if guard is None or not guard.exists(): + return None + verdict = run_guard(guard, command) + if verdict in (GUARD_BLOCKED, GUARD_CLEAN): + log_outcome(guard, tool, verdict == GUARD_BLOCKED) + if verdict == GUARD_BLOCKED: + return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + if verdict == GUARD_ERRORED: + return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") + return None + + +def handle(event): + if event.event == "pre_tool" and event.command: + verdict = evaluate(sys.argv[1:], event.command, event.tool or "") + if verdict is not None: + outcome, reason = verdict + return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + return None +# <<< agentseam handler <<< + + +def _coerce(result): + if result is None: + return Decision.allow() + if isinstance(result, Decision): + return result + raise TypeError("handle() must return a Decision or None, got %r" % (type(result),)) + + +def _read_payload(stream): + # Decode bytes ourselves rather than trust the platform locale -- a BOM'd or non-UTF-8 + # stdin must not silently disable the gate. See agentseam.dispatch for the incident + # this guards: a Windows console's cp1252 layer turning a UTF-8 BOM into three bytes + # json cannot parse, with the hook allowing everything while claiming enforcement. + buffer = getattr(stream, "buffer", None) + if buffer is not None: + return buffer.read().decode("utf-8-sig", errors="replace") + return stream.read().lstrip("\ufeff") + + +def _emit(out, text): + buffer = getattr(out, "buffer", None) + if buffer is not None: + buffer.write(text.encode("utf-8")) + buffer.flush() + else: + out.write(text) + out.flush() + + +def main(stdin=None, stdout=None, exit=True): + """Read one payload from stdin, dispatch, emit the 'cursor' response, exit.""" + stream = stdin if stdin is not None else sys.stdin + out = stdout if stdout is not None else sys.stdout + try: + raw = json.loads(_read_payload(stream)) + except Exception: + # Malformed input is not the agent's fault to pay for: allow, stay silent. + if exit: + sys.exit(0) + return 0 + event = parse(raw) + if event.event == UNKNOWN: + # A vendor event this adapter has no mapping for. handle() is not called: it + # reasons about the canonical vocabulary, and handing it something outside that + # vocabulary invites a decision made on a false premise. + if exit: + sys.exit(0) + return 0 + decision = degrade(_coerce(handle(event)), event) + text, code = respond(decision, event) + if text: + _emit(out, text) + if exit: + sys.exit(code) + return code + + +if __name__ == "__main__": # pragma: no cover + main() + diff --git a/tests/fixtures/runtime_goldens/devin.py b/tests/fixtures/runtime_goldens/devin.py new file mode 100644 index 0000000..02cee60 --- /dev/null +++ b/tests/fixtures/runtime_goldens/devin.py @@ -0,0 +1,789 @@ +# Generated by agentseam 0.2.0 -- 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. +# +# Self-contained: stdlib only, no "import agentseam" anywhere in this file. Regenerate +# with `agentseam.bundler.bundle("devin")` (same agentseam version -> identical bytes, +# except your own edits inside the handler block) rather than patching this by hand. +# https://github.com/open-coder-ai/agentseam + +from __future__ import annotations + +import json +_json = json +import sys + +import os as _chock_os +import shlex as _chock_shlex +import subprocess as _chock_subprocess +from datetime import datetime as _chock_datetime, timezone as _chock_timezone +from pathlib import Path as _chock_Path +import warnings as _warnings + +# ------------------------------------------------------------------------------ +# contract (agentseam 0.2.0) + +"""Canonical event vocabulary, normalized envelope, and decision type.""" + + + +SESSION_START = "session_start" +SESSION_END = "session_end" +PROMPT_SUBMIT = "prompt_submit" +PRE_TOOL = "pre_tool" +POST_TOOL = "post_tool" +TOOL_FAILURE = "tool_failure" +PRE_COMPACT = "pre_compact" +STOP = "stop" +SUBAGENT_START = "subagent_start" +SUBAGENT_STOP = "subagent_stop" +INSTRUCTIONS_LOADED = "instructions_loaded" +FILE_CHANGED = "file_changed" + +UNKNOWN = "unknown" + +EVENTS = ( + SESSION_START, + SESSION_END, + PROMPT_SUBMIT, + PRE_TOOL, + POST_TOOL, + TOOL_FAILURE, + PRE_COMPACT, + STOP, + SUBAGENT_START, + SUBAGENT_STOP, + INSTRUCTIONS_LOADED, + FILE_CHANGED, +) + + +class Event: + """One agent lifecycle event, normalized.""" + + __slots__ = ( + "agent", + "event", + "tool", + "command", + "path", + "content", + "output", + "prompt", + "session_id", + "tool_use_id", + "cwd", + "raw", + ) + + def __init__( + self, + agent, + event, + *, + tool=None, + command=None, + path=None, + content=None, + output=None, + prompt=None, + session_id=None, + tool_use_id=None, + cwd=None, + raw=None, + ): + self.agent = agent + self.event = event + self.tool = tool + self.command = command + self.path = path + self.content = content + self.output = output + self.prompt = prompt + self.session_id = session_id + self.tool_use_id = tool_use_id + self.cwd = cwd + self.raw = raw if raw is not None else {} + + def __repr__(self): # pragma: no cover - debugging aid + return "Event(%s/%s tool=%r path=%r)" % (self.agent, self.event, self.tool, self.path) + + +ALLOW = "allow" +DENY = "deny" +ESCALATE = "escalate" +TRANSFORM = "transform" +WARN = "warn" +VOUCH = "vouch" + +# Pre-ACS-alignment names. Same strings as their ACS-named counterparts, so every existing +# `is`/`==` comparison against the old constant keeps working untouched. +ASK = ESCALATE +REWRITE = TRANSFORM + +_CANONICAL_OUTCOMES = (ALLOW, DENY, ESCALATE, TRANSFORM, WARN, VOUCH) + +# The literal spellings a caller might still pass to Decision(outcome, ...) directly, mapped +# to the value that now backs them. Only needed for the raw-string constructor path -- +# Decision.ask()/.rewrite() below build the canonical outcome themselves. +_LEGACY_SPELLING = {"ask": ESCALATE, "rewrite": TRANSFORM} + + +class Decision: + """What a handler wants to happen. Adapters translate this to vendor dialect.""" + + __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + + #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). + DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) + + def __init__(self, outcome, reason=None, updated_input=None, evidence=None, context=None): + outcome = _LEGACY_SPELLING.get(outcome, outcome) + if outcome not in _CANONICAL_OUTCOMES: + raise ValueError("unknown outcome: %r" % (outcome,)) + self.outcome = outcome + self.reason = reason + self.updated_input = updated_input + self.evidence = evidence or {} + self.context = context + + @classmethod + def allow(cls, reason=None, evidence=None, context=None): + return cls(ALLOW, reason, evidence=evidence, context=context) + + @classmethod + def deny(cls, reason, evidence=None, context=None): + return cls(DENY, reason, evidence=evidence, context=context) + + @classmethod + def escalate(cls, reason, evidence=None, context=None): + """Defer the action to the host's own approval path (ACS `escalate`).""" + return cls(ESCALATE, reason, evidence=evidence, context=context) + + @classmethod + def ask(cls, reason, evidence=None, context=None): + """Deprecated alias of escalate() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.ask() is deprecated; use Decision.escalate()", DeprecationWarning, stacklevel=2) + return cls.escalate(reason, evidence=evidence, context=context) + + @classmethod + def transform(cls, updated_input, reason=None, evidence=None, context=None): + """Replace the tool input wholesale (ACS `transform`, at whole-value granularity).""" + return cls(TRANSFORM, reason, updated_input=updated_input, evidence=evidence, context=context) + + @classmethod + def rewrite(cls, updated_input, reason=None, evidence=None, context=None): + """Deprecated alias of transform() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.rewrite() is deprecated; use Decision.transform()", DeprecationWarning, stacklevel=2) + return cls.transform(updated_input, reason, evidence=evidence, context=context) + + @classmethod + def warn(cls, reason=None, evidence=None, context=None): + """Permit the action with no change, recording a warning (ACS `warn`).""" + return cls(WARN, reason, evidence=evidence, context=context) + + @classmethod + def vouch(cls, reason=None, evidence=None, context=None): + return cls(VOUCH, reason, evidence=evidence, context=context) + + def __repr__(self): # pragma: no cover - debugging aid + return "Decision(%s, %r)" % (self.outcome, self.reason) + + +def degraded_from(decision): + """What this decision was before the dispatcher reduced it, or None.""" + return (decision.evidence or {}).get("degraded_from") + + +def tool_input_of(raw): + """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" + if isinstance(raw, dict): + return raw + if isinstance(raw, str) and raw[:1] == "{": + try: + parsed = _json.loads(raw) + except Exception: + return {} + if isinstance(parsed, dict): + return parsed + return {} + + +# ------------------------------------------------------------------------------ +# hook_json family engine (trimmed to what this entry uses) + +OBSERVED_MARKERS = ( + "transcript_path", + "permission_mode", + "stop_hook_active", + "agent_transcript_path", + "background_tasks", + "session_crons", + "custom_instructions", + "effort", +) + +def looks_like_claude_code(raw): + """True when the payload carries a field only Claude Code has been seen to send.""" + return isinstance(raw, dict) and any(marker in raw for marker in OBSERVED_MARKERS) + +PROBES = {"looks_like_claude_code": looks_like_claude_code} + +def _wire_name(cfg, raw): + for key in cfg["claims"].get("event_key", ()): + name = raw.get(key) + if name is not None: + return name + return None + +def hj_claims(cfg, raw): + """True when this payload matches the entry's marker discipline.""" + if not isinstance(raw, dict): + return False + c = cfg["claims"] + name = _wire_name(cfg, raw) + if name in c.get("accept_names", ()): + return True + if name not in cfg["events"]: + 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 + return False + return True + +def _segment(node, part): + """One path segment: a dict key, or `key[N]` indexing the list under it.""" + if part.endswith("]") and "[" in part: + key, _, index = part[:-1].partition("[") + items = node.get(key) if isinstance(node, dict) else None + i = int(index) + return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None + return node.get(part) if isinstance(node, dict) else None + +def _walk(node, path): + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + if "[]." in path: + head, sub = path.split("[].", 1) + items = _walk(node, head) + if not isinstance(items, (list, tuple)): + return None + joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) + return joined or None + for part in path.split("."): + node = _segment(node, part) + if node is None: + return None + return node + +def _lookup(raw, ti, key): + """One config key: a `tool_input.` path walks the decoded tool input, else the payload.""" + if key.startswith("tool_input."): + return _walk(ti, key[len("tool_input.") :]) + return _walk(raw, key) + +def _field(raw, ti, chain): + value = None + for key in chain: + if value: + break + value = value or _lookup(raw, ti, key) + return value + +_FIELD_META = ("tool_input", "content_only_for_write_tools", "stringify") + +def _tool_input_raw(cfg, raw): + for key in cfg["fields"].get("tool_input", ("tool_input",)): + value = raw.get(key) + if value is not None: + return value + return None + +def _canonical_of(cfg, name): + """The canonical event for one wire name; an entry with no `events` at all (antigravity, + whose payloads never carry one) maps the shape-inferred name back through `wire_events`.""" + events = cfg["events"] + if not events: + return {wire: canonical for canonical, wire in cfg.get("wire_events", {}).items()}.get(name, UNKNOWN) + return events.get(name, UNKNOWN) + +def hj_parse(cfg, raw, wire=None): + """Normalise one payload along the entry's ordered field-fallback chains. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + ti = tool_input_of(_tool_input_raw(cfg, raw)) + fields = {name: _field(raw, ti, chain) for name, chain in cfg["fields"].items() if name not in _FIELD_META} + if cfg["fields"].get("content_only_for_write_tools") and fields.get("tool") not in cfg["tools"].get("write", ()): + fields["content"] = None + if isinstance(fields.get("output"), (dict, list)): + fields["output"] = _json.dumps(fields["output"]) + for name in cfg["fields"].get("stringify", ()): + if fields.get(name) is not None: + fields[name] = str(fields[name]) + return Event( + cfg["agent"], + _canonical_of(cfg, wire if wire is not None else _wire_name(cfg, raw)), + tool=fields.get("tool"), + command=fields.get("command"), + path=fields.get("path"), + content=fields.get("content"), + output=fields.get("output"), + prompt=fields.get("prompt"), + session_id=fields.get("session_id"), + tool_use_id=fields.get("tool_use_id"), + cwd=fields.get("cwd"), + raw=raw, + ) + +def hj_reverse(cfg): + """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" + reverse = {} + for name, canonical in cfg["events"].items(): + if canonical != UNKNOWN: + reverse[canonical] = name + reverse.update(cfg.get("wire_events", {})) + return reverse + +def _context_value(v, decision): + if v.get("context_source") == "context": + return decision.context + if v.get("context_source") == "reason": + return decision.reason + return None + +def _context_body(name, value): + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "additionalContext": value}}), 0 + +def _note_for(v, decision, at_gate, missing_input): + notes = v.get("degrade_notes", {}) + if decision.outcome == ESCALATE: + if degraded_from(decision) == TRANSFORM and "escalate_from_transform" in notes: + return notes["escalate_from_transform"] + if at_gate and "escalate_gate" in notes: + return notes["escalate_gate"] + return notes.get("escalate") + if decision.outcome == TRANSFORM: + if missing_input and "transform_missing_input" in notes: + return notes["transform_missing_input"] + return notes.get("transform") + return None + +def _default_for(v, decision, at_gate, wire=None): + gate_defaults = v.get("gate_reason_defaults", {}) + if wire in gate_defaults: + return gate_defaults[wire] + defaults = v.get("reason_defaults", {}) + key = {DENY: "deny", ESCALATE: "escalate", TRANSFORM: "transform"}.get(decision.outcome, "deny") + if at_gate and key + "_gate" in defaults: + return defaults[key + "_gate"] + return defaults.get(key, "blocked by policy") + +def _refusal_text(v, decision, at_gate, wire=None): + note = _note_for(v, decision, at_gate, decision.updated_input is None) + default = _default_for(v, decision, at_gate, wire) + if note and "%s" in note: + # A template note fills (the reason or its default, the wire event name) itself. + return note % (decision.reason or default, wire) + reason = decision.reason + if v.get("note_style") == "suffix": + reason = reason or default + return "%s (%s)" % (reason, note) if note else reason + text = "%s (%s)" % (reason, note) if reason and note else (note or reason) + return text or default + +def _g1(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 + 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 + if ( + decision.outcome == ESCALATE + and gate["honours_escalate"] + and "escalate" in words + # An escalate the dispatcher degraded a transform into is a block where the entry + # names that degradation (antigravity): prompting would offer the unmodified call. + and not (degraded_from(decision) == TRANSFORM and "escalate_from_transform" in v.get("degrade_notes", {})) + ): + reason = decision.reason or _default_for(v, decision, True, wire) + return _json.dumps({"decision": words["escalate"], "reason": reason}), 0 + out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, False, wire)} + if at_context_event and v.get("context_source") == "context" and decision.context: + out["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": decision.context} + return _json.dumps(out), 0 + +def hj_respond(cfg, decision, event, wire=None): + """(stdout_text, exit_code) in this entry's dialect for the gate the payload names. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + v = cfg["verdicts"] + if wire is None: + wire = _wire_name(cfg, event.raw or {}) + if wire in v.get("empty_object_events", ()): + return _json.dumps({}), 0 + if wire is None: + wire = v.get("default_wire_event") + if wire is None and v.get("missing_wire") == "reverse_map": + wire = hj_reverse(cfg).get(event.event) + name = wire if v.get("echo") == "payload" else hj_reverse(cfg).get(event.event, "PreToolUse") + gate = v["gates"].get(wire) + if gate is None: + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + return "", 0 + if gate["grammar"] == "G2": + return _g2(v, gate, decision, name) + return _g1(v, gate, decision, wire, name) + +_WINDOWS_KEYS = ("commandWindows", "windows") + +def _hook_dict(cfg, command): + entry = {"type": "command", "command": command} + for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): + if key in _WINDOWS_KEYS: + entry[key] = powershell_command(command) + else: + entry[key] = value + return entry + +def 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": + 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) + if not name: + continue + entry = {"hooks": [_hook_dict(cfg, command)]} + if matcher and hook_entry["matcher"]: + entry["matcher"] = matcher + hooks.setdefault(name, []).append(entry) + if hook_entry.get("group"): + return {hook_entry["group"]: hooks} + return hooks if hook_entry.get("bare") else {"hooks": hooks} + + +# ------------------------------------------------------------------------------ +# devin vendor config + engine binding + +AGENT = "devin" + +VENDOR = {'agent': 'devin', 'claims': {'accept_markers': ('prompt_id',), 'accept_names': ('PermissionRequest', 'PostCompaction'), 'event_key': ('hook_event_name',), 'mode': 'marker', 'notes': 'accept_names are names Claude Code never sends, claimed before any marker check; prompt_id is required alongside looks_like_claude_code(raw) being false.', 'reject_probes': ('looks_like_claude_code',)}, 'config_format': 'json', 'config_path': '.devin/hooks.v1.json', 'display': 'Devin', 'events': {'PermissionRequest': 'pre_tool', 'PostToolUse': 'post_tool', 'PreToolUse': 'pre_tool', 'SessionEnd': 'session_end', 'SessionStart': 'session_start', 'Stop': 'stop', 'UserPromptSubmit': 'prompt_submit'}, 'evidence': {'claims': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'hook_json', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content', 'tool_input.new_string'), 'cwd': ('cwd',), 'output': ('tool_output',), 'path': ('tool_input.file_path', 'tool_input.path'), 'prompt': ('prompt',), 'session_id': ('session_id',), 'tool': ('tool_name',)}, 'hook_entry': {'bare': True, 'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'tools': {}, 'verdicts': {'answer_events': ('PermissionRequest', 'PreToolUse', 'Stop', 'UserPromptSubmit'), 'bare_allow': 'unverified', 'context_events': ('PostToolUse', 'SessionStart', 'UserPromptSubmit'), 'context_source': 'reason', 'default_wire_event': 'PreToolUse', 'degrade_notes': {'escalate': 'Devin cannot prompt for confirmation, so this is a block', 'escalate_from_transform': 'Devin cannot modify a tool call, so this is a block'}, 'echo': 'payload', 'gates': {'PermissionRequest': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'PreToolUse': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': True}, 'Stop': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'UserPromptSubmit': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'suffix', 'reason_defaults': {'transform': 'input requires modification before it can run'}, 'transform_grammar': 'hook_specific_updated_input', 'vocabulary': ('approve', 'block'), 'vocabulary_basis': 'verified', 'words': {'allow': 'approve', 'block': 'block'}}, 'wire_events': {'pre_tool': 'PreToolUse'}} + + +def claims(raw): + return hj_claims(VENDOR, raw) + + +def parse(raw): + return hj_parse(VENDOR, raw) + + +def respond(decision, event): + return hj_respond(VENDOR, decision, event) + + +def hook_config(canonical_events, command, matcher=None): + return hook_entry_config(VENDOR, canonical_events, command, matcher) + + +# ------------------------------------------------------------------------------ +# runtime (agentseam dispatch, specialized to devin) + +_VOUCH_SPEAKS = False +_WARN_SPEAKS = False +_TRANSFORM_EVENTS = frozenset(('pre_tool',)) + + +def degrade(decision, event): + """Reduce a decision to what 'devin' can actually honor, honestly. + + This is agentseam.dispatch.degrade(), specialized to one fixed agent so this file needs + no "import agentseam" -- no evidence establishes that an explicit approval word means anything beyond a plain allow here, so vouch degrades to one; see agentseam.allow_semantics. + """ + if decision.outcome == TRANSFORM and event.event not in _TRANSFORM_EVENTS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = TRANSFORM + return Decision.escalate( + decision.reason or "input requires modification before it can run", evidence=evidence + ) + if decision.outcome == VOUCH and not _VOUCH_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = VOUCH + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + if decision.outcome == WARN and not _WARN_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = WARN + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + return decision + + +# >>> agentseam handler >>> +GUARD_VIOLATION = 1 + +_BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') + +GATE_LOG_ENV = 'CHOCK_GATE_LOG' + +_LOG_MAX_BYTES = 1048576 + +_GUARD_TIMEOUT_SECONDS = 30 + +GUARD_BLOCKED = 'blocked' + +GUARD_CLEAN = 'clean' + +GUARD_UNCHECKED = 'unchecked' + +GUARD_ERRORED = 'errored' + +VERDICT_DENY = 'deny' + +VERDICT_ESCALATE = 'escalate' + +def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: + """The `--guard ` argument a vendored runtime was invoked with, or None.""" + if '--guard' in argv: + i = argv.index('--guard') + if i + 1 < len(argv): + return _chock_Path(argv[i + 1]) + return None + +def find_bash(guard: _chock_Path) -> str | None: + """First interpreter that can actually see `guard`, or None.""" + for candidate in _BASH_CANDIDATES: + try: + proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10) + except (OSError, _chock_subprocess.SubprocessError): + continue + if proc.returncode == 0: + return candidate + return None + +def run_guard(guard: _chock_Path, command: str) -> str: + """`GUARD_BLOCKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + try: + args = _chock_shlex.split(command) + except ValueError: + print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) + return GUARD_UNCHECKED + if not args: + return GUARD_UNCHECKED + bash = find_bash(guard) + if bash is None: + print(f'chock: no usable bash found, {guard.name} not checked', file=sys.stderr) + return GUARD_UNCHECKED + try: + env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} + proc = _chock_subprocess.run([bash, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS) + except _chock_subprocess.TimeoutExpired: + print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) + return GUARD_ERRORED + except (OSError, UnicodeError) as exc: + print(f'chock: guard could not run, not checked: {exc}', file=sys.stderr) + return GUARD_ERRORED + if proc.returncode == GUARD_VIOLATION: + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + if not ((proc.stdout or '') + (proc.stderr or '')).strip(): + print(f'chock: blocked by {_chock_Path(guard).name} (guard gave no reason)', file=sys.stderr) + return GUARD_BLOCKED + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout or '').strip().splitlines() + print(f'chock: guard exited {proc.returncode}, not checked' + (f': {detail[0][:120]}' if detail else ''), file=sys.stderr) + return GUARD_ERRORED + return GUARD_CLEAN + +def log_outcome(guard: _chock_Path, tool: str, blocked: bool) -> None: + """Append one outcome record. Best effort: never raises, never changes the verdict.""" + try: + if _chock_os.environ.get(GATE_LOG_ENV) == '0': + return + guard = guard.resolve() + if guard.parent.name != 'implementations': + return + artifact_root = None + for parent in guard.parents: + if (parent / '.chock').is_dir(): + artifact_root = parent / '.chock' + break + if artifact_root is None: + return + log_dir = artifact_root / 'log' + log_dir.mkdir(parents=True, exist_ok=True) + log_path = log_dir / 'gate-events.jsonl' + if log_path.exists() and log_path.stat().st_size > _LOG_MAX_BYTES: + log_path.replace(log_dir / 'gate-events.1.jsonl') + import json + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': 'block' if blocked else 'allow'} + with log_path.open('a', encoding='utf-8') as fh: + fh.write(json.dumps(record, ensure_ascii=False) + '\n') + except Exception: + return + +def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: + """Run the guard named on `argv` (`--guard `) against `command`.""" + guard = guard_path_from_argv(argv) + if guard is None or not guard.exists(): + return None + verdict = run_guard(guard, command) + if verdict in (GUARD_BLOCKED, GUARD_CLEAN): + log_outcome(guard, tool, verdict == GUARD_BLOCKED) + if verdict == GUARD_BLOCKED: + return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + if verdict == GUARD_ERRORED: + return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") + return None + + +def handle(event): + if event.event == "pre_tool" and event.command: + verdict = evaluate(sys.argv[1:], event.command, event.tool or "") + if verdict is not None: + outcome, reason = verdict + return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + return None +# <<< agentseam handler <<< + + +def _coerce(result): + if result is None: + return Decision.allow() + if isinstance(result, Decision): + return result + raise TypeError("handle() must return a Decision or None, got %r" % (type(result),)) + + +def _read_payload(stream): + # Decode bytes ourselves rather than trust the platform locale -- a BOM'd or non-UTF-8 + # stdin must not silently disable the gate. See agentseam.dispatch for the incident + # this guards: a Windows console's cp1252 layer turning a UTF-8 BOM into three bytes + # json cannot parse, with the hook allowing everything while claiming enforcement. + buffer = getattr(stream, "buffer", None) + if buffer is not None: + return buffer.read().decode("utf-8-sig", errors="replace") + return stream.read().lstrip("\ufeff") + + +def _emit(out, text): + buffer = getattr(out, "buffer", None) + if buffer is not None: + buffer.write(text.encode("utf-8")) + buffer.flush() + else: + out.write(text) + out.flush() + + +def main(stdin=None, stdout=None, exit=True): + """Read one payload from stdin, dispatch, emit the 'devin' response, exit.""" + stream = stdin if stdin is not None else sys.stdin + out = stdout if stdout is not None else sys.stdout + try: + raw = json.loads(_read_payload(stream)) + except Exception: + # Malformed input is not the agent's fault to pay for: allow, stay silent. + if exit: + sys.exit(0) + return 0 + event = parse(raw) + if event.event == UNKNOWN: + # A vendor event this adapter has no mapping for. handle() is not called: it + # reasons about the canonical vocabulary, and handing it something outside that + # vocabulary invites a decision made on a false premise. + if exit: + sys.exit(0) + return 0 + decision = degrade(_coerce(handle(event)), event) + text, code = respond(decision, event) + if text: + _emit(out, text) + if exit: + sys.exit(code) + return code + + +if __name__ == "__main__": # pragma: no cover + main() + diff --git a/tests/fixtures/runtime_goldens/gemini_cli.py b/tests/fixtures/runtime_goldens/gemini_cli.py new file mode 100644 index 0000000..f466f97 --- /dev/null +++ b/tests/fixtures/runtime_goldens/gemini_cli.py @@ -0,0 +1,789 @@ +# Generated by agentseam 0.2.0 -- 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. +# +# Self-contained: stdlib only, no "import agentseam" anywhere in this file. Regenerate +# with `agentseam.bundler.bundle("gemini_cli")` (same agentseam version -> identical bytes, +# except your own edits inside the handler block) rather than patching this by hand. +# https://github.com/open-coder-ai/agentseam + +from __future__ import annotations + +import json +_json = json +import sys + +import os as _chock_os +import shlex as _chock_shlex +import subprocess as _chock_subprocess +from datetime import datetime as _chock_datetime, timezone as _chock_timezone +from pathlib import Path as _chock_Path +import warnings as _warnings + +# ------------------------------------------------------------------------------ +# contract (agentseam 0.2.0) + +"""Canonical event vocabulary, normalized envelope, and decision type.""" + + + +SESSION_START = "session_start" +SESSION_END = "session_end" +PROMPT_SUBMIT = "prompt_submit" +PRE_TOOL = "pre_tool" +POST_TOOL = "post_tool" +TOOL_FAILURE = "tool_failure" +PRE_COMPACT = "pre_compact" +STOP = "stop" +SUBAGENT_START = "subagent_start" +SUBAGENT_STOP = "subagent_stop" +INSTRUCTIONS_LOADED = "instructions_loaded" +FILE_CHANGED = "file_changed" + +UNKNOWN = "unknown" + +EVENTS = ( + SESSION_START, + SESSION_END, + PROMPT_SUBMIT, + PRE_TOOL, + POST_TOOL, + TOOL_FAILURE, + PRE_COMPACT, + STOP, + SUBAGENT_START, + SUBAGENT_STOP, + INSTRUCTIONS_LOADED, + FILE_CHANGED, +) + + +class Event: + """One agent lifecycle event, normalized.""" + + __slots__ = ( + "agent", + "event", + "tool", + "command", + "path", + "content", + "output", + "prompt", + "session_id", + "tool_use_id", + "cwd", + "raw", + ) + + def __init__( + self, + agent, + event, + *, + tool=None, + command=None, + path=None, + content=None, + output=None, + prompt=None, + session_id=None, + tool_use_id=None, + cwd=None, + raw=None, + ): + self.agent = agent + self.event = event + self.tool = tool + self.command = command + self.path = path + self.content = content + self.output = output + self.prompt = prompt + self.session_id = session_id + self.tool_use_id = tool_use_id + self.cwd = cwd + self.raw = raw if raw is not None else {} + + def __repr__(self): # pragma: no cover - debugging aid + return "Event(%s/%s tool=%r path=%r)" % (self.agent, self.event, self.tool, self.path) + + +ALLOW = "allow" +DENY = "deny" +ESCALATE = "escalate" +TRANSFORM = "transform" +WARN = "warn" +VOUCH = "vouch" + +# Pre-ACS-alignment names. Same strings as their ACS-named counterparts, so every existing +# `is`/`==` comparison against the old constant keeps working untouched. +ASK = ESCALATE +REWRITE = TRANSFORM + +_CANONICAL_OUTCOMES = (ALLOW, DENY, ESCALATE, TRANSFORM, WARN, VOUCH) + +# The literal spellings a caller might still pass to Decision(outcome, ...) directly, mapped +# to the value that now backs them. Only needed for the raw-string constructor path -- +# Decision.ask()/.rewrite() below build the canonical outcome themselves. +_LEGACY_SPELLING = {"ask": ESCALATE, "rewrite": TRANSFORM} + + +class Decision: + """What a handler wants to happen. Adapters translate this to vendor dialect.""" + + __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + + #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). + DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) + + def __init__(self, outcome, reason=None, updated_input=None, evidence=None, context=None): + outcome = _LEGACY_SPELLING.get(outcome, outcome) + if outcome not in _CANONICAL_OUTCOMES: + raise ValueError("unknown outcome: %r" % (outcome,)) + self.outcome = outcome + self.reason = reason + self.updated_input = updated_input + self.evidence = evidence or {} + self.context = context + + @classmethod + def allow(cls, reason=None, evidence=None, context=None): + return cls(ALLOW, reason, evidence=evidence, context=context) + + @classmethod + def deny(cls, reason, evidence=None, context=None): + return cls(DENY, reason, evidence=evidence, context=context) + + @classmethod + def escalate(cls, reason, evidence=None, context=None): + """Defer the action to the host's own approval path (ACS `escalate`).""" + return cls(ESCALATE, reason, evidence=evidence, context=context) + + @classmethod + def ask(cls, reason, evidence=None, context=None): + """Deprecated alias of escalate() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.ask() is deprecated; use Decision.escalate()", DeprecationWarning, stacklevel=2) + return cls.escalate(reason, evidence=evidence, context=context) + + @classmethod + def transform(cls, updated_input, reason=None, evidence=None, context=None): + """Replace the tool input wholesale (ACS `transform`, at whole-value granularity).""" + return cls(TRANSFORM, reason, updated_input=updated_input, evidence=evidence, context=context) + + @classmethod + def rewrite(cls, updated_input, reason=None, evidence=None, context=None): + """Deprecated alias of transform() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.rewrite() is deprecated; use Decision.transform()", DeprecationWarning, stacklevel=2) + return cls.transform(updated_input, reason, evidence=evidence, context=context) + + @classmethod + def warn(cls, reason=None, evidence=None, context=None): + """Permit the action with no change, recording a warning (ACS `warn`).""" + return cls(WARN, reason, evidence=evidence, context=context) + + @classmethod + def vouch(cls, reason=None, evidence=None, context=None): + return cls(VOUCH, reason, evidence=evidence, context=context) + + def __repr__(self): # pragma: no cover - debugging aid + return "Decision(%s, %r)" % (self.outcome, self.reason) + + +def degraded_from(decision): + """What this decision was before the dispatcher reduced it, or None.""" + return (decision.evidence or {}).get("degraded_from") + + +def tool_input_of(raw): + """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" + if isinstance(raw, dict): + return raw + if isinstance(raw, str) and raw[:1] == "{": + try: + parsed = _json.loads(raw) + except Exception: + return {} + if isinstance(parsed, dict): + return parsed + return {} + + +# ------------------------------------------------------------------------------ +# flat_decision family engine (trimmed to what this entry uses) + +OBSERVED_MARKERS = ( + "transcript_path", + "permission_mode", + "stop_hook_active", + "agent_transcript_path", + "background_tasks", + "session_crons", + "custom_instructions", + "effort", +) + +def looks_like_claude_code(raw): + """True when the payload carries a field only Claude Code has been seen to send.""" + return isinstance(raw, dict) and any(marker in raw for marker in OBSERVED_MARKERS) + +PROBES = {"looks_like_claude_code": looks_like_claude_code} + +def _wire_name(cfg, raw): + for key in cfg["claims"].get("event_key", ()): + name = raw.get(key) + if name is not None: + return name + return None + +def hj_claims(cfg, raw): + """True when this payload matches the entry's marker discipline.""" + if not isinstance(raw, dict): + return False + c = cfg["claims"] + name = _wire_name(cfg, raw) + if name in c.get("accept_names", ()): + return True + if name not in cfg["events"]: + 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 + return False + return True + +def _segment(node, part): + """One path segment: a dict key, or `key[N]` indexing the list under it.""" + if part.endswith("]") and "[" in part: + key, _, index = part[:-1].partition("[") + items = node.get(key) if isinstance(node, dict) else None + i = int(index) + return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None + return node.get(part) if isinstance(node, dict) else None + +def _walk(node, path): + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + if "[]." in path: + head, sub = path.split("[].", 1) + items = _walk(node, head) + if not isinstance(items, (list, tuple)): + return None + joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) + return joined or None + for part in path.split("."): + node = _segment(node, part) + if node is None: + return None + return node + +def _lookup(raw, ti, key): + """One config key: a `tool_input.` path walks the decoded tool input, else the payload.""" + if key.startswith("tool_input."): + return _walk(ti, key[len("tool_input.") :]) + return _walk(raw, key) + +def _field(raw, ti, chain): + value = None + for key in chain: + if value: + break + value = value or _lookup(raw, ti, key) + return value + +_FIELD_META = ("tool_input", "content_only_for_write_tools", "stringify") + +def _tool_input_raw(cfg, raw): + for key in cfg["fields"].get("tool_input", ("tool_input",)): + value = raw.get(key) + if value is not None: + return value + return None + +def _canonical_of(cfg, name): + """The canonical event for one wire name; an entry with no `events` at all (antigravity, + whose payloads never carry one) maps the shape-inferred name back through `wire_events`.""" + events = cfg["events"] + if not events: + return {wire: canonical for canonical, wire in cfg.get("wire_events", {}).items()}.get(name, UNKNOWN) + return events.get(name, UNKNOWN) + +def hj_parse(cfg, raw, wire=None): + """Normalise one payload along the entry's ordered field-fallback chains. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + ti = tool_input_of(_tool_input_raw(cfg, raw)) + fields = {name: _field(raw, ti, chain) for name, chain in cfg["fields"].items() if name not in _FIELD_META} + if cfg["fields"].get("content_only_for_write_tools") and fields.get("tool") not in cfg["tools"].get("write", ()): + fields["content"] = None + if isinstance(fields.get("output"), (dict, list)): + fields["output"] = _json.dumps(fields["output"]) + for name in cfg["fields"].get("stringify", ()): + if fields.get(name) is not None: + fields[name] = str(fields[name]) + return Event( + cfg["agent"], + _canonical_of(cfg, wire if wire is not None else _wire_name(cfg, raw)), + tool=fields.get("tool"), + command=fields.get("command"), + path=fields.get("path"), + content=fields.get("content"), + output=fields.get("output"), + prompt=fields.get("prompt"), + session_id=fields.get("session_id"), + tool_use_id=fields.get("tool_use_id"), + cwd=fields.get("cwd"), + raw=raw, + ) + +def hj_reverse(cfg): + """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" + reverse = {} + for name, canonical in cfg["events"].items(): + if canonical != UNKNOWN: + reverse[canonical] = name + reverse.update(cfg.get("wire_events", {})) + return reverse + +def _context_value(v, decision): + if v.get("context_source") == "context": + return decision.context + if v.get("context_source") == "reason": + return decision.reason + return None + +def _context_body(name, value): + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "additionalContext": value}}), 0 + +def _note_for(v, decision, at_gate, missing_input): + notes = v.get("degrade_notes", {}) + if decision.outcome == ESCALATE: + if degraded_from(decision) == TRANSFORM and "escalate_from_transform" in notes: + return notes["escalate_from_transform"] + if at_gate and "escalate_gate" in notes: + return notes["escalate_gate"] + return notes.get("escalate") + if decision.outcome == TRANSFORM: + if missing_input and "transform_missing_input" in notes: + return notes["transform_missing_input"] + return notes.get("transform") + return None + +def _default_for(v, decision, at_gate, wire=None): + gate_defaults = v.get("gate_reason_defaults", {}) + if wire in gate_defaults: + return gate_defaults[wire] + defaults = v.get("reason_defaults", {}) + key = {DENY: "deny", ESCALATE: "escalate", TRANSFORM: "transform"}.get(decision.outcome, "deny") + if at_gate and key + "_gate" in defaults: + return defaults[key + "_gate"] + return defaults.get(key, "blocked by policy") + +def _refusal_text(v, decision, at_gate, wire=None): + note = _note_for(v, decision, at_gate, decision.updated_input is None) + default = _default_for(v, decision, at_gate, wire) + if note and "%s" in note: + # A template note fills (the reason or its default, the wire event name) itself. + return note % (decision.reason or default, wire) + reason = decision.reason + if v.get("note_style") == "suffix": + reason = reason or default + return "%s (%s)" % (reason, note) if note else reason + text = "%s (%s)" % (reason, note) if reason and note else (note or reason) + return text or default + +def _g1(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 + 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 + if ( + decision.outcome == ESCALATE + and gate["honours_escalate"] + and "escalate" in words + # An escalate the dispatcher degraded a transform into is a block where the entry + # names that degradation (antigravity): prompting would offer the unmodified call. + and not (degraded_from(decision) == TRANSFORM and "escalate_from_transform" in v.get("degrade_notes", {})) + ): + reason = decision.reason or _default_for(v, decision, True, wire) + return _json.dumps({"decision": words["escalate"], "reason": reason}), 0 + out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, False, wire)} + if at_context_event and v.get("context_source") == "context" and decision.context: + out["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": decision.context} + return _json.dumps(out), 0 + +def hj_respond(cfg, decision, event, wire=None): + """(stdout_text, exit_code) in this entry's dialect for the gate the payload names. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + v = cfg["verdicts"] + if wire is None: + wire = _wire_name(cfg, event.raw or {}) + if wire in v.get("empty_object_events", ()): + return _json.dumps({}), 0 + if wire is None: + wire = v.get("default_wire_event") + if wire is None and v.get("missing_wire") == "reverse_map": + wire = hj_reverse(cfg).get(event.event) + name = wire if v.get("echo") == "payload" else hj_reverse(cfg).get(event.event, "PreToolUse") + gate = v["gates"].get(wire) + if gate is None: + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + return "", 0 + if gate["grammar"] == "G2": + return _g2(v, gate, decision, name) + return _g1(v, gate, decision, wire, name) + +_WINDOWS_KEYS = ("commandWindows", "windows") + +def _hook_dict(cfg, command): + entry = {"type": "command", "command": command} + for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): + if key in _WINDOWS_KEYS: + entry[key] = powershell_command(command) + else: + entry[key] = value + return entry + +def 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": + 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) + if not name: + continue + entry = {"hooks": [_hook_dict(cfg, command)]} + if matcher and hook_entry["matcher"]: + entry["matcher"] = matcher + hooks.setdefault(name, []).append(entry) + if hook_entry.get("group"): + return {hook_entry["group"]: hooks} + return hooks if hook_entry.get("bare") else {"hooks": hooks} + + +# ------------------------------------------------------------------------------ +# gemini_cli vendor config + engine binding + +AGENT = "gemini_cli" + +VENDOR = {'agent': 'gemini_cli', 'claims': {'client_types': (None, 'gemini_cli', 'gemini'), 'event_key': ('hook_event_name',), 'mode': 'marker', 'reject_markers': ('timestamp', 'project_path', 'prompt_id', 'turn_id'), 'reject_probes': ('looks_like_claude_code',)}, 'config_format': 'json', 'config_path': '.gemini/settings.json', 'display': 'Gemini CLI', 'events': {'AfterAgent': 'stop', 'AfterTool': 'post_tool', 'BeforeAgent': 'prompt_submit', 'BeforeTool': 'pre_tool', 'PreCompress': 'pre_compact', 'SessionEnd': 'session_end', 'SessionStart': 'session_start'}, 'evidence': {'claims': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'vendor-source', 'date': '2026-08-28', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'flat_decision', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content', 'tool_input.new_string', 'tool_input.new_str'), 'content_only_for_write_tools': True, 'cwd': ('cwd',), 'output': ('tool_output', 'tool_response'), 'path': ('tool_input.file_path', 'tool_input.absolute_path', 'tool_input.path'), 'prompt': ('prompt', 'user_message'), 'session_id': ('session_id',), 'tool': ('tool_name',)}, 'hook_entry': {'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'tools': {'shell': ('run_shell_command',), 'write': ('write_file', 'replace')}, 'verdicts': {'answer_events': ('AfterAgent', 'AfterTool', 'BeforeAgent', 'BeforeTool'), 'bare_allow': 'inert', 'degrade_notes': {'escalate': '%s (confirmation required; %s cannot prompt from a hook)'}, 'gates': {'AfterAgent': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'AfterTool': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'BeforeAgent': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'BeforeTool': {'grammar': 'G1', 'honours_escalate': True, 'honours_transform': True}}, 'reason_defaults': {'escalate': 'policy requires confirmation', 'escalate_gate': 'confirmation required'}, 'transform_grammar': 'hook_specific_tool_input', 'vocabulary': ('allow', 'ask', 'deny'), 'vocabulary_basis': 'verified', 'words': {'allow': 'allow', 'block': 'deny', 'escalate': 'ask'}}} + + +def claims(raw): + return hj_claims(VENDOR, raw) + + +def parse(raw): + return hj_parse(VENDOR, raw) + + +def respond(decision, event): + return hj_respond(VENDOR, decision, event) + + +def hook_config(canonical_events, command, matcher=None): + return hook_entry_config(VENDOR, canonical_events, command, matcher) + + +# ------------------------------------------------------------------------------ +# runtime (agentseam dispatch, specialized to gemini_cli) + +_VOUCH_SPEAKS = False +_WARN_SPEAKS = False +_TRANSFORM_EVENTS = frozenset(('pre_tool',)) + + +def degrade(decision, event): + """Reduce a decision to what 'gemini_cli' can actually honor, honestly. + + This is agentseam.dispatch.degrade(), specialized to one fixed agent so this file needs + no "import agentseam" -- no evidence establishes that an explicit approval word means anything beyond a plain allow here, so vouch degrades to one; see agentseam.allow_semantics. + """ + if decision.outcome == TRANSFORM and event.event not in _TRANSFORM_EVENTS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = TRANSFORM + return Decision.escalate( + decision.reason or "input requires modification before it can run", evidence=evidence + ) + if decision.outcome == VOUCH and not _VOUCH_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = VOUCH + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + if decision.outcome == WARN and not _WARN_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = WARN + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + return decision + + +# >>> agentseam handler >>> +GUARD_VIOLATION = 1 + +_BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') + +GATE_LOG_ENV = 'CHOCK_GATE_LOG' + +_LOG_MAX_BYTES = 1048576 + +_GUARD_TIMEOUT_SECONDS = 30 + +GUARD_BLOCKED = 'blocked' + +GUARD_CLEAN = 'clean' + +GUARD_UNCHECKED = 'unchecked' + +GUARD_ERRORED = 'errored' + +VERDICT_DENY = 'deny' + +VERDICT_ESCALATE = 'escalate' + +def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: + """The `--guard ` argument a vendored runtime was invoked with, or None.""" + if '--guard' in argv: + i = argv.index('--guard') + if i + 1 < len(argv): + return _chock_Path(argv[i + 1]) + return None + +def find_bash(guard: _chock_Path) -> str | None: + """First interpreter that can actually see `guard`, or None.""" + for candidate in _BASH_CANDIDATES: + try: + proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10) + except (OSError, _chock_subprocess.SubprocessError): + continue + if proc.returncode == 0: + return candidate + return None + +def run_guard(guard: _chock_Path, command: str) -> str: + """`GUARD_BLOCKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + try: + args = _chock_shlex.split(command) + except ValueError: + print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) + return GUARD_UNCHECKED + if not args: + return GUARD_UNCHECKED + bash = find_bash(guard) + if bash is None: + print(f'chock: no usable bash found, {guard.name} not checked', file=sys.stderr) + return GUARD_UNCHECKED + try: + env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} + proc = _chock_subprocess.run([bash, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS) + except _chock_subprocess.TimeoutExpired: + print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) + return GUARD_ERRORED + except (OSError, UnicodeError) as exc: + print(f'chock: guard could not run, not checked: {exc}', file=sys.stderr) + return GUARD_ERRORED + if proc.returncode == GUARD_VIOLATION: + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + if not ((proc.stdout or '') + (proc.stderr or '')).strip(): + print(f'chock: blocked by {_chock_Path(guard).name} (guard gave no reason)', file=sys.stderr) + return GUARD_BLOCKED + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout or '').strip().splitlines() + print(f'chock: guard exited {proc.returncode}, not checked' + (f': {detail[0][:120]}' if detail else ''), file=sys.stderr) + return GUARD_ERRORED + return GUARD_CLEAN + +def log_outcome(guard: _chock_Path, tool: str, blocked: bool) -> None: + """Append one outcome record. Best effort: never raises, never changes the verdict.""" + try: + if _chock_os.environ.get(GATE_LOG_ENV) == '0': + return + guard = guard.resolve() + if guard.parent.name != 'implementations': + return + artifact_root = None + for parent in guard.parents: + if (parent / '.chock').is_dir(): + artifact_root = parent / '.chock' + break + if artifact_root is None: + return + log_dir = artifact_root / 'log' + log_dir.mkdir(parents=True, exist_ok=True) + log_path = log_dir / 'gate-events.jsonl' + if log_path.exists() and log_path.stat().st_size > _LOG_MAX_BYTES: + log_path.replace(log_dir / 'gate-events.1.jsonl') + import json + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': 'block' if blocked else 'allow'} + with log_path.open('a', encoding='utf-8') as fh: + fh.write(json.dumps(record, ensure_ascii=False) + '\n') + except Exception: + return + +def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: + """Run the guard named on `argv` (`--guard `) against `command`.""" + guard = guard_path_from_argv(argv) + if guard is None or not guard.exists(): + return None + verdict = run_guard(guard, command) + if verdict in (GUARD_BLOCKED, GUARD_CLEAN): + log_outcome(guard, tool, verdict == GUARD_BLOCKED) + if verdict == GUARD_BLOCKED: + return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + if verdict == GUARD_ERRORED: + return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") + return None + + +def handle(event): + if event.event == "pre_tool" and event.command: + verdict = evaluate(sys.argv[1:], event.command, event.tool or "") + if verdict is not None: + outcome, reason = verdict + return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + return None +# <<< agentseam handler <<< + + +def _coerce(result): + if result is None: + return Decision.allow() + if isinstance(result, Decision): + return result + raise TypeError("handle() must return a Decision or None, got %r" % (type(result),)) + + +def _read_payload(stream): + # Decode bytes ourselves rather than trust the platform locale -- a BOM'd or non-UTF-8 + # stdin must not silently disable the gate. See agentseam.dispatch for the incident + # this guards: a Windows console's cp1252 layer turning a UTF-8 BOM into three bytes + # json cannot parse, with the hook allowing everything while claiming enforcement. + buffer = getattr(stream, "buffer", None) + if buffer is not None: + return buffer.read().decode("utf-8-sig", errors="replace") + return stream.read().lstrip("\ufeff") + + +def _emit(out, text): + buffer = getattr(out, "buffer", None) + if buffer is not None: + buffer.write(text.encode("utf-8")) + buffer.flush() + else: + out.write(text) + out.flush() + + +def main(stdin=None, stdout=None, exit=True): + """Read one payload from stdin, dispatch, emit the 'gemini_cli' response, exit.""" + stream = stdin if stdin is not None else sys.stdin + out = stdout if stdout is not None else sys.stdout + try: + raw = json.loads(_read_payload(stream)) + except Exception: + # Malformed input is not the agent's fault to pay for: allow, stay silent. + if exit: + sys.exit(0) + return 0 + event = parse(raw) + if event.event == UNKNOWN: + # A vendor event this adapter has no mapping for. handle() is not called: it + # reasons about the canonical vocabulary, and handing it something outside that + # vocabulary invites a decision made on a false premise. + if exit: + sys.exit(0) + return 0 + decision = degrade(_coerce(handle(event)), event) + text, code = respond(decision, event) + if text: + _emit(out, text) + if exit: + sys.exit(code) + return code + + +if __name__ == "__main__": # pragma: no cover + main() + diff --git a/tests/fixtures/runtime_goldens/grok.py b/tests/fixtures/runtime_goldens/grok.py new file mode 100644 index 0000000..2ddef51 --- /dev/null +++ b/tests/fixtures/runtime_goldens/grok.py @@ -0,0 +1,772 @@ +# Generated by agentseam 0.2.0 -- 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. +# +# Self-contained: stdlib only, no "import agentseam" anywhere in this file. Regenerate +# with `agentseam.bundler.bundle("grok")` (same agentseam version -> identical bytes, +# except your own edits inside the handler block) rather than patching this by hand. +# https://github.com/open-coder-ai/agentseam + +from __future__ import annotations + +import json +_json = json +import sys + +import os as _chock_os +import shlex as _chock_shlex +import subprocess as _chock_subprocess +from datetime import datetime as _chock_datetime, timezone as _chock_timezone +from pathlib import Path as _chock_Path +import warnings as _warnings + +# ------------------------------------------------------------------------------ +# contract (agentseam 0.2.0) + +"""Canonical event vocabulary, normalized envelope, and decision type.""" + + + +SESSION_START = "session_start" +SESSION_END = "session_end" +PROMPT_SUBMIT = "prompt_submit" +PRE_TOOL = "pre_tool" +POST_TOOL = "post_tool" +TOOL_FAILURE = "tool_failure" +PRE_COMPACT = "pre_compact" +STOP = "stop" +SUBAGENT_START = "subagent_start" +SUBAGENT_STOP = "subagent_stop" +INSTRUCTIONS_LOADED = "instructions_loaded" +FILE_CHANGED = "file_changed" + +UNKNOWN = "unknown" + +EVENTS = ( + SESSION_START, + SESSION_END, + PROMPT_SUBMIT, + PRE_TOOL, + POST_TOOL, + TOOL_FAILURE, + PRE_COMPACT, + STOP, + SUBAGENT_START, + SUBAGENT_STOP, + INSTRUCTIONS_LOADED, + FILE_CHANGED, +) + + +class Event: + """One agent lifecycle event, normalized.""" + + __slots__ = ( + "agent", + "event", + "tool", + "command", + "path", + "content", + "output", + "prompt", + "session_id", + "tool_use_id", + "cwd", + "raw", + ) + + def __init__( + self, + agent, + event, + *, + tool=None, + command=None, + path=None, + content=None, + output=None, + prompt=None, + session_id=None, + tool_use_id=None, + cwd=None, + raw=None, + ): + self.agent = agent + self.event = event + self.tool = tool + self.command = command + self.path = path + self.content = content + self.output = output + self.prompt = prompt + self.session_id = session_id + self.tool_use_id = tool_use_id + self.cwd = cwd + self.raw = raw if raw is not None else {} + + def __repr__(self): # pragma: no cover - debugging aid + return "Event(%s/%s tool=%r path=%r)" % (self.agent, self.event, self.tool, self.path) + + +ALLOW = "allow" +DENY = "deny" +ESCALATE = "escalate" +TRANSFORM = "transform" +WARN = "warn" +VOUCH = "vouch" + +# Pre-ACS-alignment names. Same strings as their ACS-named counterparts, so every existing +# `is`/`==` comparison against the old constant keeps working untouched. +ASK = ESCALATE +REWRITE = TRANSFORM + +_CANONICAL_OUTCOMES = (ALLOW, DENY, ESCALATE, TRANSFORM, WARN, VOUCH) + +# The literal spellings a caller might still pass to Decision(outcome, ...) directly, mapped +# to the value that now backs them. Only needed for the raw-string constructor path -- +# Decision.ask()/.rewrite() below build the canonical outcome themselves. +_LEGACY_SPELLING = {"ask": ESCALATE, "rewrite": TRANSFORM} + + +class Decision: + """What a handler wants to happen. Adapters translate this to vendor dialect.""" + + __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + + #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). + DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) + + def __init__(self, outcome, reason=None, updated_input=None, evidence=None, context=None): + outcome = _LEGACY_SPELLING.get(outcome, outcome) + if outcome not in _CANONICAL_OUTCOMES: + raise ValueError("unknown outcome: %r" % (outcome,)) + self.outcome = outcome + self.reason = reason + self.updated_input = updated_input + self.evidence = evidence or {} + self.context = context + + @classmethod + def allow(cls, reason=None, evidence=None, context=None): + return cls(ALLOW, reason, evidence=evidence, context=context) + + @classmethod + def deny(cls, reason, evidence=None, context=None): + return cls(DENY, reason, evidence=evidence, context=context) + + @classmethod + def escalate(cls, reason, evidence=None, context=None): + """Defer the action to the host's own approval path (ACS `escalate`).""" + return cls(ESCALATE, reason, evidence=evidence, context=context) + + @classmethod + def ask(cls, reason, evidence=None, context=None): + """Deprecated alias of escalate() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.ask() is deprecated; use Decision.escalate()", DeprecationWarning, stacklevel=2) + return cls.escalate(reason, evidence=evidence, context=context) + + @classmethod + def transform(cls, updated_input, reason=None, evidence=None, context=None): + """Replace the tool input wholesale (ACS `transform`, at whole-value granularity).""" + return cls(TRANSFORM, reason, updated_input=updated_input, evidence=evidence, context=context) + + @classmethod + def rewrite(cls, updated_input, reason=None, evidence=None, context=None): + """Deprecated alias of transform() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.rewrite() is deprecated; use Decision.transform()", DeprecationWarning, stacklevel=2) + return cls.transform(updated_input, reason, evidence=evidence, context=context) + + @classmethod + def warn(cls, reason=None, evidence=None, context=None): + """Permit the action with no change, recording a warning (ACS `warn`).""" + return cls(WARN, reason, evidence=evidence, context=context) + + @classmethod + def vouch(cls, reason=None, evidence=None, context=None): + return cls(VOUCH, reason, evidence=evidence, context=context) + + def __repr__(self): # pragma: no cover - debugging aid + return "Decision(%s, %r)" % (self.outcome, self.reason) + + +def degraded_from(decision): + """What this decision was before the dispatcher reduced it, or None.""" + return (decision.evidence or {}).get("degraded_from") + + +def tool_input_of(raw): + """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" + if isinstance(raw, dict): + return raw + if isinstance(raw, str) and raw[:1] == "{": + try: + parsed = _json.loads(raw) + except Exception: + return {} + if isinstance(parsed, dict): + return parsed + return {} + + +# ------------------------------------------------------------------------------ +# flat_decision family engine (trimmed to what this entry uses) + +def _wire_name(cfg, raw): + for key in cfg["claims"].get("event_key", ()): + name = raw.get(key) + if name is not None: + return name + return None + +def hj_claims(cfg, raw): + """True when this payload matches the entry's marker discipline.""" + if not isinstance(raw, dict): + return False + c = cfg["claims"] + name = _wire_name(cfg, raw) + if name in c.get("accept_names", ()): + return True + if name not in cfg["events"]: + 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 + return False + return True + +def _segment(node, part): + """One path segment: a dict key, or `key[N]` indexing the list under it.""" + if part.endswith("]") and "[" in part: + key, _, index = part[:-1].partition("[") + items = node.get(key) if isinstance(node, dict) else None + i = int(index) + return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None + return node.get(part) if isinstance(node, dict) else None + +def _walk(node, path): + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + if "[]." in path: + head, sub = path.split("[].", 1) + items = _walk(node, head) + if not isinstance(items, (list, tuple)): + return None + joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) + return joined or None + for part in path.split("."): + node = _segment(node, part) + if node is None: + return None + return node + +def _lookup(raw, ti, key): + """One config key: a `tool_input.` path walks the decoded tool input, else the payload.""" + if key.startswith("tool_input."): + return _walk(ti, key[len("tool_input.") :]) + return _walk(raw, key) + +def _field(raw, ti, chain): + value = None + for key in chain: + if value: + break + value = value or _lookup(raw, ti, key) + return value + +_FIELD_META = ("tool_input", "content_only_for_write_tools", "stringify") + +def _tool_input_raw(cfg, raw): + for key in cfg["fields"].get("tool_input", ("tool_input",)): + value = raw.get(key) + if value is not None: + return value + return None + +def _canonical_of(cfg, name): + """The canonical event for one wire name; an entry with no `events` at all (antigravity, + whose payloads never carry one) maps the shape-inferred name back through `wire_events`.""" + events = cfg["events"] + if not events: + return {wire: canonical for canonical, wire in cfg.get("wire_events", {}).items()}.get(name, UNKNOWN) + return events.get(name, UNKNOWN) + +def hj_parse(cfg, raw, wire=None): + """Normalise one payload along the entry's ordered field-fallback chains. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + ti = tool_input_of(_tool_input_raw(cfg, raw)) + fields = {name: _field(raw, ti, chain) for name, chain in cfg["fields"].items() if name not in _FIELD_META} + if cfg["fields"].get("content_only_for_write_tools") and fields.get("tool") not in cfg["tools"].get("write", ()): + fields["content"] = None + if isinstance(fields.get("output"), (dict, list)): + fields["output"] = _json.dumps(fields["output"]) + for name in cfg["fields"].get("stringify", ()): + if fields.get(name) is not None: + fields[name] = str(fields[name]) + return Event( + cfg["agent"], + _canonical_of(cfg, wire if wire is not None else _wire_name(cfg, raw)), + tool=fields.get("tool"), + command=fields.get("command"), + path=fields.get("path"), + content=fields.get("content"), + output=fields.get("output"), + prompt=fields.get("prompt"), + session_id=fields.get("session_id"), + tool_use_id=fields.get("tool_use_id"), + cwd=fields.get("cwd"), + raw=raw, + ) + +def hj_reverse(cfg): + """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" + reverse = {} + for name, canonical in cfg["events"].items(): + if canonical != UNKNOWN: + reverse[canonical] = name + reverse.update(cfg.get("wire_events", {})) + return reverse + +def _context_value(v, decision): + if v.get("context_source") == "context": + return decision.context + if v.get("context_source") == "reason": + return decision.reason + return None + +def _context_body(name, value): + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "additionalContext": value}}), 0 + +def _note_for(v, decision, at_gate, missing_input): + notes = v.get("degrade_notes", {}) + if decision.outcome == ESCALATE: + if degraded_from(decision) == TRANSFORM and "escalate_from_transform" in notes: + return notes["escalate_from_transform"] + if at_gate and "escalate_gate" in notes: + return notes["escalate_gate"] + return notes.get("escalate") + if decision.outcome == TRANSFORM: + if missing_input and "transform_missing_input" in notes: + return notes["transform_missing_input"] + return notes.get("transform") + return None + +def _default_for(v, decision, at_gate, wire=None): + gate_defaults = v.get("gate_reason_defaults", {}) + if wire in gate_defaults: + return gate_defaults[wire] + defaults = v.get("reason_defaults", {}) + key = {DENY: "deny", ESCALATE: "escalate", TRANSFORM: "transform"}.get(decision.outcome, "deny") + if at_gate and key + "_gate" in defaults: + return defaults[key + "_gate"] + return defaults.get(key, "blocked by policy") + +def _refusal_text(v, decision, at_gate, wire=None): + note = _note_for(v, decision, at_gate, decision.updated_input is None) + default = _default_for(v, decision, at_gate, wire) + if note and "%s" in note: + # A template note fills (the reason or its default, the wire event name) itself. + return note % (decision.reason or default, wire) + reason = decision.reason + if v.get("note_style") == "suffix": + reason = reason or default + return "%s (%s)" % (reason, note) if note else reason + text = "%s (%s)" % (reason, note) if reason and note else (note or reason) + return text or default + +def _g1(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 + 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 + if ( + decision.outcome == ESCALATE + and gate["honours_escalate"] + and "escalate" in words + # An escalate the dispatcher degraded a transform into is a block where the entry + # names that degradation (antigravity): prompting would offer the unmodified call. + and not (degraded_from(decision) == TRANSFORM and "escalate_from_transform" in v.get("degrade_notes", {})) + ): + reason = decision.reason or _default_for(v, decision, True, wire) + return _json.dumps({"decision": words["escalate"], "reason": reason}), 0 + out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, False, wire)} + if at_context_event and v.get("context_source") == "context" and decision.context: + out["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": decision.context} + return _json.dumps(out), 0 + +def hj_respond(cfg, decision, event, wire=None): + """(stdout_text, exit_code) in this entry's dialect for the gate the payload names. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + v = cfg["verdicts"] + if wire is None: + wire = _wire_name(cfg, event.raw or {}) + if wire in v.get("empty_object_events", ()): + return _json.dumps({}), 0 + if wire is None: + wire = v.get("default_wire_event") + if wire is None and v.get("missing_wire") == "reverse_map": + wire = hj_reverse(cfg).get(event.event) + name = wire if v.get("echo") == "payload" else hj_reverse(cfg).get(event.event, "PreToolUse") + gate = v["gates"].get(wire) + if gate is None: + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + return "", 0 + if gate["grammar"] == "G2": + return _g2(v, gate, decision, name) + return _g1(v, gate, decision, wire, name) + +_WINDOWS_KEYS = ("commandWindows", "windows") + +def _hook_dict(cfg, command): + entry = {"type": "command", "command": command} + for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): + if key in _WINDOWS_KEYS: + entry[key] = powershell_command(command) + else: + entry[key] = value + return entry + +def 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": + 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) + if not name: + continue + entry = {"hooks": [_hook_dict(cfg, command)]} + if matcher and hook_entry["matcher"]: + entry["matcher"] = matcher + hooks.setdefault(name, []).append(entry) + if hook_entry.get("group"): + return {hook_entry["group"]: hooks} + return hooks if hook_entry.get("bare") else {"hooks": hooks} + + +# ------------------------------------------------------------------------------ +# grok vendor config + engine binding + +AGENT = "grok" + +VENDOR = {'agent': 'grok', 'claims': {'event_key': ('hookEventName',), 'mode': 'marker'}, 'config_format': 'json', 'config_path': '.grok/hooks/agentseam.json', 'display': 'Grok CLI', 'events': {'PermissionDenied': 'tool_failure', 'PostCompact': 'pre_compact', 'PostToolUse': 'post_tool', 'PostToolUseFailure': 'tool_failure', 'PreCompact': 'pre_compact', 'PreToolUse': 'pre_tool', 'SessionEnd': 'session_end', 'SessionStart': 'session_start', 'Stop': 'stop', 'StopFailure': 'stop', 'SubagentStart': 'subagent_start', 'SubagentStop': 'subagent_stop', 'UserPromptSubmit': 'prompt_submit'}, 'evidence': {'claims': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'flat_decision', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content', 'tool_input.new_string'), 'cwd': ('cwd', 'workspaceRoot'), 'output': ('toolOutput',), 'path': ('tool_input.file_path', 'tool_input.path'), 'prompt': ('prompt',), 'session_id': ('sessionId',), 'tool': ('toolName',), 'tool_input': ('toolInput',)}, 'hook_entry': {'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': True, 'tools': {}, 'verdicts': {'answer_events': ('PreToolUse',), 'bare_allow': 'silent', 'degrade_notes': {'escalate': 'Grok cannot prompt for confirmation', 'escalate_from_transform': 'Grok cannot modify a tool call', 'transform': 'Grok cannot modify a tool call'}, 'gates': {'PreToolUse': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'because', 'vocabulary': ('deny',), 'vocabulary_basis': 'verified', 'words': {'block': 'deny'}}, 'wire_events': {'pre_compact': 'PreCompact', 'stop': 'Stop', 'tool_failure': 'PostToolUseFailure'}} + + +def claims(raw): + return hj_claims(VENDOR, raw) + + +def parse(raw): + return hj_parse(VENDOR, raw) + + +def respond(decision, event): + return hj_respond(VENDOR, decision, event) + + +def hook_config(canonical_events, command, matcher=None): + return hook_entry_config(VENDOR, canonical_events, command, matcher) + + +# ------------------------------------------------------------------------------ +# runtime (agentseam dispatch, specialized to grok) + +_VOUCH_SPEAKS = False +_WARN_SPEAKS = False +_TRANSFORM_EVENTS = frozenset(()) + + +def degrade(decision, event): + """Reduce a decision to what 'grok' can actually honor, honestly. + + This is agentseam.dispatch.degrade(), specialized to one fixed agent so this file needs + no "import agentseam" -- no evidence establishes that an explicit approval word means anything beyond a plain allow here, so vouch degrades to one; see agentseam.allow_semantics. + """ + if decision.outcome == TRANSFORM and event.event not in _TRANSFORM_EVENTS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = TRANSFORM + return Decision.escalate( + decision.reason or "input requires modification before it can run", evidence=evidence + ) + if decision.outcome == VOUCH and not _VOUCH_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = VOUCH + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + if decision.outcome == WARN and not _WARN_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = WARN + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + return decision + + +# >>> agentseam handler >>> +GUARD_VIOLATION = 1 + +_BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') + +GATE_LOG_ENV = 'CHOCK_GATE_LOG' + +_LOG_MAX_BYTES = 1048576 + +_GUARD_TIMEOUT_SECONDS = 30 + +GUARD_BLOCKED = 'blocked' + +GUARD_CLEAN = 'clean' + +GUARD_UNCHECKED = 'unchecked' + +GUARD_ERRORED = 'errored' + +VERDICT_DENY = 'deny' + +VERDICT_ESCALATE = 'escalate' + +def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: + """The `--guard ` argument a vendored runtime was invoked with, or None.""" + if '--guard' in argv: + i = argv.index('--guard') + if i + 1 < len(argv): + return _chock_Path(argv[i + 1]) + return None + +def find_bash(guard: _chock_Path) -> str | None: + """First interpreter that can actually see `guard`, or None.""" + for candidate in _BASH_CANDIDATES: + try: + proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10) + except (OSError, _chock_subprocess.SubprocessError): + continue + if proc.returncode == 0: + return candidate + return None + +def run_guard(guard: _chock_Path, command: str) -> str: + """`GUARD_BLOCKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + try: + args = _chock_shlex.split(command) + except ValueError: + print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) + return GUARD_UNCHECKED + if not args: + return GUARD_UNCHECKED + bash = find_bash(guard) + if bash is None: + print(f'chock: no usable bash found, {guard.name} not checked', file=sys.stderr) + return GUARD_UNCHECKED + try: + env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} + proc = _chock_subprocess.run([bash, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS) + except _chock_subprocess.TimeoutExpired: + print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) + return GUARD_ERRORED + except (OSError, UnicodeError) as exc: + print(f'chock: guard could not run, not checked: {exc}', file=sys.stderr) + return GUARD_ERRORED + if proc.returncode == GUARD_VIOLATION: + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + if not ((proc.stdout or '') + (proc.stderr or '')).strip(): + print(f'chock: blocked by {_chock_Path(guard).name} (guard gave no reason)', file=sys.stderr) + return GUARD_BLOCKED + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout or '').strip().splitlines() + print(f'chock: guard exited {proc.returncode}, not checked' + (f': {detail[0][:120]}' if detail else ''), file=sys.stderr) + return GUARD_ERRORED + return GUARD_CLEAN + +def log_outcome(guard: _chock_Path, tool: str, blocked: bool) -> None: + """Append one outcome record. Best effort: never raises, never changes the verdict.""" + try: + if _chock_os.environ.get(GATE_LOG_ENV) == '0': + return + guard = guard.resolve() + if guard.parent.name != 'implementations': + return + artifact_root = None + for parent in guard.parents: + if (parent / '.chock').is_dir(): + artifact_root = parent / '.chock' + break + if artifact_root is None: + return + log_dir = artifact_root / 'log' + log_dir.mkdir(parents=True, exist_ok=True) + log_path = log_dir / 'gate-events.jsonl' + if log_path.exists() and log_path.stat().st_size > _LOG_MAX_BYTES: + log_path.replace(log_dir / 'gate-events.1.jsonl') + import json + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': 'block' if blocked else 'allow'} + with log_path.open('a', encoding='utf-8') as fh: + fh.write(json.dumps(record, ensure_ascii=False) + '\n') + except Exception: + return + +def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: + """Run the guard named on `argv` (`--guard `) against `command`.""" + guard = guard_path_from_argv(argv) + if guard is None or not guard.exists(): + return None + verdict = run_guard(guard, command) + if verdict in (GUARD_BLOCKED, GUARD_CLEAN): + log_outcome(guard, tool, verdict == GUARD_BLOCKED) + if verdict == GUARD_BLOCKED: + return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + if verdict == GUARD_ERRORED: + return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") + return None + + +def handle(event): + if event.event == "pre_tool" and event.command: + verdict = evaluate(sys.argv[1:], event.command, event.tool or "") + if verdict is not None: + outcome, reason = verdict + return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + return None +# <<< agentseam handler <<< + + +def _coerce(result): + if result is None: + return Decision.allow() + if isinstance(result, Decision): + return result + raise TypeError("handle() must return a Decision or None, got %r" % (type(result),)) + + +def _read_payload(stream): + # Decode bytes ourselves rather than trust the platform locale -- a BOM'd or non-UTF-8 + # stdin must not silently disable the gate. See agentseam.dispatch for the incident + # this guards: a Windows console's cp1252 layer turning a UTF-8 BOM into three bytes + # json cannot parse, with the hook allowing everything while claiming enforcement. + buffer = getattr(stream, "buffer", None) + if buffer is not None: + return buffer.read().decode("utf-8-sig", errors="replace") + return stream.read().lstrip("\ufeff") + + +def _emit(out, text): + buffer = getattr(out, "buffer", None) + if buffer is not None: + buffer.write(text.encode("utf-8")) + buffer.flush() + else: + out.write(text) + out.flush() + + +def main(stdin=None, stdout=None, exit=True): + """Read one payload from stdin, dispatch, emit the 'grok' response, exit.""" + stream = stdin if stdin is not None else sys.stdin + out = stdout if stdout is not None else sys.stdout + try: + raw = json.loads(_read_payload(stream)) + except Exception: + # Malformed input is not the agent's fault to pay for: allow, stay silent. + if exit: + sys.exit(0) + return 0 + event = parse(raw) + if event.event == UNKNOWN: + # A vendor event this adapter has no mapping for. handle() is not called: it + # reasons about the canonical vocabulary, and handing it something outside that + # vocabulary invites a decision made on a false premise. + if exit: + sys.exit(0) + return 0 + decision = degrade(_coerce(handle(event)), event) + text, code = respond(decision, event) + if text: + _emit(out, text) + if exit: + sys.exit(code) + return code + + +if __name__ == "__main__": # pragma: no cover + main() + diff --git a/tests/fixtures/runtime_goldens/tabnine.py b/tests/fixtures/runtime_goldens/tabnine.py new file mode 100644 index 0000000..23b39e6 --- /dev/null +++ b/tests/fixtures/runtime_goldens/tabnine.py @@ -0,0 +1,772 @@ +# Generated by agentseam 0.2.0 -- 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. +# +# Self-contained: stdlib only, no "import agentseam" anywhere in this file. Regenerate +# with `agentseam.bundler.bundle("tabnine")` (same agentseam version -> identical bytes, +# except your own edits inside the handler block) rather than patching this by hand. +# https://github.com/open-coder-ai/agentseam + +from __future__ import annotations + +import json +_json = json +import sys + +import os as _chock_os +import shlex as _chock_shlex +import subprocess as _chock_subprocess +from datetime import datetime as _chock_datetime, timezone as _chock_timezone +from pathlib import Path as _chock_Path +import warnings as _warnings + +# ------------------------------------------------------------------------------ +# contract (agentseam 0.2.0) + +"""Canonical event vocabulary, normalized envelope, and decision type.""" + + + +SESSION_START = "session_start" +SESSION_END = "session_end" +PROMPT_SUBMIT = "prompt_submit" +PRE_TOOL = "pre_tool" +POST_TOOL = "post_tool" +TOOL_FAILURE = "tool_failure" +PRE_COMPACT = "pre_compact" +STOP = "stop" +SUBAGENT_START = "subagent_start" +SUBAGENT_STOP = "subagent_stop" +INSTRUCTIONS_LOADED = "instructions_loaded" +FILE_CHANGED = "file_changed" + +UNKNOWN = "unknown" + +EVENTS = ( + SESSION_START, + SESSION_END, + PROMPT_SUBMIT, + PRE_TOOL, + POST_TOOL, + TOOL_FAILURE, + PRE_COMPACT, + STOP, + SUBAGENT_START, + SUBAGENT_STOP, + INSTRUCTIONS_LOADED, + FILE_CHANGED, +) + + +class Event: + """One agent lifecycle event, normalized.""" + + __slots__ = ( + "agent", + "event", + "tool", + "command", + "path", + "content", + "output", + "prompt", + "session_id", + "tool_use_id", + "cwd", + "raw", + ) + + def __init__( + self, + agent, + event, + *, + tool=None, + command=None, + path=None, + content=None, + output=None, + prompt=None, + session_id=None, + tool_use_id=None, + cwd=None, + raw=None, + ): + self.agent = agent + self.event = event + self.tool = tool + self.command = command + self.path = path + self.content = content + self.output = output + self.prompt = prompt + self.session_id = session_id + self.tool_use_id = tool_use_id + self.cwd = cwd + self.raw = raw if raw is not None else {} + + def __repr__(self): # pragma: no cover - debugging aid + return "Event(%s/%s tool=%r path=%r)" % (self.agent, self.event, self.tool, self.path) + + +ALLOW = "allow" +DENY = "deny" +ESCALATE = "escalate" +TRANSFORM = "transform" +WARN = "warn" +VOUCH = "vouch" + +# Pre-ACS-alignment names. Same strings as their ACS-named counterparts, so every existing +# `is`/`==` comparison against the old constant keeps working untouched. +ASK = ESCALATE +REWRITE = TRANSFORM + +_CANONICAL_OUTCOMES = (ALLOW, DENY, ESCALATE, TRANSFORM, WARN, VOUCH) + +# The literal spellings a caller might still pass to Decision(outcome, ...) directly, mapped +# to the value that now backs them. Only needed for the raw-string constructor path -- +# Decision.ask()/.rewrite() below build the canonical outcome themselves. +_LEGACY_SPELLING = {"ask": ESCALATE, "rewrite": TRANSFORM} + + +class Decision: + """What a handler wants to happen. Adapters translate this to vendor dialect.""" + + __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + + #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). + DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) + + def __init__(self, outcome, reason=None, updated_input=None, evidence=None, context=None): + outcome = _LEGACY_SPELLING.get(outcome, outcome) + if outcome not in _CANONICAL_OUTCOMES: + raise ValueError("unknown outcome: %r" % (outcome,)) + self.outcome = outcome + self.reason = reason + self.updated_input = updated_input + self.evidence = evidence or {} + self.context = context + + @classmethod + def allow(cls, reason=None, evidence=None, context=None): + return cls(ALLOW, reason, evidence=evidence, context=context) + + @classmethod + def deny(cls, reason, evidence=None, context=None): + return cls(DENY, reason, evidence=evidence, context=context) + + @classmethod + def escalate(cls, reason, evidence=None, context=None): + """Defer the action to the host's own approval path (ACS `escalate`).""" + return cls(ESCALATE, reason, evidence=evidence, context=context) + + @classmethod + def ask(cls, reason, evidence=None, context=None): + """Deprecated alias of escalate() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.ask() is deprecated; use Decision.escalate()", DeprecationWarning, stacklevel=2) + return cls.escalate(reason, evidence=evidence, context=context) + + @classmethod + def transform(cls, updated_input, reason=None, evidence=None, context=None): + """Replace the tool input wholesale (ACS `transform`, at whole-value granularity).""" + return cls(TRANSFORM, reason, updated_input=updated_input, evidence=evidence, context=context) + + @classmethod + def rewrite(cls, updated_input, reason=None, evidence=None, context=None): + """Deprecated alias of transform() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.rewrite() is deprecated; use Decision.transform()", DeprecationWarning, stacklevel=2) + return cls.transform(updated_input, reason, evidence=evidence, context=context) + + @classmethod + def warn(cls, reason=None, evidence=None, context=None): + """Permit the action with no change, recording a warning (ACS `warn`).""" + return cls(WARN, reason, evidence=evidence, context=context) + + @classmethod + def vouch(cls, reason=None, evidence=None, context=None): + return cls(VOUCH, reason, evidence=evidence, context=context) + + def __repr__(self): # pragma: no cover - debugging aid + return "Decision(%s, %r)" % (self.outcome, self.reason) + + +def degraded_from(decision): + """What this decision was before the dispatcher reduced it, or None.""" + return (decision.evidence or {}).get("degraded_from") + + +def tool_input_of(raw): + """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" + if isinstance(raw, dict): + return raw + if isinstance(raw, str) and raw[:1] == "{": + try: + parsed = _json.loads(raw) + except Exception: + return {} + if isinstance(parsed, dict): + return parsed + return {} + + +# ------------------------------------------------------------------------------ +# flat_decision family engine (trimmed to what this entry uses) + +def _wire_name(cfg, raw): + for key in cfg["claims"].get("event_key", ()): + name = raw.get(key) + if name is not None: + return name + return None + +def hj_claims(cfg, raw): + """True when this payload matches the entry's marker discipline.""" + if not isinstance(raw, dict): + return False + c = cfg["claims"] + name = _wire_name(cfg, raw) + if name in c.get("accept_names", ()): + return True + if name not in cfg["events"]: + 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 + return False + return True + +def _segment(node, part): + """One path segment: a dict key, or `key[N]` indexing the list under it.""" + if part.endswith("]") and "[" in part: + key, _, index = part[:-1].partition("[") + items = node.get(key) if isinstance(node, dict) else None + i = int(index) + return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None + return node.get(part) if isinstance(node, dict) else None + +def _walk(node, path): + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + if "[]." in path: + head, sub = path.split("[].", 1) + items = _walk(node, head) + if not isinstance(items, (list, tuple)): + return None + joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) + return joined or None + for part in path.split("."): + node = _segment(node, part) + if node is None: + return None + return node + +def _lookup(raw, ti, key): + """One config key: a `tool_input.` path walks the decoded tool input, else the payload.""" + if key.startswith("tool_input."): + return _walk(ti, key[len("tool_input.") :]) + return _walk(raw, key) + +def _field(raw, ti, chain): + value = None + for key in chain: + if value: + break + value = value or _lookup(raw, ti, key) + return value + +_FIELD_META = ("tool_input", "content_only_for_write_tools", "stringify") + +def _tool_input_raw(cfg, raw): + for key in cfg["fields"].get("tool_input", ("tool_input",)): + value = raw.get(key) + if value is not None: + return value + return None + +def _canonical_of(cfg, name): + """The canonical event for one wire name; an entry with no `events` at all (antigravity, + whose payloads never carry one) maps the shape-inferred name back through `wire_events`.""" + events = cfg["events"] + if not events: + return {wire: canonical for canonical, wire in cfg.get("wire_events", {}).items()}.get(name, UNKNOWN) + return events.get(name, UNKNOWN) + +def hj_parse(cfg, raw, wire=None): + """Normalise one payload along the entry's ordered field-fallback chains. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + ti = tool_input_of(_tool_input_raw(cfg, raw)) + fields = {name: _field(raw, ti, chain) for name, chain in cfg["fields"].items() if name not in _FIELD_META} + if cfg["fields"].get("content_only_for_write_tools") and fields.get("tool") not in cfg["tools"].get("write", ()): + fields["content"] = None + if isinstance(fields.get("output"), (dict, list)): + fields["output"] = _json.dumps(fields["output"]) + for name in cfg["fields"].get("stringify", ()): + if fields.get(name) is not None: + fields[name] = str(fields[name]) + return Event( + cfg["agent"], + _canonical_of(cfg, wire if wire is not None else _wire_name(cfg, raw)), + tool=fields.get("tool"), + command=fields.get("command"), + path=fields.get("path"), + content=fields.get("content"), + output=fields.get("output"), + prompt=fields.get("prompt"), + session_id=fields.get("session_id"), + tool_use_id=fields.get("tool_use_id"), + cwd=fields.get("cwd"), + raw=raw, + ) + +def hj_reverse(cfg): + """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" + reverse = {} + for name, canonical in cfg["events"].items(): + if canonical != UNKNOWN: + reverse[canonical] = name + reverse.update(cfg.get("wire_events", {})) + return reverse + +def _context_value(v, decision): + if v.get("context_source") == "context": + return decision.context + if v.get("context_source") == "reason": + return decision.reason + return None + +def _context_body(name, value): + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "additionalContext": value}}), 0 + +def _note_for(v, decision, at_gate, missing_input): + notes = v.get("degrade_notes", {}) + if decision.outcome == ESCALATE: + if degraded_from(decision) == TRANSFORM and "escalate_from_transform" in notes: + return notes["escalate_from_transform"] + if at_gate and "escalate_gate" in notes: + return notes["escalate_gate"] + return notes.get("escalate") + if decision.outcome == TRANSFORM: + if missing_input and "transform_missing_input" in notes: + return notes["transform_missing_input"] + return notes.get("transform") + return None + +def _default_for(v, decision, at_gate, wire=None): + gate_defaults = v.get("gate_reason_defaults", {}) + if wire in gate_defaults: + return gate_defaults[wire] + defaults = v.get("reason_defaults", {}) + key = {DENY: "deny", ESCALATE: "escalate", TRANSFORM: "transform"}.get(decision.outcome, "deny") + if at_gate and key + "_gate" in defaults: + return defaults[key + "_gate"] + return defaults.get(key, "blocked by policy") + +def _refusal_text(v, decision, at_gate, wire=None): + note = _note_for(v, decision, at_gate, decision.updated_input is None) + default = _default_for(v, decision, at_gate, wire) + if note and "%s" in note: + # A template note fills (the reason or its default, the wire event name) itself. + return note % (decision.reason or default, wire) + reason = decision.reason + if v.get("note_style") == "suffix": + reason = reason or default + return "%s (%s)" % (reason, note) if note else reason + text = "%s (%s)" % (reason, note) if reason and note else (note or reason) + return text or default + +def _g1(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 + 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 + if ( + decision.outcome == ESCALATE + and gate["honours_escalate"] + and "escalate" in words + # An escalate the dispatcher degraded a transform into is a block where the entry + # names that degradation (antigravity): prompting would offer the unmodified call. + and not (degraded_from(decision) == TRANSFORM and "escalate_from_transform" in v.get("degrade_notes", {})) + ): + reason = decision.reason or _default_for(v, decision, True, wire) + return _json.dumps({"decision": words["escalate"], "reason": reason}), 0 + out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, False, wire)} + if at_context_event and v.get("context_source") == "context" and decision.context: + out["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": decision.context} + return _json.dumps(out), 0 + +def hj_respond(cfg, decision, event, wire=None): + """(stdout_text, exit_code) in this entry's dialect for the gate the payload names. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + v = cfg["verdicts"] + if wire is None: + wire = _wire_name(cfg, event.raw or {}) + if wire in v.get("empty_object_events", ()): + return _json.dumps({}), 0 + if wire is None: + wire = v.get("default_wire_event") + if wire is None and v.get("missing_wire") == "reverse_map": + wire = hj_reverse(cfg).get(event.event) + name = wire if v.get("echo") == "payload" else hj_reverse(cfg).get(event.event, "PreToolUse") + gate = v["gates"].get(wire) + if gate is None: + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + return "", 0 + if gate["grammar"] == "G2": + return _g2(v, gate, decision, name) + return _g1(v, gate, decision, wire, name) + +_WINDOWS_KEYS = ("commandWindows", "windows") + +def _hook_dict(cfg, command): + entry = {"type": "command", "command": command} + for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): + if key in _WINDOWS_KEYS: + entry[key] = powershell_command(command) + else: + entry[key] = value + return entry + +def 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": + 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) + if not name: + continue + entry = {"hooks": [_hook_dict(cfg, command)]} + if matcher and hook_entry["matcher"]: + entry["matcher"] = matcher + hooks.setdefault(name, []).append(entry) + if hook_entry.get("group"): + return {hook_entry["group"]: hooks} + return hooks if hook_entry.get("bare") else {"hooks": hooks} + + +# ------------------------------------------------------------------------------ +# tabnine vendor config + engine binding + +AGENT = "tabnine" + +VENDOR = {'agent': 'tabnine', 'claims': {'accept_markers': ('timestamp',), 'event_key': ('hook_event_name',), 'mode': 'marker', 'notes': 'timestamp identifies Tabnine but cannot exclude Gemini CLI, which sends it too (tabnine.py notes); detect() declines when both could claim, and the agent must be named explicitly.'}, 'config_format': 'json', 'config_path': '.tabnine/agent/settings.json', 'display': 'Tabnine CLI', 'events': {'AfterAgent': 'stop', 'AfterTool': 'post_tool', 'BeforeAgent': 'prompt_submit', 'BeforeTool': 'pre_tool', 'PreCompress': 'pre_compact', 'SessionEnd': 'session_end', 'SessionStart': 'session_start'}, 'evidence': {'claims': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'vendor-docs', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'flat_decision', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content', 'tool_input.new_string'), 'cwd': ('cwd',), 'output': ('tool_output', 'tool_response'), 'path': ('tool_input.file_path', 'tool_input.path'), 'prompt': ('prompt',), 'session_id': ('session_id',), 'tool': ('tool_name',)}, 'hook_entry': {'entry_extra': {'name': 'agentseam'}, 'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'tools': {}, 'verdicts': {'answer_events': ('AfterAgent', 'AfterTool', 'BeforeAgent', 'BeforeTool'), 'bare_allow': 'unverified', 'degrade_notes': {'escalate': 'Tabnine cannot prompt for confirmation', 'escalate_from_transform': 'Tabnine cannot modify a tool call', 'transform': 'Tabnine cannot modify a tool call'}, 'gates': {'AfterAgent': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'AfterTool': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'BeforeAgent': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'BeforeTool': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'missing_wire': 'reverse_map', 'note_style': 'because', 'vocabulary': ('allow', 'deny'), 'vocabulary_basis': 'unverified', 'words': {'allow': 'allow', 'block': 'deny'}}} + + +def claims(raw): + return hj_claims(VENDOR, raw) + + +def parse(raw): + return hj_parse(VENDOR, raw) + + +def respond(decision, event): + return hj_respond(VENDOR, decision, event) + + +def hook_config(canonical_events, command, matcher=None): + return hook_entry_config(VENDOR, canonical_events, command, matcher) + + +# ------------------------------------------------------------------------------ +# runtime (agentseam dispatch, specialized to tabnine) + +_VOUCH_SPEAKS = False +_WARN_SPEAKS = False +_TRANSFORM_EVENTS = frozenset(()) + + +def degrade(decision, event): + """Reduce a decision to what 'tabnine' can actually honor, honestly. + + This is agentseam.dispatch.degrade(), specialized to one fixed agent so this file needs + no "import agentseam" -- no evidence establishes that an explicit approval word means anything beyond a plain allow here, so vouch degrades to one; see agentseam.allow_semantics. + """ + if decision.outcome == TRANSFORM and event.event not in _TRANSFORM_EVENTS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = TRANSFORM + return Decision.escalate( + decision.reason or "input requires modification before it can run", evidence=evidence + ) + if decision.outcome == VOUCH and not _VOUCH_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = VOUCH + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + if decision.outcome == WARN and not _WARN_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = WARN + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + return decision + + +# >>> agentseam handler >>> +GUARD_VIOLATION = 1 + +_BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') + +GATE_LOG_ENV = 'CHOCK_GATE_LOG' + +_LOG_MAX_BYTES = 1048576 + +_GUARD_TIMEOUT_SECONDS = 30 + +GUARD_BLOCKED = 'blocked' + +GUARD_CLEAN = 'clean' + +GUARD_UNCHECKED = 'unchecked' + +GUARD_ERRORED = 'errored' + +VERDICT_DENY = 'deny' + +VERDICT_ESCALATE = 'escalate' + +def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: + """The `--guard ` argument a vendored runtime was invoked with, or None.""" + if '--guard' in argv: + i = argv.index('--guard') + if i + 1 < len(argv): + return _chock_Path(argv[i + 1]) + return None + +def find_bash(guard: _chock_Path) -> str | None: + """First interpreter that can actually see `guard`, or None.""" + for candidate in _BASH_CANDIDATES: + try: + proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10) + except (OSError, _chock_subprocess.SubprocessError): + continue + if proc.returncode == 0: + return candidate + return None + +def run_guard(guard: _chock_Path, command: str) -> str: + """`GUARD_BLOCKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + try: + args = _chock_shlex.split(command) + except ValueError: + print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) + return GUARD_UNCHECKED + if not args: + return GUARD_UNCHECKED + bash = find_bash(guard) + if bash is None: + print(f'chock: no usable bash found, {guard.name} not checked', file=sys.stderr) + return GUARD_UNCHECKED + try: + env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} + proc = _chock_subprocess.run([bash, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS) + except _chock_subprocess.TimeoutExpired: + print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) + return GUARD_ERRORED + except (OSError, UnicodeError) as exc: + print(f'chock: guard could not run, not checked: {exc}', file=sys.stderr) + return GUARD_ERRORED + if proc.returncode == GUARD_VIOLATION: + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + if not ((proc.stdout or '') + (proc.stderr or '')).strip(): + print(f'chock: blocked by {_chock_Path(guard).name} (guard gave no reason)', file=sys.stderr) + return GUARD_BLOCKED + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout or '').strip().splitlines() + print(f'chock: guard exited {proc.returncode}, not checked' + (f': {detail[0][:120]}' if detail else ''), file=sys.stderr) + return GUARD_ERRORED + return GUARD_CLEAN + +def log_outcome(guard: _chock_Path, tool: str, blocked: bool) -> None: + """Append one outcome record. Best effort: never raises, never changes the verdict.""" + try: + if _chock_os.environ.get(GATE_LOG_ENV) == '0': + return + guard = guard.resolve() + if guard.parent.name != 'implementations': + return + artifact_root = None + for parent in guard.parents: + if (parent / '.chock').is_dir(): + artifact_root = parent / '.chock' + break + if artifact_root is None: + return + log_dir = artifact_root / 'log' + log_dir.mkdir(parents=True, exist_ok=True) + log_path = log_dir / 'gate-events.jsonl' + if log_path.exists() and log_path.stat().st_size > _LOG_MAX_BYTES: + log_path.replace(log_dir / 'gate-events.1.jsonl') + import json + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': 'block' if blocked else 'allow'} + with log_path.open('a', encoding='utf-8') as fh: + fh.write(json.dumps(record, ensure_ascii=False) + '\n') + except Exception: + return + +def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: + """Run the guard named on `argv` (`--guard `) against `command`.""" + guard = guard_path_from_argv(argv) + if guard is None or not guard.exists(): + return None + verdict = run_guard(guard, command) + if verdict in (GUARD_BLOCKED, GUARD_CLEAN): + log_outcome(guard, tool, verdict == GUARD_BLOCKED) + if verdict == GUARD_BLOCKED: + return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + if verdict == GUARD_ERRORED: + return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") + return None + + +def handle(event): + if event.event == "pre_tool" and event.command: + verdict = evaluate(sys.argv[1:], event.command, event.tool or "") + if verdict is not None: + outcome, reason = verdict + return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + return None +# <<< agentseam handler <<< + + +def _coerce(result): + if result is None: + return Decision.allow() + if isinstance(result, Decision): + return result + raise TypeError("handle() must return a Decision or None, got %r" % (type(result),)) + + +def _read_payload(stream): + # Decode bytes ourselves rather than trust the platform locale -- a BOM'd or non-UTF-8 + # stdin must not silently disable the gate. See agentseam.dispatch for the incident + # this guards: a Windows console's cp1252 layer turning a UTF-8 BOM into three bytes + # json cannot parse, with the hook allowing everything while claiming enforcement. + buffer = getattr(stream, "buffer", None) + if buffer is not None: + return buffer.read().decode("utf-8-sig", errors="replace") + return stream.read().lstrip("\ufeff") + + +def _emit(out, text): + buffer = getattr(out, "buffer", None) + if buffer is not None: + buffer.write(text.encode("utf-8")) + buffer.flush() + else: + out.write(text) + out.flush() + + +def main(stdin=None, stdout=None, exit=True): + """Read one payload from stdin, dispatch, emit the 'tabnine' response, exit.""" + stream = stdin if stdin is not None else sys.stdin + out = stdout if stdout is not None else sys.stdout + try: + raw = json.loads(_read_payload(stream)) + except Exception: + # Malformed input is not the agent's fault to pay for: allow, stay silent. + if exit: + sys.exit(0) + return 0 + event = parse(raw) + if event.event == UNKNOWN: + # A vendor event this adapter has no mapping for. handle() is not called: it + # reasons about the canonical vocabulary, and handing it something outside that + # vocabulary invites a decision made on a false premise. + if exit: + sys.exit(0) + return 0 + decision = degrade(_coerce(handle(event)), event) + text, code = respond(decision, event) + if text: + _emit(out, text) + if exit: + sys.exit(code) + return code + + +if __name__ == "__main__": # pragma: no cover + main() + diff --git a/tests/fixtures/runtime_goldens/vscode_copilot.py b/tests/fixtures/runtime_goldens/vscode_copilot.py new file mode 100644 index 0000000..1fb5e66 --- /dev/null +++ b/tests/fixtures/runtime_goldens/vscode_copilot.py @@ -0,0 +1,623 @@ +# Generated by agentseam 0.2.0 -- 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. +# +# Self-contained: stdlib only, no "import agentseam" anywhere in this file. Regenerate +# with `agentseam.bundler.bundle("vscode_copilot")` (same agentseam version -> identical bytes, +# except your own edits inside the handler block) rather than patching this by hand. +# https://github.com/open-coder-ai/agentseam + +from __future__ import annotations + +import json +_json = json +import sys + +import os as _chock_os +import shlex as _chock_shlex +import subprocess as _chock_subprocess +from datetime import datetime as _chock_datetime, timezone as _chock_timezone +from pathlib import Path as _chock_Path +import warnings as _warnings + +# ------------------------------------------------------------------------------ +# contract (agentseam 0.2.0) + +"""Canonical event vocabulary, normalized envelope, and decision type.""" + + + +SESSION_START = "session_start" +SESSION_END = "session_end" +PROMPT_SUBMIT = "prompt_submit" +PRE_TOOL = "pre_tool" +POST_TOOL = "post_tool" +TOOL_FAILURE = "tool_failure" +PRE_COMPACT = "pre_compact" +STOP = "stop" +SUBAGENT_START = "subagent_start" +SUBAGENT_STOP = "subagent_stop" +INSTRUCTIONS_LOADED = "instructions_loaded" +FILE_CHANGED = "file_changed" + +UNKNOWN = "unknown" + +EVENTS = ( + SESSION_START, + SESSION_END, + PROMPT_SUBMIT, + PRE_TOOL, + POST_TOOL, + TOOL_FAILURE, + PRE_COMPACT, + STOP, + SUBAGENT_START, + SUBAGENT_STOP, + INSTRUCTIONS_LOADED, + FILE_CHANGED, +) + + +class Event: + """One agent lifecycle event, normalized.""" + + __slots__ = ( + "agent", + "event", + "tool", + "command", + "path", + "content", + "output", + "prompt", + "session_id", + "tool_use_id", + "cwd", + "raw", + ) + + def __init__( + self, + agent, + event, + *, + tool=None, + command=None, + path=None, + content=None, + output=None, + prompt=None, + session_id=None, + tool_use_id=None, + cwd=None, + raw=None, + ): + self.agent = agent + self.event = event + self.tool = tool + self.command = command + self.path = path + self.content = content + self.output = output + self.prompt = prompt + self.session_id = session_id + self.tool_use_id = tool_use_id + self.cwd = cwd + self.raw = raw if raw is not None else {} + + def __repr__(self): # pragma: no cover - debugging aid + return "Event(%s/%s tool=%r path=%r)" % (self.agent, self.event, self.tool, self.path) + + +ALLOW = "allow" +DENY = "deny" +ESCALATE = "escalate" +TRANSFORM = "transform" +WARN = "warn" +VOUCH = "vouch" + +# Pre-ACS-alignment names. Same strings as their ACS-named counterparts, so every existing +# `is`/`==` comparison against the old constant keeps working untouched. +ASK = ESCALATE +REWRITE = TRANSFORM + +_CANONICAL_OUTCOMES = (ALLOW, DENY, ESCALATE, TRANSFORM, WARN, VOUCH) + +# The literal spellings a caller might still pass to Decision(outcome, ...) directly, mapped +# to the value that now backs them. Only needed for the raw-string constructor path -- +# Decision.ask()/.rewrite() below build the canonical outcome themselves. +_LEGACY_SPELLING = {"ask": ESCALATE, "rewrite": TRANSFORM} + + +class Decision: + """What a handler wants to happen. Adapters translate this to vendor dialect.""" + + __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + + #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). + DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) + + def __init__(self, outcome, reason=None, updated_input=None, evidence=None, context=None): + outcome = _LEGACY_SPELLING.get(outcome, outcome) + if outcome not in _CANONICAL_OUTCOMES: + raise ValueError("unknown outcome: %r" % (outcome,)) + self.outcome = outcome + self.reason = reason + self.updated_input = updated_input + self.evidence = evidence or {} + self.context = context + + @classmethod + def allow(cls, reason=None, evidence=None, context=None): + return cls(ALLOW, reason, evidence=evidence, context=context) + + @classmethod + def deny(cls, reason, evidence=None, context=None): + return cls(DENY, reason, evidence=evidence, context=context) + + @classmethod + def escalate(cls, reason, evidence=None, context=None): + """Defer the action to the host's own approval path (ACS `escalate`).""" + return cls(ESCALATE, reason, evidence=evidence, context=context) + + @classmethod + def ask(cls, reason, evidence=None, context=None): + """Deprecated alias of escalate() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.ask() is deprecated; use Decision.escalate()", DeprecationWarning, stacklevel=2) + return cls.escalate(reason, evidence=evidence, context=context) + + @classmethod + def transform(cls, updated_input, reason=None, evidence=None, context=None): + """Replace the tool input wholesale (ACS `transform`, at whole-value granularity).""" + return cls(TRANSFORM, reason, updated_input=updated_input, evidence=evidence, context=context) + + @classmethod + def rewrite(cls, updated_input, reason=None, evidence=None, context=None): + """Deprecated alias of transform() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.rewrite() is deprecated; use Decision.transform()", DeprecationWarning, stacklevel=2) + return cls.transform(updated_input, reason, evidence=evidence, context=context) + + @classmethod + def warn(cls, reason=None, evidence=None, context=None): + """Permit the action with no change, recording a warning (ACS `warn`).""" + return cls(WARN, reason, evidence=evidence, context=context) + + @classmethod + def vouch(cls, reason=None, evidence=None, context=None): + return cls(VOUCH, reason, evidence=evidence, context=context) + + def __repr__(self): # pragma: no cover - debugging aid + return "Decision(%s, %r)" % (self.outcome, self.reason) + + +def degraded_from(decision): + """What this decision was before the dispatcher reduced it, or None.""" + return (decision.evidence or {}).get("degraded_from") + + +def tool_input_of(raw): + """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" + if isinstance(raw, dict): + return raw + if isinstance(raw, str) and raw[:1] == "{": + try: + parsed = _json.loads(raw) + except Exception: + return {} + if isinstance(parsed, dict): + return parsed + return {} + + +# ------------------------------------------------------------------------------ +# windows helper (used by the vscode_copilot adapter) + +"""PowerShell's one rule that breaks hook commands, shared by the vendors it affects.""" + + + +def powershell_command(command): + """`command` rewritten so PowerShell will actually run it.""" + return command if command.lstrip().startswith("&") else "& " + command + + +# ------------------------------------------------------------------------------ +# vscode_copilot adapter + +"""VS Code Copilot (agent mode) and GitHub Copilot CLI adapter.""" + + + +AGENT = "vscode_copilot" + +EVENT_MAP = { + "PreToolUse": PRE_TOOL, + "PostToolUse": POST_TOOL, + "UserPromptSubmit": PROMPT_SUBMIT, + "SessionStart": SESSION_START, + "SubagentStart": SUBAGENT_START, + "SubagentStop": SUBAGENT_STOP, + "Stop": STOP, + "preToolUse": PRE_TOOL, + "postToolUse": POST_TOOL, + "userPromptSubmitted": PROMPT_SUBMIT, + "sessionStart": SESSION_START, + "sessionEnd": SESSION_END, + "subagentStop": SUBAGENT_STOP, + "agentStop": STOP, +} + +MEMORY_TOOLS = ("memory", "copilot_memory") +MEMORY_WRITE_COMMANDS = ("create", "str_replace", "insert") + + +_CODEX_MARKERS = ("turn_id", "permission_mode") + +_CURSOR_MARKERS = ("model", "cursor_version", "conversation_id", "generation_id", "workspace_roots") + +_CLAIMABLE = tuple(name for name in EVENT_MAP if name[:1].islower()) + +_VSCODE_ENVELOPE = "timestamp" + + +def claims(raw): + """True for a payload from either product.""" + if not isinstance(raw, dict): + return False + name = raw.get("hook_event_name") or raw.get("hookEventName") + if name in EVENT_MAP and _VSCODE_ENVELOPE in raw and "turn_id" not in raw: + return True + if any(k in raw for k in _CODEX_MARKERS + _CURSOR_MARKERS): + return False + if name in _CLAIMABLE: + return True + ti = raw.get("tool_input") + return raw.get("tool_name") in MEMORY_TOOLS and isinstance(ti, dict) and "command" in ti + + +def parse(raw): + ti = raw.get("tool_input") + ti = tool_input_of(ti) + tool = raw.get("tool_name") or raw.get("toolName") + path = content = None + if tool in MEMORY_TOOLS: + if ti.get("command") in MEMORY_WRITE_COMMANDS: + path = ti.get("path") or "/memories/" + content = ti.get("file_text") or ti.get("new_str") or ti.get("insert_text") + else: + path = ti.get("path") + else: + path = ti.get("filePath") or ti.get("file_path") or ti.get("path") + content = ti.get("content") or ti.get("newText") or ti.get("new_str") + name = raw.get("hook_event_name") or raw.get("hookEventName") or "preToolUse" + return Event( + AGENT, + EVENT_MAP.get(name, UNKNOWN), + tool=tool, + command=ti.get("command") if tool not in MEMORY_TOOLS else None, + path=path, + content=content, + prompt=raw.get("prompt"), + output=raw.get("tool_output") or raw.get("tool_response"), + session_id=raw.get("session_id"), + tool_use_id=raw.get("tool_use_id"), + cwd=raw.get("cwd"), + raw=raw, + ) + + +def is_memory_write(event): + """True when this event is a memory-tool content write (VS Code's memory surface).""" + ti = event.raw.get("tool_input") + ti = tool_input_of(ti) + return event.tool in MEMORY_TOOLS and ti.get("command") in MEMORY_WRITE_COMMANDS + + +_TOP_LEVEL_BLOCK = (PROMPT_SUBMIT, POST_TOOL) + +_NESTED_BLOCK = (STOP, SUBAGENT_STOP) + + +def _echoed_name(event): + """This event's own vendor spelling, out of the payload; VS Code's name if there is none.""" + raw = event.raw or {} + return raw.get("hook_event_name") or raw.get("hookEventName") or REVERSE_EVENT_MAP.get(event.event, "PreToolUse") + + +def _refusal_reason(decision): + """One reason string for the block dialects, which have no ask and no rewrite.""" + reason = decision.reason or "blocked by policy" + if decision.outcome == ASK: + return reason + " (confirmation requested; this event cannot prompt, so it blocks)" + if decision.outcome == REWRITE: + return reason + " (input rewrite requested; this event cannot modify input, so it blocks)" + return reason + + +DECISION_VOCABULARY = frozenset({"allow", "deny", "ask", "block"}) + + +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 + + 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 + + if decision.outcome == ALLOW: + return "", 0 + + out = {"hookEventName": _echoed_name(event)} + if decision.outcome == VOUCH: + out["permissionDecision"] = "allow" + if decision.reason: + out["permissionDecisionReason"] = decision.reason + elif decision.outcome == DENY: + out["permissionDecision"] = "deny" + out["permissionDecisionReason"] = decision.reason or "blocked" + elif decision.outcome == ASK: + out["permissionDecision"] = "ask" + out["permissionDecisionReason"] = 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 + + +REVERSE_EVENT_MAP = { + PRE_TOOL: "PreToolUse", + POST_TOOL: "PostToolUse", + PROMPT_SUBMIT: "UserPromptSubmit", + SESSION_START: "SessionStart", + SUBAGENT_START: "SubagentStart", + SUBAGENT_STOP: "SubagentStop", + STOP: "Stop", +} + + +def hook_config(canonical_events, command, matcher=None): + """The hooks file VS Code actually parses: an object keyed by event name.""" + hooks = {} + for ev in canonical_events: + name = REVERSE_EVENT_MAP.get(ev) + if name: + entry = {"type": "command", "command": command, "windows": powershell_command(command)} + hooks.setdefault(name, []).append(entry) + return {"hooks": hooks} + + +CONFIG_PATH = ".github/hooks/agentseam.json" + + +# ------------------------------------------------------------------------------ +# runtime (agentseam dispatch, specialized to vscode_copilot) + +_VOUCH_SPEAKS = True +_WARN_SPEAKS = False +_TRANSFORM_EVENTS = frozenset(('pre_tool',)) + + +def degrade(decision, event): + """Reduce a decision to what 'vscode_copilot' can actually honor, honestly. + + This is agentseam.dispatch.degrade(), specialized to one fixed agent so this file needs + no "import agentseam" -- claude_code and vscode_copilot are the only agents with real evidence that an explicit approval word means "skip confirmation"; see agentseam.allow_semantics. + """ + if decision.outcome == TRANSFORM and event.event not in _TRANSFORM_EVENTS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = TRANSFORM + return Decision.escalate( + decision.reason or "input requires modification before it can run", evidence=evidence + ) + if decision.outcome == VOUCH and not _VOUCH_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = VOUCH + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + if decision.outcome == WARN and not _WARN_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = WARN + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + return decision + + +# >>> agentseam handler >>> +GUARD_VIOLATION = 1 + +_BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') + +GATE_LOG_ENV = 'CHOCK_GATE_LOG' + +_LOG_MAX_BYTES = 1048576 + +_GUARD_TIMEOUT_SECONDS = 30 + +GUARD_BLOCKED = 'blocked' + +GUARD_CLEAN = 'clean' + +GUARD_UNCHECKED = 'unchecked' + +GUARD_ERRORED = 'errored' + +VERDICT_DENY = 'deny' + +VERDICT_ESCALATE = 'escalate' + +def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: + """The `--guard ` argument a vendored runtime was invoked with, or None.""" + if '--guard' in argv: + i = argv.index('--guard') + if i + 1 < len(argv): + return _chock_Path(argv[i + 1]) + return None + +def find_bash(guard: _chock_Path) -> str | None: + """First interpreter that can actually see `guard`, or None.""" + for candidate in _BASH_CANDIDATES: + try: + proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10) + except (OSError, _chock_subprocess.SubprocessError): + continue + if proc.returncode == 0: + return candidate + return None + +def run_guard(guard: _chock_Path, command: str) -> str: + """`GUARD_BLOCKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + try: + args = _chock_shlex.split(command) + except ValueError: + print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) + return GUARD_UNCHECKED + if not args: + return GUARD_UNCHECKED + bash = find_bash(guard) + if bash is None: + print(f'chock: no usable bash found, {guard.name} not checked', file=sys.stderr) + return GUARD_UNCHECKED + try: + env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} + proc = _chock_subprocess.run([bash, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS) + except _chock_subprocess.TimeoutExpired: + print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) + return GUARD_ERRORED + except (OSError, UnicodeError) as exc: + print(f'chock: guard could not run, not checked: {exc}', file=sys.stderr) + return GUARD_ERRORED + if proc.returncode == GUARD_VIOLATION: + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + if not ((proc.stdout or '') + (proc.stderr or '')).strip(): + print(f'chock: blocked by {_chock_Path(guard).name} (guard gave no reason)', file=sys.stderr) + return GUARD_BLOCKED + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout or '').strip().splitlines() + print(f'chock: guard exited {proc.returncode}, not checked' + (f': {detail[0][:120]}' if detail else ''), file=sys.stderr) + return GUARD_ERRORED + return GUARD_CLEAN + +def log_outcome(guard: _chock_Path, tool: str, blocked: bool) -> None: + """Append one outcome record. Best effort: never raises, never changes the verdict.""" + try: + if _chock_os.environ.get(GATE_LOG_ENV) == '0': + return + guard = guard.resolve() + if guard.parent.name != 'implementations': + return + artifact_root = None + for parent in guard.parents: + if (parent / '.chock').is_dir(): + artifact_root = parent / '.chock' + break + if artifact_root is None: + return + log_dir = artifact_root / 'log' + log_dir.mkdir(parents=True, exist_ok=True) + log_path = log_dir / 'gate-events.jsonl' + if log_path.exists() and log_path.stat().st_size > _LOG_MAX_BYTES: + log_path.replace(log_dir / 'gate-events.1.jsonl') + import json + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': 'block' if blocked else 'allow'} + with log_path.open('a', encoding='utf-8') as fh: + fh.write(json.dumps(record, ensure_ascii=False) + '\n') + except Exception: + return + +def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: + """Run the guard named on `argv` (`--guard `) against `command`.""" + guard = guard_path_from_argv(argv) + if guard is None or not guard.exists(): + return None + verdict = run_guard(guard, command) + if verdict in (GUARD_BLOCKED, GUARD_CLEAN): + log_outcome(guard, tool, verdict == GUARD_BLOCKED) + if verdict == GUARD_BLOCKED: + return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + if verdict == GUARD_ERRORED: + return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") + return None + + +def handle(event): + if event.event == "pre_tool" and event.command: + verdict = evaluate(sys.argv[1:], event.command, event.tool or "") + if verdict is not None: + outcome, reason = verdict + return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + return None +# <<< agentseam handler <<< + + +def _coerce(result): + if result is None: + return Decision.allow() + if isinstance(result, Decision): + return result + raise TypeError("handle() must return a Decision or None, got %r" % (type(result),)) + + +def _read_payload(stream): + # Decode bytes ourselves rather than trust the platform locale -- a BOM'd or non-UTF-8 + # stdin must not silently disable the gate. See agentseam.dispatch for the incident + # this guards: a Windows console's cp1252 layer turning a UTF-8 BOM into three bytes + # json cannot parse, with the hook allowing everything while claiming enforcement. + buffer = getattr(stream, "buffer", None) + if buffer is not None: + return buffer.read().decode("utf-8-sig", errors="replace") + return stream.read().lstrip("\ufeff") + + +def _emit(out, text): + buffer = getattr(out, "buffer", None) + if buffer is not None: + buffer.write(text.encode("utf-8")) + buffer.flush() + else: + out.write(text) + out.flush() + + +def main(stdin=None, stdout=None, exit=True): + """Read one payload from stdin, dispatch, emit the 'vscode_copilot' response, exit.""" + stream = stdin if stdin is not None else sys.stdin + out = stdout if stdout is not None else sys.stdout + try: + raw = json.loads(_read_payload(stream)) + except Exception: + # Malformed input is not the agent's fault to pay for: allow, stay silent. + if exit: + sys.exit(0) + return 0 + event = parse(raw) + if event.event == UNKNOWN: + # A vendor event this adapter has no mapping for. handle() is not called: it + # reasons about the canonical vocabulary, and handing it something outside that + # vocabulary invites a decision made on a false premise. + if exit: + sys.exit(0) + return 0 + decision = degrade(_coerce(handle(event)), event) + text, code = respond(decision, event) + if text: + _emit(out, text) + if exit: + sys.exit(code) + return code + + +if __name__ == "__main__": # pragma: no cover + main() + diff --git a/tests/fixtures/runtime_goldens/windsurf.py b/tests/fixtures/runtime_goldens/windsurf.py new file mode 100644 index 0000000..301ebb4 --- /dev/null +++ b/tests/fixtures/runtime_goldens/windsurf.py @@ -0,0 +1,701 @@ +# Generated by agentseam 0.2.0 -- 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. +# +# Self-contained: stdlib only, no "import agentseam" anywhere in this file. Regenerate +# with `agentseam.bundler.bundle("windsurf")` (same agentseam version -> identical bytes, +# except your own edits inside the handler block) rather than patching this by hand. +# https://github.com/open-coder-ai/agentseam + +from __future__ import annotations + +import json +_json = json +import sys + +import os as _chock_os +import shlex as _chock_shlex +import subprocess as _chock_subprocess +from datetime import datetime as _chock_datetime, timezone as _chock_timezone +from pathlib import Path as _chock_Path +import warnings as _warnings + +# ------------------------------------------------------------------------------ +# contract (agentseam 0.2.0) + +"""Canonical event vocabulary, normalized envelope, and decision type.""" + + + +SESSION_START = "session_start" +SESSION_END = "session_end" +PROMPT_SUBMIT = "prompt_submit" +PRE_TOOL = "pre_tool" +POST_TOOL = "post_tool" +TOOL_FAILURE = "tool_failure" +PRE_COMPACT = "pre_compact" +STOP = "stop" +SUBAGENT_START = "subagent_start" +SUBAGENT_STOP = "subagent_stop" +INSTRUCTIONS_LOADED = "instructions_loaded" +FILE_CHANGED = "file_changed" + +UNKNOWN = "unknown" + +EVENTS = ( + SESSION_START, + SESSION_END, + PROMPT_SUBMIT, + PRE_TOOL, + POST_TOOL, + TOOL_FAILURE, + PRE_COMPACT, + STOP, + SUBAGENT_START, + SUBAGENT_STOP, + INSTRUCTIONS_LOADED, + FILE_CHANGED, +) + + +class Event: + """One agent lifecycle event, normalized.""" + + __slots__ = ( + "agent", + "event", + "tool", + "command", + "path", + "content", + "output", + "prompt", + "session_id", + "tool_use_id", + "cwd", + "raw", + ) + + def __init__( + self, + agent, + event, + *, + tool=None, + command=None, + path=None, + content=None, + output=None, + prompt=None, + session_id=None, + tool_use_id=None, + cwd=None, + raw=None, + ): + self.agent = agent + self.event = event + self.tool = tool + self.command = command + self.path = path + self.content = content + self.output = output + self.prompt = prompt + self.session_id = session_id + self.tool_use_id = tool_use_id + self.cwd = cwd + self.raw = raw if raw is not None else {} + + def __repr__(self): # pragma: no cover - debugging aid + return "Event(%s/%s tool=%r path=%r)" % (self.agent, self.event, self.tool, self.path) + + +ALLOW = "allow" +DENY = "deny" +ESCALATE = "escalate" +TRANSFORM = "transform" +WARN = "warn" +VOUCH = "vouch" + +# Pre-ACS-alignment names. Same strings as their ACS-named counterparts, so every existing +# `is`/`==` comparison against the old constant keeps working untouched. +ASK = ESCALATE +REWRITE = TRANSFORM + +_CANONICAL_OUTCOMES = (ALLOW, DENY, ESCALATE, TRANSFORM, WARN, VOUCH) + +# The literal spellings a caller might still pass to Decision(outcome, ...) directly, mapped +# to the value that now backs them. Only needed for the raw-string constructor path -- +# Decision.ask()/.rewrite() below build the canonical outcome themselves. +_LEGACY_SPELLING = {"ask": ESCALATE, "rewrite": TRANSFORM} + + +class Decision: + """What a handler wants to happen. Adapters translate this to vendor dialect.""" + + __slots__ = ("outcome", "reason", "updated_input", "evidence", "context") + + #: Classmethods kept only so existing callers keep constructing; see .ask()/.rewrite(). + DEPRECATED_ALIASES = frozenset({"ask", "rewrite"}) + + def __init__(self, outcome, reason=None, updated_input=None, evidence=None, context=None): + outcome = _LEGACY_SPELLING.get(outcome, outcome) + if outcome not in _CANONICAL_OUTCOMES: + raise ValueError("unknown outcome: %r" % (outcome,)) + self.outcome = outcome + self.reason = reason + self.updated_input = updated_input + self.evidence = evidence or {} + self.context = context + + @classmethod + def allow(cls, reason=None, evidence=None, context=None): + return cls(ALLOW, reason, evidence=evidence, context=context) + + @classmethod + def deny(cls, reason, evidence=None, context=None): + return cls(DENY, reason, evidence=evidence, context=context) + + @classmethod + def escalate(cls, reason, evidence=None, context=None): + """Defer the action to the host's own approval path (ACS `escalate`).""" + return cls(ESCALATE, reason, evidence=evidence, context=context) + + @classmethod + def ask(cls, reason, evidence=None, context=None): + """Deprecated alias of escalate() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.ask() is deprecated; use Decision.escalate()", DeprecationWarning, stacklevel=2) + return cls.escalate(reason, evidence=evidence, context=context) + + @classmethod + def transform(cls, updated_input, reason=None, evidence=None, context=None): + """Replace the tool input wholesale (ACS `transform`, at whole-value granularity).""" + return cls(TRANSFORM, reason, updated_input=updated_input, evidence=evidence, context=context) + + @classmethod + def rewrite(cls, updated_input, reason=None, evidence=None, context=None): + """Deprecated alias of transform() -- kept so existing callers keep constructing.""" + _warnings.warn("Decision.rewrite() is deprecated; use Decision.transform()", DeprecationWarning, stacklevel=2) + return cls.transform(updated_input, reason, evidence=evidence, context=context) + + @classmethod + def warn(cls, reason=None, evidence=None, context=None): + """Permit the action with no change, recording a warning (ACS `warn`).""" + return cls(WARN, reason, evidence=evidence, context=context) + + @classmethod + def vouch(cls, reason=None, evidence=None, context=None): + return cls(VOUCH, reason, evidence=evidence, context=context) + + def __repr__(self): # pragma: no cover - debugging aid + return "Decision(%s, %r)" % (self.outcome, self.reason) + + +def degraded_from(decision): + """What this decision was before the dispatcher reduced it, or None.""" + return (decision.evidence or {}).get("degraded_from") + + +def tool_input_of(raw): + """The tool's arguments as a dict, decoding the JSON-string form some vendors send.""" + if isinstance(raw, dict): + return raw + if isinstance(raw, str) and raw[:1] == "{": + try: + parsed = _json.loads(raw) + except Exception: + return {} + if isinstance(parsed, dict): + return parsed + return {} + + +# ------------------------------------------------------------------------------ +# windsurf family engine (trimmed to what this entry uses) + +def _wire_name(cfg, raw): + for key in cfg["claims"].get("event_key", ()): + name = raw.get(key) + if name is not None: + return name + return None + +def _segment(node, part): + """One path segment: a dict key, or `key[N]` indexing the list under it.""" + if part.endswith("]") and "[" in part: + key, _, index = part[:-1].partition("[") + items = node.get(key) if isinstance(node, dict) else None + i = int(index) + return items[i] if isinstance(items, (list, tuple)) and len(items) > i else None + return node.get(part) if isinstance(node, dict) else None + +def _walk(node, path): + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + if "[]." in path: + head, sub = path.split("[].", 1) + items = _walk(node, head) + if not isinstance(items, (list, tuple)): + return None + joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) + return joined or None + for part in path.split("."): + node = _segment(node, part) + if node is None: + return None + return node + +def _lookup(raw, ti, key): + """One config key: a `tool_input.` path walks the decoded tool input, else the payload.""" + if key.startswith("tool_input."): + return _walk(ti, key[len("tool_input.") :]) + return _walk(raw, key) + +def _field(raw, ti, chain): + value = None + for key in chain: + if value: + break + value = value or _lookup(raw, ti, key) + return value + +_FIELD_META = ("tool_input", "content_only_for_write_tools", "stringify") + +def _tool_input_raw(cfg, raw): + for key in cfg["fields"].get("tool_input", ("tool_input",)): + value = raw.get(key) + if value is not None: + return value + return None + +def _canonical_of(cfg, name): + """The canonical event for one wire name; an entry with no `events` at all (antigravity, + whose payloads never carry one) maps the shape-inferred name back through `wire_events`.""" + events = cfg["events"] + if not events: + return {wire: canonical for canonical, wire in cfg.get("wire_events", {}).items()}.get(name, UNKNOWN) + return events.get(name, UNKNOWN) + +def hj_parse(cfg, raw, wire=None): + """Normalise one payload along the entry's ordered field-fallback chains. + + `wire` is the pre-resolved wire event name for the shape-inferred families; the + marker families resolve it from the payload's own event key. + """ + ti = tool_input_of(_tool_input_raw(cfg, raw)) + fields = {name: _field(raw, ti, chain) for name, chain in cfg["fields"].items() if name not in _FIELD_META} + if cfg["fields"].get("content_only_for_write_tools") and fields.get("tool") not in cfg["tools"].get("write", ()): + fields["content"] = None + if isinstance(fields.get("output"), (dict, list)): + fields["output"] = _json.dumps(fields["output"]) + for name in cfg["fields"].get("stringify", ()): + if fields.get(name) is not None: + fields[name] = str(fields[name]) + return Event( + cfg["agent"], + _canonical_of(cfg, wire if wire is not None else _wire_name(cfg, raw)), + tool=fields.get("tool"), + command=fields.get("command"), + path=fields.get("path"), + content=fields.get("content"), + output=fields.get("output"), + prompt=fields.get("prompt"), + session_id=fields.get("session_id"), + tool_use_id=fields.get("tool_use_id"), + cwd=fields.get("cwd"), + raw=raw, + ) + +def hj_reverse(cfg): + """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides.""" + reverse = {} + for name, canonical in cfg["events"].items(): + if canonical != UNKNOWN: + reverse[canonical] = name + reverse.update(cfg.get("wire_events", {})) + return reverse + +def _note_for(v, decision, at_gate, missing_input): + notes = v.get("degrade_notes", {}) + if decision.outcome == ESCALATE: + if degraded_from(decision) == TRANSFORM and "escalate_from_transform" in notes: + return notes["escalate_from_transform"] + if at_gate and "escalate_gate" in notes: + return notes["escalate_gate"] + return notes.get("escalate") + if decision.outcome == TRANSFORM: + if missing_input and "transform_missing_input" in notes: + return notes["transform_missing_input"] + return notes.get("transform") + return None + +def _default_for(v, decision, at_gate, wire=None): + gate_defaults = v.get("gate_reason_defaults", {}) + if wire in gate_defaults: + return gate_defaults[wire] + defaults = v.get("reason_defaults", {}) + key = {DENY: "deny", ESCALATE: "escalate", TRANSFORM: "transform"}.get(decision.outcome, "deny") + if at_gate and key + "_gate" in defaults: + return defaults[key + "_gate"] + return defaults.get(key, "blocked by policy") + +def _refusal_text(v, decision, at_gate, wire=None): + note = _note_for(v, decision, at_gate, decision.updated_input is None) + default = _default_for(v, decision, at_gate, wire) + if note and "%s" in note: + # A template note fills (the reason or its default, the wire event name) itself. + return note % (decision.reason or default, wire) + reason = decision.reason + if v.get("note_style") == "suffix": + reason = reason or default + return "%s (%s)" % (reason, note) if note else reason + text = "%s (%s)" % (reason, note) if reason and note else (note or reason) + return text or default + +_WINDOWS_KEYS = ("commandWindows", "windows") + +def _hook_dict(cfg, command): + entry = {"type": "command", "command": command} + for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): + if key in _WINDOWS_KEYS: + entry[key] = powershell_command(command) + else: + entry[key] = value + return entry + +def 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": + 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) + if not name: + continue + entry = {"hooks": [_hook_dict(cfg, command)]} + if matcher and hook_entry["matcher"]: + entry["matcher"] = matcher + hooks.setdefault(name, []).append(entry) + if hook_entry.get("group"): + return {hook_entry["group"]: hooks} + return hooks if hook_entry.get("bare") else {"hooks": hooks} + +_MCP_EVENTS = ("pre_mcp_tool_use", "post_mcp_tool_use") + +def windsurf_wire(raw): + """The wire event name, inferred from `tool_info` when the payload names none.""" + name = raw.get("hook_event_name") + if name is not None: + return name + info = raw.get("tool_info") or {} + return "pre_run_command" if info.get("command_line") else "pre_user_prompt" + +def windsurf_claims(cfg, raw): + if not isinstance(raw, dict): + return False + if raw.get("hook_event_name") in cfg["events"]: + return True + return "trajectory_id" in raw and isinstance(raw.get("tool_info"), dict) + +def windsurf_parse(cfg, raw): + name = windsurf_wire(raw) + event = hj_parse(cfg, raw, wire=name) + info = raw.get("tool_info") or {} + if name in _MCP_EVENTS: + joined = "%s/%s" % (info["server"], info["tool"]) if info.get("server") and info.get("tool") else None + event.tool = joined or info.get("tool") + else: + event.tool = name + return event + +def windsurf_respond(cfg, decision, event): + """Exit code only: 2 blocks at a gate; elsewhere a refusal can only be flagged.""" + v = cfg["verdicts"] + if decision.outcome not in (DENY, ESCALATE, TRANSFORM): + return "", 0 + 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 + + +# ------------------------------------------------------------------------------ +# windsurf vendor config + engine binding + +AGENT = "windsurf" + +VENDOR = {'agent': 'windsurf', 'claims': {'mode': 'shape_inferred'}, 'config_format': 'json', 'config_path': '.windsurf/hooks.json', 'display': 'Windsurf (Cascade)', 'events': {'post_cascade_response': 'stop', 'post_mcp_tool_use': 'post_tool', 'pre_mcp_tool_use': 'pre_tool', 'pre_run_command': 'pre_tool', 'pre_user_prompt': 'prompt_submit'}, 'evidence': {'claims': {'basis': 'third-party-install', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'third-party-install', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'third-party-install', 'date': '2026-08-26', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'third-party-install', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'third-party-install', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'third-party-install', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'third-party-install', 'date': '2026-08-26', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'third-party-install', 'date': '2026-08-26', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'windsurf', 'fields': {'command': ('tool_info.command_line',), 'cwd': ('cwd',), 'output': ('output', 'result'), 'path': ('path', 'tool_info.path'), 'prompt': ('query', 'prompt'), 'session_id': ('trajectory_id',)}, 'hook_entry': {'also_wires': {'pre_tool': 'pre_mcp_tool_use'}, 'matcher': False, 'wrapper': 'flat_entries'}, 'needs_trust': False, 'tools': {}, 'verdicts': {'answer_events': ('pre_mcp_tool_use', 'pre_run_command', 'pre_user_prompt'), 'bare_allow': 'silent', 'degrade_notes': {'escalate': 'this agent cannot prompt for confirmation; blocking instead', 'escalate_from_transform': 'this agent cannot rewrite tool input; blocking instead', 'transform': 'this agent cannot rewrite tool input; blocking instead'}, 'flag_note': 'windsurf: flagged after the fact (%s cannot block): %s', 'flag_note_default': 'policy violation', 'gates': {'pre_mcp_tool_use': {'grammar': 'G5', 'honours_escalate': False, 'honours_transform': False}, 'pre_run_command': {'grammar': 'G5', 'honours_escalate': False, 'honours_transform': False}, 'pre_user_prompt': {'grammar': 'G5', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'suffix', 'reason_defaults': {'escalate': 'confirmation required', 'transform': 'input requires modification'}, 'vocabulary': (), 'vocabulary_basis': 'verified'}, 'wire_events': {'pre_tool': 'pre_run_command'}} + + +def claims(raw): + return windsurf_claims(VENDOR, raw) + + +def parse(raw): + return windsurf_parse(VENDOR, raw) + + +def respond(decision, event): + return windsurf_respond(VENDOR, decision, event) + + +def hook_config(canonical_events, command, matcher=None): + return hook_entry_config(VENDOR, canonical_events, command, matcher) + + +# ------------------------------------------------------------------------------ +# runtime (agentseam dispatch, specialized to windsurf) + +_VOUCH_SPEAKS = False +_WARN_SPEAKS = False +_TRANSFORM_EVENTS = frozenset(()) + + +def degrade(decision, event): + """Reduce a decision to what 'windsurf' can actually honor, honestly. + + This is agentseam.dispatch.degrade(), specialized to one fixed agent so this file needs + no "import agentseam" -- no evidence establishes that an explicit approval word means anything beyond a plain allow here, so vouch degrades to one; see agentseam.allow_semantics. + """ + if decision.outcome == TRANSFORM and event.event not in _TRANSFORM_EVENTS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = TRANSFORM + return Decision.escalate( + decision.reason or "input requires modification before it can run", evidence=evidence + ) + if decision.outcome == VOUCH and not _VOUCH_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = VOUCH + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + if decision.outcome == WARN and not _WARN_SPEAKS: + evidence = dict(decision.evidence) + evidence["degraded_from"] = WARN + return Decision.allow(decision.reason, evidence=evidence, context=decision.context) + return decision + + +# >>> agentseam handler >>> +GUARD_VIOLATION = 1 + +_BASH_CANDIDATES = ('bash', 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe', '/bin/bash', '/usr/bin/bash') + +GATE_LOG_ENV = 'CHOCK_GATE_LOG' + +_LOG_MAX_BYTES = 1048576 + +_GUARD_TIMEOUT_SECONDS = 30 + +GUARD_BLOCKED = 'blocked' + +GUARD_CLEAN = 'clean' + +GUARD_UNCHECKED = 'unchecked' + +GUARD_ERRORED = 'errored' + +VERDICT_DENY = 'deny' + +VERDICT_ESCALATE = 'escalate' + +def guard_path_from_argv(argv: list[str]) -> _chock_Path | None: + """The `--guard ` argument a vendored runtime was invoked with, or None.""" + if '--guard' in argv: + i = argv.index('--guard') + if i + 1 < len(argv): + return _chock_Path(argv[i + 1]) + return None + +def find_bash(guard: _chock_Path) -> str | None: + """First interpreter that can actually see `guard`, or None.""" + for candidate in _BASH_CANDIDATES: + try: + proc = _chock_subprocess.run([candidate, '-c', f'test -f "{guard.as_posix()}"'], capture_output=True, timeout=10) + except (OSError, _chock_subprocess.SubprocessError): + continue + if proc.returncode == 0: + return candidate + return None + +def run_guard(guard: _chock_Path, command: str) -> str: + """`GUARD_BLOCKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + try: + args = _chock_shlex.split(command) + except ValueError: + print('chock: could not parse command (unbalanced quotes), not checked', file=sys.stderr) + return GUARD_UNCHECKED + if not args: + return GUARD_UNCHECKED + bash = find_bash(guard) + if bash is None: + print(f'chock: no usable bash found, {guard.name} not checked', file=sys.stderr) + return GUARD_UNCHECKED + try: + env = {**_chock_os.environ, 'CHOCK_RAW_COMMAND': command} + proc = _chock_subprocess.run([bash, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=_GUARD_TIMEOUT_SECONDS) + except _chock_subprocess.TimeoutExpired: + print(f'chock: guard timed out after {_GUARD_TIMEOUT_SECONDS}s, not checked', file=sys.stderr) + return GUARD_ERRORED + except (OSError, UnicodeError) as exc: + print(f'chock: guard could not run, not checked: {exc}', file=sys.stderr) + return GUARD_ERRORED + if proc.returncode == GUARD_VIOLATION: + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + if not ((proc.stdout or '') + (proc.stderr or '')).strip(): + print(f'chock: blocked by {_chock_Path(guard).name} (guard gave no reason)', file=sys.stderr) + return GUARD_BLOCKED + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout or '').strip().splitlines() + print(f'chock: guard exited {proc.returncode}, not checked' + (f': {detail[0][:120]}' if detail else ''), file=sys.stderr) + return GUARD_ERRORED + return GUARD_CLEAN + +def log_outcome(guard: _chock_Path, tool: str, blocked: bool) -> None: + """Append one outcome record. Best effort: never raises, never changes the verdict.""" + try: + if _chock_os.environ.get(GATE_LOG_ENV) == '0': + return + guard = guard.resolve() + if guard.parent.name != 'implementations': + return + artifact_root = None + for parent in guard.parents: + if (parent / '.chock').is_dir(): + artifact_root = parent / '.chock' + break + if artifact_root is None: + return + log_dir = artifact_root / 'log' + log_dir.mkdir(parents=True, exist_ok=True) + log_path = log_dir / 'gate-events.jsonl' + if log_path.exists() and log_path.stat().st_size > _LOG_MAX_BYTES: + log_path.replace(log_dir / 'gate-events.1.jsonl') + import json + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': 'block' if blocked else 'allow'} + with log_path.open('a', encoding='utf-8') as fh: + fh.write(json.dumps(record, ensure_ascii=False) + '\n') + except Exception: + return + +def evaluate(argv: list[str], command: str, tool: str='') -> tuple[str, str] | None: + """Run the guard named on `argv` (`--guard `) against `command`.""" + guard = guard_path_from_argv(argv) + if guard is None or not guard.exists(): + return None + verdict = run_guard(guard, command) + if verdict in (GUARD_BLOCKED, GUARD_CLEAN): + log_outcome(guard, tool, verdict == GUARD_BLOCKED) + if verdict == GUARD_BLOCKED: + return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + if verdict == GUARD_ERRORED: + return (VERDICT_ESCALATE, f"chock could not check this command: the {guard.stem} guard did not complete (see this hook's stderr). Approving runs it unchecked.") + return None + + +def handle(event): + if event.event == "pre_tool" and event.command: + verdict = evaluate(sys.argv[1:], event.command, event.tool or "") + if verdict is not None: + outcome, reason = verdict + return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + return None +# <<< agentseam handler <<< + + +def _coerce(result): + if result is None: + return Decision.allow() + if isinstance(result, Decision): + return result + raise TypeError("handle() must return a Decision or None, got %r" % (type(result),)) + + +def _read_payload(stream): + # Decode bytes ourselves rather than trust the platform locale -- a BOM'd or non-UTF-8 + # stdin must not silently disable the gate. See agentseam.dispatch for the incident + # this guards: a Windows console's cp1252 layer turning a UTF-8 BOM into three bytes + # json cannot parse, with the hook allowing everything while claiming enforcement. + buffer = getattr(stream, "buffer", None) + if buffer is not None: + return buffer.read().decode("utf-8-sig", errors="replace") + return stream.read().lstrip("\ufeff") + + +def _emit(out, text): + buffer = getattr(out, "buffer", None) + if buffer is not None: + buffer.write(text.encode("utf-8")) + buffer.flush() + else: + out.write(text) + out.flush() + + +def main(stdin=None, stdout=None, exit=True): + """Read one payload from stdin, dispatch, emit the 'windsurf' response, exit.""" + stream = stdin if stdin is not None else sys.stdin + out = stdout if stdout is not None else sys.stdout + try: + raw = json.loads(_read_payload(stream)) + except Exception: + # Malformed input is not the agent's fault to pay for: allow, stay silent. + if exit: + sys.exit(0) + return 0 + event = parse(raw) + if event.event == UNKNOWN: + # A vendor event this adapter has no mapping for. handle() is not called: it + # reasons about the canonical vocabulary, and handing it something outside that + # vocabulary invites a decision made on a false premise. + if exit: + sys.exit(0) + return 0 + decision = degrade(_coerce(handle(event)), event) + text, code = respond(decision, event) + if text: + _emit(out, text) + if exit: + sys.exit(code) + return code + + +if __name__ == "__main__": # pragma: no cover + main() + diff --git a/tests/test_basis_capped_coverage.py b/tests/test_basis_capped_coverage.py index 279c171..b587d54 100644 --- a/tests/test_basis_capped_coverage.py +++ b/tests/test_basis_capped_coverage.py @@ -112,7 +112,14 @@ def test_the_fail_to_ask_lift_needs_a_tested_claim(monkeypatch) -> None: fail_open = [a for a in sorted(SURFACE_AGENTS) if in_agent_level(a) == "best-effort"] assert fail_open, "no fail-open agent left to test the lift on" - for agent in fail_open: + liftable = [ + a + for a in fail_open + if evidence.honours_ask(MATRIX_AGENT[a]) + and level_rank(cap_for(weakest_basis(resting_bases(MATRIX_AGENT[a])))) >= level_rank("fail-to-ask") + ] + assert liftable, "no agent with both a tested claim and lift-admitting evidence left" + for agent in liftable: assert in_agent_level(agent, degrades_to=DEGRADES_TO_ASK) == "fail-to-ask" monkeypatch.setattr(evidence, "honours_ask", lambda agent, table=None: False) diff --git a/tests/test_coverage_grades.py b/tests/test_coverage_grades.py index ae22fb7..8f8a80a 100644 --- a/tests/test_coverage_grades.py +++ b/tests/test_coverage_grades.py @@ -51,15 +51,27 @@ def test_unranked_levels_refuse_a_rank_rather_than_inventing_one() -> None: def test_degrading_to_a_prompt_grades_strictly_stronger_than_degrading_to_allowing() -> None: - """The case that motivated the whole change, and the one it must not get wrong.""" + """The case that motivated the whole change, recomputed per agent: the lift needs a + + tested honours_ask claim AND evidence whose cap admits `fail-to-ask`; everyone else + stays at the word the basis cap allows, never silently above it. + """ + from chock import evidence + from chock.compile.levels import capped, resting_bases + from chock.vendors import CHOCK_AGENT + assert FAIL_OPEN_AGENTS, "no fail-open agent left to test the distinction on" + lifted = [] for agent in FAIL_OPEN_AGENTS: allowing = in_agent_level(agent, degrades_to=DEGRADES_TO_ALLOW) asking = in_agent_level(agent, degrades_to=DEGRADES_TO_ASK) - assert level_rank(asking) > level_rank(allowing), ( - f"{agent}: a control that asks ({asking}) does not outrank one that allows ({allowing})" - ) - assert asking == "fail-to-ask" + mapped = CHOCK_AGENT[agent] + expected = "fail-to-ask" if evidence.honours_ask(mapped) else "best-effort" + assert asking == capped(expected, resting_bases(mapped)), f"{agent}: {asking}" + if asking == "fail-to-ask": + assert level_rank(asking) > level_rank(allowing) + lifted.append(agent) + assert "claude" in lifted, "the motivating case (a live-run host that honours ask) went missing" def test_a_deny_on_failure_is_graded_no_lower_than_an_ask() -> None: @@ -87,7 +99,7 @@ def test_an_unknown_degradation_mode_is_refused() -> None: def test_an_unmapped_agent_has_no_in_agent_level() -> None: assert in_agent_level("no-such-agent") == "none" - assert in_agent_level("codex") == "none", "codex has no in-agent surface in SURFACE_AGENTS" + assert in_agent_level("kimi-code") == "none", "kimi-code has no in-agent surface in SURFACE_AGENTS" def _guard(tmp_path: Path, body: str) -> list[str]: diff --git a/tests/test_coverage_honesty.py b/tests/test_coverage_honesty.py index e64ccee..4a48c23 100644 --- a/tests/test_coverage_honesty.py +++ b/tests/test_coverage_honesty.py @@ -114,17 +114,13 @@ def test_a_wrapper_agent_gets_at_least_the_ambient_rule() -> None: def test_repo_coverage_matches_actual_enforcement() -> None: """End to end on this repo: every enforcement claim names its real witness.""" + from chock.compile.levels import IN_AGENT_TODAY from chock.hooks.in_agent_install import installed_policy_ids + from chock.vendors import CHOCK_AGENT coverage = json.loads((FRAMEWORK_ROOT / ".chock" / "coverage.json").read_text(encoding="utf-8")) compiled = FRAMEWORK_ROOT / ".chock" / "compiled" - agent_hooks_witness = installed_policy_ids(FRAMEWORK_ROOT, "vscode_copilot") - witnesses = { - "claude": installed_policy_ids(FRAMEWORK_ROOT, "claude_code"), - "cursor": installed_policy_ids(FRAMEWORK_ROOT, "cursor"), - "copilot": agent_hooks_witness, - "vscode": agent_hooks_witness, - } + witnesses = {agent: installed_policy_ids(FRAMEWORK_ROOT, CHOCK_AGENT[agent]) for agent in IN_AGENT_TODAY} AGENT_HOOK_LEVELS = {"enforced", "enforceable", "best-effort"} diff --git a/tests/test_emitter_stability.py b/tests/test_emitter_stability.py index c4bf382..f36b0f0 100644 --- a/tests/test_emitter_stability.py +++ b/tests/test_emitter_stability.py @@ -70,6 +70,16 @@ def test_golden_tree_covers_every_surface_worth_freezing() -> None: """The guarantee is only as wide as the fixtures. If a golden tree exists but no""" if not GOLDEN.exists(): pytest.skip("goldens not generated yet") + from chock.compile.emitters.in_agent import GENERIC_VENDORS + names = {p.name for p in GOLDEN.rglob("*") if p.is_file()} - for required in ("gate.json", "ambient.md", "pretooluse.json", "cursor-hooks.json", "agent-hooks.json"): + per_vendor = tuple(f"{vendor}-hooks.json" for vendor in GENERIC_VENDORS) + for required in ( + "gate.json", + "ambient.md", + "pretooluse.json", + "cursor-hooks.json", + "agent-hooks.json", + *per_vendor, + ): assert any(required in n for n in names), f"golden tree lost its {required} coverage" diff --git a/tests/test_generic_hooks_install.py b/tests/test_generic_hooks_install.py new file mode 100644 index 0000000..a932fe9 --- /dev/null +++ b/tests/test_generic_hooks_install.py @@ -0,0 +1,121 @@ +"""The generic in-agent installer: one merge policy over every derived vendor's config shape.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest +from conftest import baseline_policy + +from chock import vendors +from chock.compile.compiler import compile_policy +from chock.compile.emitters.in_agent import GENERIC_VENDORS +from chock.compile.surfaces import Surface +from chock.hooks.in_agent_install import install_hooks, installed_policy_ids + +POLICY = "block-destructive-commands" + + +def _repo(tmp_path: Path) -> Path: + repo = tmp_path / "r" + repo.mkdir() + compile_policy( + baseline_policy(POLICY), + targets=[Surface.PRE_TOOL_USE.value], + output_root=repo / ".chock" / "compiled", + agents=["claude"], + repo_root=repo, + ) + return repo + + +def _config(repo: Path, vendor: str) -> dict: + return json.loads((repo / vendors.config_path(vendor)).read_text(encoding="utf-8")) + + +@pytest.mark.parametrize("vendor", GENERIC_VENDORS) +def test_install_writes_config_runtime_and_reports(tmp_path: Path, vendor: str) -> None: + repo = _repo(tmp_path) + + installed = install_hooks(repo, vendor) + + assert installed == [POLICY] + assert (repo / ".chock" / "bin" / f"{vendor}.py").exists() + text = json.dumps(_config(repo, vendor)) + assert f".chock/bin/{vendor}.py" in text + assert sys.executable in text, "the interpreter placeholder must be baked at install" + assert "@CHOCK_PYTHON@" not in text + assert installed_policy_ids(repo, vendor) == {POLICY} + + +@pytest.mark.parametrize("vendor", GENERIC_VENDORS) +def test_install_is_idempotent(tmp_path: Path, vendor: str) -> None: + repo = _repo(tmp_path) + install_hooks(repo, vendor) + first = (repo / vendors.config_path(vendor)).read_bytes() + + install_hooks(repo, vendor) + + assert (repo / vendors.config_path(vendor)).read_bytes() == first + + +@pytest.mark.parametrize("vendor", GENERIC_VENDORS) +def test_removal_deletes_only_what_chock_owns(tmp_path: Path, vendor: str) -> None: + """No fragments left: our entries and runtime go; a config file that held only ours goes too.""" + import shutil + + repo = _repo(tmp_path) + install_hooks(repo, vendor) + shutil.rmtree(repo / ".chock" / "compiled") + + assert install_hooks(repo, vendor) == [] + + assert not (repo / vendors.config_path(vendor)).exists() + assert not (repo / ".chock" / "bin" / f"{vendor}.py").exists() + assert installed_policy_ids(repo, vendor) == set() + + +def test_foreign_settings_and_entries_survive_install_and_removal(tmp_path: Path) -> None: + """gemini's config is a shared settings file: the adopter's keys and hooks are not ours to move.""" + import shutil + + repo = _repo(tmp_path) + config_path = repo / vendors.config_path("gemini_cli") + config_path.parent.mkdir(parents=True) + theirs_entry = {"hooks": [{"type": "command", "command": "./scripts/audit.sh"}]} + config_path.write_text(json.dumps({"theme": "dark", "hooks": {"BeforeTool": [theirs_entry]}}), encoding="utf-8") + + install_hooks(repo, "gemini_cli") + settings = _config(repo, "gemini_cli") + assert settings["theme"] == "dark" + assert settings["hooks"]["BeforeTool"][0] == theirs_entry, "the adopter's entry stays first" + assert len(settings["hooks"]["BeforeTool"]) == 2 + + shutil.rmtree(repo / ".chock" / "compiled") + install_hooks(repo, "gemini_cli") + settings = _config(repo, "gemini_cli") + assert settings == {"theme": "dark", "hooks": {"BeforeTool": [theirs_entry]}} + + +def test_windsurf_wires_both_recorded_pre_tool_events(tmp_path: Path) -> None: + """The also_wires fact reaches the installed file through the rendering, not a chock table.""" + repo = _repo(tmp_path) + install_hooks(repo, "windsurf") + hooks = _config(repo, "windsurf")["hooks"] + assert set(hooks) == {"pre_run_command", "pre_mcp_tool_use"} + + +def test_a_stale_interpreter_is_rebaked_not_reused(tmp_path: Path) -> None: + repo = _repo(tmp_path) + install_hooks(repo, "devin") + config_path = repo / vendors.config_path("devin") + stale = config_path.read_text(encoding="utf-8").replace(sys.executable, "/no/such/python3") + config_path.write_text(stale, encoding="utf-8") + + install_hooks(repo, "devin") + + text = config_path.read_text(encoding="utf-8") + assert "/no/such/python3" not in text + assert sys.executable in text diff --git a/tests/test_guard_fail_to_ask.py b/tests/test_guard_fail_to_ask.py index 7908046..9aca25d 100644 --- a/tests/test_guard_fail_to_ask.py +++ b/tests/test_guard_fail_to_ask.py @@ -66,6 +66,57 @@ def make_guard(tmp_path: Path, name: str, body: str) -> Path: "tool_name": "Bash", "tool_input": {"command": c}, }, + "antigravity": lambda c: { + "toolCall": {"name": "run_command", "args": {"CommandLine": c, "Cwd": "/x"}}, + "conversationId": "c1", + "stepIdx": 1, + "workspacePaths": ["/x"], + }, + "devin": lambda c: { + "hook_event_name": "PreToolUse", + "prompt_id": "p1", + "tool_name": "Bash", + "tool_input": {"command": c}, + "session_id": "s", + }, + "gemini_cli": lambda c: { + "hook_event_name": "BeforeTool", + "tool_name": "run_shell_command", + "tool_input": {"command": c}, + "session_id": "s", + }, + "grok": lambda c: { + "hookEventName": "PreToolUse", + "toolName": "Bash", + "toolInput": {"command": c}, + "sessionId": "s", + }, + "tabnine": lambda c: { + "hook_event_name": "BeforeTool", + "timestamp": "2026-08-31T00:00:00Z", + "tool_name": "shell", + "tool_input": {"command": c}, + }, + "windsurf": lambda c: { + "trajectory_id": "t1", + "tool_info": {"command_line": c}, + "cwd": "/x", + }, +} + +#: The deny each vendor's wire actually carries, witnessed by the deny-guard fixture below: +#: `block` is the devin-family spelling, `exit-2` windsurf's wordless exit-code grammar (G5). +DENY_ON_THE_WIRE = { + "claude_code": "deny", + "codex_cli": "deny", + "cursor": "deny", + "vscode_copilot": "deny", + "antigravity": "deny", + "devin": "block", + "gemini_cli": "deny", + "grok": "deny", + "tabnine": "deny", + "windsurf": "exit-2", } @@ -80,19 +131,29 @@ def run(runtimes: dict[str, Path], agent: str, guard: Path, command: str) -> sub def decision(result: subprocess.CompletedProcess) -> dict: - """The verdict a client would read, flattened across the two response shapes.""" + """The verdict a client would read, flattened across the response grammars.""" out = result.stdout.decode("utf-8").strip() + if result.returncode == 2: + return {"decision": "exit-2", "reason": out} if not out: return {} body = json.loads(out) nested = body.get("hookSpecificOutput") if isinstance(nested, dict): return {"decision": nested.get("permissionDecision"), "reason": nested.get("permissionDecisionReason")} + if "decision" in body: + return {"decision": body.get("decision"), "reason": body.get("reason")} return {"decision": body.get("permission"), "reason": body.get("user_message")} ASK_ON_THE_WIRE = {c.agent: c.verdict for c in evidence.claims() if c.claim == evidence.HONOURS_ASK} +#: What an allow looks like per wire: silence (or an explicit allow) everywhere but devin, +#: whose bare-allow grammar requires the word `approve`. +ALLOWED_WORDS = { + agent: {None, "allow", "approve"} if agent == "devin" else {None, "allow"} for agent in ASK_ON_THE_WIRE +} + @pytest.mark.parametrize("agent", sorted(ASK_ON_THE_WIRE)) def test_a_crashed_guard_asks_rather_than_allowing(agent: str, tmp_path: Path, runtimes) -> None: @@ -101,7 +162,7 @@ def test_a_crashed_guard_asks_rather_than_allowing(agent: str, tmp_path: Path, r result = run(runtimes, agent, guard, "ls -la") - assert result.returncode == 0 + assert result.returncode == (2 if ASK_ON_THE_WIRE[agent] == "exit-2" else 0) verdict = decision(result) assert verdict.get("decision") == ASK_ON_THE_WIRE[agent], f"{agent} must not silently allow an unchecked command" assert verdict.get("reason"), "a confirmation request the user cannot interpret is a click-through" @@ -123,10 +184,10 @@ def test_the_ask_does_not_fire_on_a_clean_or_a_violating_guard(agent: str, tmp_p denying = make_guard(tmp_path, "deny.sh", "echo NOPE >&2; exit 1") allowed = decision(run(runtimes, agent, clean, "ls -la")) - assert allowed.get("decision") in (None, "allow"), "a clean guard must not prompt" + assert allowed.get("decision") in ALLOWED_WORDS[agent], "a clean guard must not prompt" denied = decision(run(runtimes, agent, denying, "some destructive thing")) - assert denied.get("decision") == "deny", "a real violation is still a deny, not a prompt" + assert denied.get("decision") == DENY_ON_THE_WIRE[agent], "a real violation is still a deny, not a prompt" @pytest.mark.parametrize("agent", sorted(ASK_ON_THE_WIRE)) @@ -136,7 +197,7 @@ def test_an_unparseable_command_still_allows(agent: str, tmp_path: Path, runtime verdict = decision(run(runtimes, agent, guard, "echo 'unbalanced")) - assert verdict.get("decision") in (None, "allow"), "an unparseable command must not prompt" + assert verdict.get("decision") in ALLOWED_WORDS[agent], "an unparseable command must not prompt" def test_a_missing_bash_still_allows(tmp_path: Path, monkeypatch) -> None: @@ -188,5 +249,7 @@ def test_the_wire_vocabulary_is_the_words_the_fixtures_witness(tmp_path: Path, r def test_every_gated_runtime_is_covered_here() -> None: - """The table above is checked against the code, so a fifth runtime cannot join silently.""" + """The tables above are checked against the code, so an eleventh runtime cannot join silently.""" assert set(ASK_ON_THE_WIRE) == set(runtime_bundle.RUNTIME_AGENTS) + assert set(DENY_ON_THE_WIRE) == set(runtime_bundle.RUNTIME_AGENTS) + assert set(PAYLOADS) == set(runtime_bundle.RUNTIME_AGENTS) diff --git a/tests/test_repo_standards.py b/tests/test_repo_standards.py index 2f33283..b2ab2b0 100644 --- a/tests/test_repo_standards.py +++ b/tests/test_repo_standards.py @@ -12,13 +12,11 @@ "requirements/semgrep.txt", "requirements/brand-assets.txt", "src/chock/gate/runner.py", - ".chock/bin/gate.py", - ".chock/bin/claude_code.py", - ".chock/bin/cursor.py", - ".chock/bin/vscode_copilot.py", } -EXEMPT_PREFIXES = (".chock/log/", "src/chock/authoring/data/") +# .chock/bin/ is bundler output (generated, review lives at its sources); runtime_goldens +# freeze that same generated output per vendor. +EXEMPT_PREFIXES = (".chock/log/", "src/chock/authoring/data/", ".chock/bin/", "tests/fixtures/runtime_goldens/") SKIP_DIRS = { ".git", diff --git a/tests/test_runtime_goldens.py b/tests/test_runtime_goldens.py new file mode 100644 index 0000000..8adb82a --- /dev/null +++ b/tests/test_runtime_goldens.py @@ -0,0 +1,38 @@ +"""Per-vendor runtime goldens: bundle output is frozen bytes until a deliberate regen.""" + +from __future__ import annotations + +import os +import shutil +from pathlib import Path + +import pytest + +from chock.gate import runtime_bundle + +GOLDEN = Path(__file__).resolve().parent / "fixtures" / "runtime_goldens" + + +def test_every_runtime_matches_its_frozen_fixture() -> None: + rendered = {f"{agent}.py": runtime_bundle.render(agent) for agent in runtime_bundle.RUNTIME_AGENTS} + + if os.environ.get("CHOCK_REGEN_GOLDENS") == "1": + if GOLDEN.exists(): + shutil.rmtree(GOLDEN) + GOLDEN.mkdir(parents=True) + for name, text in rendered.items(): + (GOLDEN / name).write_text(text, encoding="utf-8") + pytest.skip( + "runtime goldens regenerated -- commit the diff; only an agentseam pin bump or a deliberate handler change explains one" + ) + + assert GOLDEN.exists(), "no runtime goldens committed; run with CHOCK_REGEN_GOLDENS=1 once" + frozen = {p.name: p.read_text(encoding="utf-8") for p in GOLDEN.glob("*.py")} + assert set(frozen) == set(rendered), ( + f"the runtime set changed (frozen={sorted(frozen)}, rendered={sorted(rendered)}); regenerate deliberately" + ) + differing = sorted(name for name in rendered if rendered[name] != frozen[name]) + assert not differing, ( + f"runtime bytes moved for {differing}: every adopter's next sync rewrites .chock/bin. " + "Intentional (pin bump, handler change)? Regenerate with CHOCK_REGEN_GOLDENS=1." + ) diff --git a/tests/test_vendor_wire_facts.py b/tests/test_vendor_wire_facts.py index fba9f20..03586c9 100644 --- a/tests/test_vendor_wire_facts.py +++ b/tests/test_vendor_wire_facts.py @@ -96,3 +96,34 @@ def test_cursor_fail_closed_stays_unset_pending_the_owner_decision() -> None: rendered = adapters.get("cursor").hook_config(("pre_tool",), "CMD", fail_closed=None) (entry,) = rendered["hooks"]["preToolUse"] assert "failClosed" not in entry + + +def test_home_level_config_vendors_stay_out_only_for_their_recorded_facts() -> None: + """junie/kimi_code block per the matrix but are excluded from the in-agent set for one + + reason each chock can read upstream: a home-anchored config path (both), a TOML config + (kimi_code). The day upstream records a repo-level JSON config, membership widens by + derivation alone -- extend wiring, goldens and docs then, not this exclusion. + """ + from chock.vendors import in_agent_vendors, repo_wirable + + assert str(VENDOR_CONFIG["junie"]["config_path"]).startswith("~") + assert str(VENDOR_CONFIG["kimi_code"]["config_path"]).startswith("~") + assert VENDOR_CONFIG["kimi_code"]["config_format"] == "toml" + for vendor in ("junie", "kimi_code"): + assert not repo_wirable(vendor) + assert vendor not in in_agent_vendors() + + +def test_the_derived_vendor_set_is_the_predicate_recomputed() -> None: + """Design test (a) at the vendor level: membership is can_block x repo-wirable, recomputed.""" + from agentseam import contract as _contract + from agentseam import matrix as _matrix + + from chock.vendors import in_agent_vendors, repo_wirable + + recomputed = { + vendor for vendor in VENDOR_CONFIG if _matrix.can_block(vendor, _contract.PRE_TOOL) and repo_wirable(vendor) + } + assert set(in_agent_vendors()) == recomputed + assert "junie" in VENDOR_CONFIG and "kimi_code" in VENDOR_CONFIG