From f4f6c32e3989767f9d89a9dd0ec49cc2efc89c1b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 00:28:42 +0000 Subject: [PATCH 1/3] Derive in-agent membership from the matrix; extend the surface to seven new vendors (C3) IN_AGENT_TODAY, SURFACE_AGENTS, RUNTIME_AGENTS and VENDORED_RUNTIMES stop being hand lists: membership derives in chock.vendors from agentseam 0.2.0's matrix.can_block(V, PRE_TOOL), capped by what the repo-scoped installer may touch (a repo-relative JSON hook config). Wire facts keep coming from the vendor entries; capability enters from the matrix only. New in-agent vendors: antigravity, codex_cli (repo-level, beside its plugin store), devin, gemini_cli, grok, tabnine, windsurf. Each gets per-policy pre-tool fragments rendered by agentseam's own hook_config, one shape-agnostic merge installer (hooks/in_agent_generic.py: strip-ours + deep-merge keyed on the vendored runtime path, interpreter baking as before), and a vendored runtime from bundle(). junie and kimi_code block per the matrix but their recorded configs are home-anchored (kimi_code's TOML), outside chock sync --repo's writes: excluded by derivation, pinned by a test that fails when upstream records repo-level JSON for them. junie joins the alias table, advisory surfaces and both published matrices. Day-one coverage is basis-capped and honest: every new cell reads best-effort (), witnessed: false; no word moved for the four previously enforced vendors and their artifacts are byte-identical (before/after tree diff: only new-vendor coverage cells changed). Six new tested honours_ask claim rows record each new runtime's crash-guard wire word (block and exit-2 join the wire vocabulary for devin's spelling and windsurf's G5 exit-code grammar). New goldens freeze per-vendor fragments (emitter-stability tree) and per-vendor runtime bytes (tests/fixtures/runtime_goldens/). Fragment commands for the new vendors use repo-relative paths: no repo-root token is recorded upstream for them (known gap, filed), so the hooks resolve where the vendor runs them from the repo root, and installs stay unwitnessed best-effort claims until a live run lands in the witness ledger. Co-Authored-By: Claude Fable 5 Signed-off-by: Claude --- .agents/hooks.json | 38 + .chock/bin/antigravity.py | 763 +++++++++++++++ .chock/bin/codex_cli.py | 805 ++++++++++++++++ .chock/bin/devin.py | 789 ++++++++++++++++ .chock/bin/gemini_cli.py | 789 ++++++++++++++++ .chock/bin/grok.py | 772 +++++++++++++++ .chock/bin/tabnine.py | 772 +++++++++++++++ .chock/bin/windsurf.py | 701 ++++++++++++++ .../pre-tool-use/antigravity-hooks.json | 14 + .../pre-tool-use/codex_cli-hooks.json | 15 + .../pre-tool-use/devin-hooks.json | 12 + .../pre-tool-use/gemini_cli-hooks.json | 15 + .../pre-tool-use/grok-hooks.json | 14 + .../pre-tool-use/tabnine-hooks.json | 15 + .../pre-tool-use/windsurf-hooks.json | 14 + .../pre-tool-use/antigravity-hooks.json | 14 + .../pre-tool-use/codex_cli-hooks.json | 15 + .../pre-tool-use/devin-hooks.json | 12 + .../pre-tool-use/gemini_cli-hooks.json | 15 + .../pre-tool-use/grok-hooks.json | 14 + .../pre-tool-use/tabnine-hooks.json | 15 + .../pre-tool-use/windsurf-hooks.json | 14 + .../pre-tool-use/antigravity-hooks.json | 14 + .../pre-tool-use/codex_cli-hooks.json | 15 + .../pre-tool-use/devin-hooks.json | 12 + .../pre-tool-use/gemini_cli-hooks.json | 15 + .../pre-tool-use/grok-hooks.json | 14 + .../pre-tool-use/tabnine-hooks.json | 15 + .../pre-tool-use/windsurf-hooks.json | 14 + .../pre-tool-use/antigravity-hooks.json | 14 + .../pre-tool-use/codex_cli-hooks.json | 15 + .../pre-tool-use/devin-hooks.json | 12 + .../pre-tool-use/gemini_cli-hooks.json | 15 + .../pre-tool-use/grok-hooks.json | 14 + .../pre-tool-use/tabnine-hooks.json | 15 + .../pre-tool-use/windsurf-hooks.json | 14 + .chock/coverage.json | 96 +- .codex/hooks.json | 42 + .devin/hooks.v1.json | 36 + .gemini/settings.json | 42 + .grok/hooks/agentseam.json | 38 + .tabnine/agent/settings.json | 42 + .windsurf/hooks.json | 32 + CHANGELOG.md | 29 + README.md | 26 +- chock.lock | 8 +- docs/enforcement-surfaces.md | 45 +- pyproject.toml | 2 +- src/chock/compile/emitters/in_agent.py | 24 + src/chock/compile/levels.py | 10 +- src/chock/compile/surfaces.py | 40 +- src/chock/data/claims.json | 48 + src/chock/evidence.py | 5 +- src/chock/gate/runtime_bundle.py | 4 +- src/chock/hooks/in_agent_generic.py | 190 ++++ src/chock/hooks/in_agent_install.py | 31 +- src/chock/vendored.py | 6 +- src/chock/vendors.py | 27 + .../pre-tool-use/antigravity-hooks.json | 14 + .../pre-tool-use/codex_cli-hooks.json | 15 + .../pre-tool-use/devin-hooks.json | 12 + .../pre-tool-use/gemini_cli-hooks.json | 15 + .../pre-tool-use/grok-hooks.json | 14 + .../pre-tool-use/tabnine-hooks.json | 15 + .../pre-tool-use/windsurf-hooks.json | 14 + tests/fixtures/runtime_goldens/antigravity.py | 763 +++++++++++++++ tests/fixtures/runtime_goldens/claude_code.py | 884 ++++++++++++++++++ tests/fixtures/runtime_goldens/codex_cli.py | 805 ++++++++++++++++ tests/fixtures/runtime_goldens/cursor.py | 728 +++++++++++++++ tests/fixtures/runtime_goldens/devin.py | 789 ++++++++++++++++ tests/fixtures/runtime_goldens/gemini_cli.py | 789 ++++++++++++++++ tests/fixtures/runtime_goldens/grok.py | 772 +++++++++++++++ tests/fixtures/runtime_goldens/tabnine.py | 772 +++++++++++++++ .../runtime_goldens/vscode_copilot.py | 623 ++++++++++++ tests/fixtures/runtime_goldens/windsurf.py | 701 ++++++++++++++ tests/test_basis_capped_coverage.py | 9 +- tests/test_coverage_grades.py | 24 +- tests/test_coverage_honesty.py | 10 +- tests/test_emitter_stability.py | 12 +- tests/test_generic_hooks_install.py | 121 +++ tests/test_guard_fail_to_ask.py | 75 +- tests/test_repo_standards.py | 8 +- tests/test_runtime_goldens.py | 38 + tests/test_vendor_wire_facts.py | 31 + 84 files changed, 14534 insertions(+), 167 deletions(-) create mode 100644 .agents/hooks.json create mode 100755 .chock/bin/antigravity.py create mode 100755 .chock/bin/codex_cli.py create mode 100755 .chock/bin/devin.py create mode 100755 .chock/bin/gemini_cli.py create mode 100755 .chock/bin/grok.py create mode 100755 .chock/bin/tabnine.py create mode 100755 .chock/bin/windsurf.py create mode 100644 .chock/compiled/block-destructive-commands/pre-tool-use/antigravity-hooks.json create mode 100644 .chock/compiled/block-destructive-commands/pre-tool-use/codex_cli-hooks.json create mode 100644 .chock/compiled/block-destructive-commands/pre-tool-use/devin-hooks.json create mode 100644 .chock/compiled/block-destructive-commands/pre-tool-use/gemini_cli-hooks.json create mode 100644 .chock/compiled/block-destructive-commands/pre-tool-use/grok-hooks.json create mode 100644 .chock/compiled/block-destructive-commands/pre-tool-use/tabnine-hooks.json create mode 100644 .chock/compiled/block-destructive-commands/pre-tool-use/windsurf-hooks.json create mode 100644 .chock/compiled/block-no-verify/pre-tool-use/antigravity-hooks.json create mode 100644 .chock/compiled/block-no-verify/pre-tool-use/codex_cli-hooks.json create mode 100644 .chock/compiled/block-no-verify/pre-tool-use/devin-hooks.json create mode 100644 .chock/compiled/block-no-verify/pre-tool-use/gemini_cli-hooks.json create mode 100644 .chock/compiled/block-no-verify/pre-tool-use/grok-hooks.json create mode 100644 .chock/compiled/block-no-verify/pre-tool-use/tabnine-hooks.json create mode 100644 .chock/compiled/block-no-verify/pre-tool-use/windsurf-hooks.json create mode 100644 .chock/compiled/protect-agent-config/pre-tool-use/antigravity-hooks.json create mode 100644 .chock/compiled/protect-agent-config/pre-tool-use/codex_cli-hooks.json create mode 100644 .chock/compiled/protect-agent-config/pre-tool-use/devin-hooks.json create mode 100644 .chock/compiled/protect-agent-config/pre-tool-use/gemini_cli-hooks.json create mode 100644 .chock/compiled/protect-agent-config/pre-tool-use/grok-hooks.json create mode 100644 .chock/compiled/protect-agent-config/pre-tool-use/tabnine-hooks.json create mode 100644 .chock/compiled/protect-agent-config/pre-tool-use/windsurf-hooks.json create mode 100644 .chock/compiled/protect-commit-privacy/pre-tool-use/antigravity-hooks.json create mode 100644 .chock/compiled/protect-commit-privacy/pre-tool-use/codex_cli-hooks.json create mode 100644 .chock/compiled/protect-commit-privacy/pre-tool-use/devin-hooks.json create mode 100644 .chock/compiled/protect-commit-privacy/pre-tool-use/gemini_cli-hooks.json create mode 100644 .chock/compiled/protect-commit-privacy/pre-tool-use/grok-hooks.json create mode 100644 .chock/compiled/protect-commit-privacy/pre-tool-use/tabnine-hooks.json create mode 100644 .chock/compiled/protect-commit-privacy/pre-tool-use/windsurf-hooks.json create mode 100644 .codex/hooks.json create mode 100644 .devin/hooks.v1.json create mode 100644 .gemini/settings.json create mode 100644 .grok/hooks/agentseam.json create mode 100644 .tabnine/agent/settings.json create mode 100644 .windsurf/hooks.json create mode 100644 src/chock/hooks/in_agent_generic.py create mode 100644 tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/antigravity-hooks.json create mode 100644 tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/codex_cli-hooks.json create mode 100644 tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/devin-hooks.json create mode 100644 tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/gemini_cli-hooks.json create mode 100644 tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/grok-hooks.json create mode 100644 tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/tabnine-hooks.json create mode 100644 tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/windsurf-hooks.json create mode 100644 tests/fixtures/runtime_goldens/antigravity.py create mode 100644 tests/fixtures/runtime_goldens/claude_code.py create mode 100644 tests/fixtures/runtime_goldens/codex_cli.py create mode 100644 tests/fixtures/runtime_goldens/cursor.py create mode 100644 tests/fixtures/runtime_goldens/devin.py create mode 100644 tests/fixtures/runtime_goldens/gemini_cli.py create mode 100644 tests/fixtures/runtime_goldens/grok.py create mode 100644 tests/fixtures/runtime_goldens/tabnine.py create mode 100644 tests/fixtures/runtime_goldens/vscode_copilot.py create mode 100644 tests/fixtures/runtime_goldens/windsurf.py create mode 100644 tests/test_generic_hooks_install.py create mode 100644 tests/test_runtime_goldens.py 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/.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/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 From d557ee83efaf44d23f55de774f05627f35acd3b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 00:34:04 +0000 Subject: [PATCH 2/3] Regenerate brand assets: junie joins the derived agent set Co-Authored-By: Claude Fable 5 Signed-off-by: Claude --- docs/assets/social-preview.png | Bin 74349 -> 74674 bytes docs/assets/social-preview.svg | 12 ++++++------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/assets/social-preview.png b/docs/assets/social-preview.png index fd4a9a85d2010fac59cad52aa0cbf1aff1dbfae4..2b95c2a4f9d46f24764e4558125c3fba58925661 100644 GIT binary patch literal 74674 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 literal 74349 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 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 From 5ac2632a52e4e5cf4ec4c977a956d8a02cfb71ed Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 00:36:24 +0000 Subject: [PATCH 3/3] CodeQL: ignore the frozen runtime goldens like the bundler output they freeze Co-Authored-By: Claude Fable 5 Signed-off-by: Claude --- .github/codeql/codeql-config.yml | 3 +++ 1 file changed, 3 insertions(+) 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/**"