From 9b8a7380b5281bcfb01c3eb0e591c49f08a576c0 Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:31:54 +0000 Subject: [PATCH 1/8] chock sync wires every agentseam adapter it can reach (E-ALL) Pin agentseam to main bebbfa6 as a direct reference (its newest adapters are unreleased). The agent list comes from agentseam's adapter registry: every adapter is a valid supported_agents name, and every one whose hook config sits in the repository as JSON or TOML gets hook entries and a .chock/bin/.py runtime. - New: factory_droid, goose (chock's own .agents/plugins/chock/ slot), openhands, and mistral_vibe (one owned block in .vibe/hooks.toml). - Each vendor's documented hook timeout field is recorded with its source. - Factory Droid's bare event map merges its after-tool recorder. - Sync prints agentseam's pre-tool tier row with its basis and date, and "Untested" for an agent the witness ledger has no live run of. - honours_ask claims for the four, tested against their rendered runtimes; CHOCK_CAN_ASK is 0 at each one's pre-tool gate, per vendor data. - Not wired yet, and named in a test with the reason: cline (hooks directory, one command per event) and junie, kimi_code (home config). - Grok's instruction file is AGENTS.md upstream now: the wrapper-deletion tests use junie's own file instead. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- README.md | 5 + changelog.d/sync-every-agentseam-adapter.md | 10 + docs/enforcement-surfaces.md | 64 +- pyproject.toml | 7 +- requirements/brand-assets.in | 2 +- requirements/brand-assets.txt | 5 +- src/chock/compile/emitters/in_agent_hooks.py | 13 +- .../compile/emitters/in_agent_tool_call.py | 8 +- src/chock/data/claims.json | 32 + src/chock/data/hook_timeouts.json | 24 + src/chock/hooks/in_agent_generic.py | 29 +- src/chock/hooks/in_agent_toml.py | 70 + src/chock/hooks/sync_notes.py | 39 + src/chock/scaffold/recompile.py | 3 + src/chock/vendors.py | 40 +- .../pre-tool-use/factory_droid-hooks.json | 14 + .../pre-tool-use/goose-hooks.json | 17 + .../pre-tool-use/mistral_vibe-hooks.json | 9 + .../pre-tool-use/openhands-hooks.json | 16 + .../factory_droid-write-hooks.json | 14 + .../pre-tool-use/goose-write-hooks.json | 17 + .../mistral_vibe-write-hooks.json | 9 + .../pre-tool-use/openhands-write-hooks.json | 16 + .../stop/factory_droid-hooks.json | 13 + .../stability-script/stop/goose-hooks.json | 16 + .../stop/mistral_vibe-hooks.json | 8 + .../stop/openhands-hooks.json | 15 + tests/fixtures/runtime_goldens/antigravity.py | 51 +- tests/fixtures/runtime_goldens/claude_code.py | 85 +- tests/fixtures/runtime_goldens/codex_cli.py | 61 +- tests/fixtures/runtime_goldens/cursor.py | 49 +- tests/fixtures/runtime_goldens/devin.py | 83 +- .../fixtures/runtime_goldens/factory_droid.py | 2438 +++++++++++++++++ tests/fixtures/runtime_goldens/gemini_cli.py | 83 +- tests/fixtures/runtime_goldens/goose.py | 2407 ++++++++++++++++ tests/fixtures/runtime_goldens/grok.py | 104 +- .../fixtures/runtime_goldens/mistral_vibe.py | 2432 ++++++++++++++++ tests/fixtures/runtime_goldens/openhands.py | 2407 ++++++++++++++++ tests/fixtures/runtime_goldens/tabnine.py | 61 +- .../runtime_goldens/vscode_copilot.py | 8 +- tests/fixtures/runtime_goldens/windsurf.py | 49 +- tests/runtime_payloads.py | 128 + tests/test_generic_hooks_install.py | 4 +- tests/test_guard_can_ask.py | 6 + tests/test_guard_fail_to_ask.py | 87 +- tests/test_init_preserves_adopter_files.py | 22 +- tests/test_surface_doc_matches_code.py | 2 + tests/test_sync_every_adapter.py | 162 ++ tests/test_tool_call_gate.py | 12 +- 49 files changed, 11000 insertions(+), 256 deletions(-) create mode 100644 changelog.d/sync-every-agentseam-adapter.md create mode 100644 src/chock/hooks/in_agent_toml.py create mode 100644 src/chock/hooks/sync_notes.py create mode 100644 tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/factory_droid-hooks.json create mode 100644 tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/goose-hooks.json create mode 100644 tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/mistral_vibe-hooks.json create mode 100644 tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/openhands-hooks.json create mode 100644 tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/factory_droid-write-hooks.json create mode 100644 tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/goose-write-hooks.json create mode 100644 tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/mistral_vibe-write-hooks.json create mode 100644 tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/openhands-write-hooks.json create mode 100644 tests/fixtures/emitter_stability/golden/stability-script/stop/factory_droid-hooks.json create mode 100644 tests/fixtures/emitter_stability/golden/stability-script/stop/goose-hooks.json create mode 100644 tests/fixtures/emitter_stability/golden/stability-script/stop/mistral_vibe-hooks.json create mode 100644 tests/fixtures/emitter_stability/golden/stability-script/stop/openhands-hooks.json create mode 100644 tests/fixtures/runtime_goldens/factory_droid.py create mode 100644 tests/fixtures/runtime_goldens/goose.py create mode 100644 tests/fixtures/runtime_goldens/mistral_vibe.py create mode 100644 tests/fixtures/runtime_goldens/openhands.py create mode 100644 tests/runtime_payloads.py create mode 100644 tests/test_sync_every_adapter.py diff --git a/README.md b/README.md index cb417044..7e639876 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,11 @@ Full nine-surface matrix and per-agent caveats: | **Grok** | native pre-tool-use hook | `.grok/hooks/agentseam.json` | | **Tabnine** | native pre-tool-use hook | `.tabnine/agent/settings.json` | | **Antigravity CLI** | native pre-tool-use hook | `.agents/hooks.json` | +| **Factory Droid** | native pre-tool-use hook | `.factory/hooks.json` | +| **goose** | native pre-tool-use hook | `.agents/plugins/chock/hooks/hooks.json` | +| **OpenHands** | native pre-tool-use hook | `.openhands/hooks.json` | +| **Mistral Vibe** | native pre-tool-use hook | `.vibe/hooks.toml` | +| **Cline** | git hook + CI gate only — one hook file per event; chock's multi-policy dispatch for it is not built yet | — | | **Aider** | git hook + CI gate only — no native hook API yet | — | | **Junie** | git hook + CI gate only — home-anchored config, outside chock's repo-scoped install | `~/.junie/config.json` | | **Kimi Code** | git hook + CI gate only — home-anchored config, outside chock's repo-scoped install | `~/.kimi-code/config.toml` | diff --git a/changelog.d/sync-every-agentseam-adapter.md b/changelog.d/sync-every-agentseam-adapter.md new file mode 100644 index 00000000..702d2fa4 --- /dev/null +++ b/changelog.d/sync-every-agentseam-adapter.md @@ -0,0 +1,10 @@ +- **`chock sync` wires Factory Droid, goose, OpenHands and Mistral Vibe.** The agent list comes from + agentseam's adapter registry: every adapter is a valid `supported_agents` name (`factory-droid`, `goose`, + `openhands`, `mistral-vibe`, `cline`), and every one whose hook config sits in the repository as JSON or TOML + gets its hook entries and a `.chock/bin/.py` runtime. Vibe's rules go in one owned block in + `.vibe/hooks.toml`; goose's go in `.agents/plugins/chock/hooks/hooks.json`. Each answers in its own + grammar; none can prompt, so an ask is a refusal there and `CHOCK_CAN_ASK` is 0. Cline (one executable per + event) and Junie and Kimi Code (home-directory config) are selectable but not yet wired. +- **Sync prints each wired agent's pre-tool row from agentseam's tier table**, with its basis and date, and + says "Untested" for an agent chock has no live witness of. +- **agentseam is pinned to main commit bebbfa6** as a direct reference until its next release. diff --git a/docs/enforcement-surfaces.md b/docs/enforcement-surfaces.md index 6d7a9f92..83cf4d26 100644 --- a/docs/enforcement-surfaces.md +++ b/docs/enforcement-surfaces.md @@ -166,21 +166,25 @@ Which surfaces each agent supports today (from `src/chock/compile/surfaces.py`): | Tabnine | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | | VS Code | ✅ | ✅ | ✅ | — | ✅ | — | ✅ | | Antigravity CLI | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | - -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). `stop` is derived the same way from the **turn-end** row, which -is a different question with a different answer: Grok and Windsurf can observe a finished turn -but not refuse one, so they are `detect` and get no column mark. Cursor cannot hold a turn either, -but its `stop` hook hands a refusal back to the agent as a `followup_message` (witnessed live on -3.21.18), which is `best-effort` and worth a mark: the agent is sent back to what the turn left -behind rather than the user being told after the fact. Copilot and VS Code refuse one too: their -`Stop` key in chock's own file was witnessed firing in VS Code agent mode (2026-09-28), and a block -answer made the agent carry on with the turn. It is registered under `Stop` alone, since `agentStop` fires -beside it and would run the gate twice; which of the top-level or nested block answers Copilot reads is -not isolated, so chock writes both. 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. +| Factory Droid | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | +| goose | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | +| OpenHands | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | +| Mistral Vibe | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | +| Cline | ✅ | ✅ | ✅ | — | — | — | — | + +In-agent membership derives from agentseam's matrix: every adapted vendor whose row can block a pre-tool call +from a repo-level JSON or TOML hook config gets `pre-tool-use` (Copilot CLI and VS Code: `agent-hooks`, chock's +owned file). `stop` is derived the same way from the **turn-end** row, which is a different question with a +different answer: Grok and Windsurf can observe a finished turn but not refuse one, so they are `detect` and get +no column mark. Cursor cannot hold a turn either, but its `stop` hook hands a refusal back to the agent as a +`followup_message` (witnessed live on 3.21.18), which is `best-effort` and worth a mark: the agent is sent back +to what the turn left behind rather than the user being told after the fact. Copilot and VS Code refuse one too: +their `Stop` key in chock's own file was witnessed firing in VS Code agent mode (2026-09-28), and a block answer +made the agent carry on with the turn. It is registered under `Stop` alone, since `agentStop` fires beside it +and would run the gate twice; which of the top-level or nested block answers Copilot reads is not isolated, so +chock writes both. Junie and Kimi Code block only via home configs, outside what `chock sync --repo` may write; +Cline's per-event hooks directory is not wired yet; Aider and Replit cannot block. A checkmark is a wiring +claim: new-vendor cells stay `witnessed: false` until a real client run is recorded; sync calls them "Untested". ## Coverage levels @@ -190,22 +194,20 @@ evidence cap that bounds every grade -- is on its own page: **[Coverage Levels]( ## How a policy picks its surfaces -A hook that must stop a command targets `git-hook` + `ci-gate` (the universal floor) and, where -available, `pre-tool-use` + `managed-setting`. A hook whose `on:` includes `tool_use` is compiled to -the `pre-tool-use` surface on agents that support it (nine of them, Claude Code and Cursor -among them; Copilot -CLI and VS Code get the same guard via `agent-hooks`, and a content gate's write check as a `PreToolUse` entry over `Edit|Write` there). A hook whose `on:` includes -`tool_call` (a `content_regex` or `script` gate with `params.tools`, globs over tool names such as -`mcp__Firecrawl__*` or `WebFetch`) gates PreToolUse for any tool by name, on the five agents whose -vendor facts record a tool vocabulary (Claude Code, Codex CLI, Cursor, Gemini CLI, and Copilot -via `agent-hooks`); the others get no `tool_call` entry and are credited nothing for it. A gate's -`outside_repo` globs let its PreToolUse write check see declared files outside the repository (the -turn-end check cannot). A `script` gate on `tool_call` also keeps a local per-session tool-call log -under `.chock/state/` that its script can read; see -[`spec/session-log.md`](../spec/session-log.md). A best-practice rule with -no deterministic check compiles only to `ambient-rule`. The compiler always pairs a control with the -**strongest available backstop** — e.g. a git hook plus a CI gate, because a git hook alone can be -skipped with `--no-verify`. +A hook that must stop a command targets `git-hook` + `ci-gate` (the universal floor) and, where available, +`pre-tool-use` + `managed-setting`. A hook whose `on:` includes `tool_use` is compiled to the `pre-tool-use` +surface on agents that support it (thirteen of them, Claude Code and Cursor among them; Copilot CLI and VS Code +get the same guard via `agent-hooks`, and a content gate's write check as a `PreToolUse` entry over `Edit|Write` +there). A hook whose `on:` includes `tool_call` (a `content_regex` or `script` gate with `params.tools`, globs +over tool names such as `mcp__Firecrawl__*` or `WebFetch`) gates PreToolUse for any tool by name, on the nine +agents whose vendor facts record a tool vocabulary (Claude Code, Codex CLI, Cursor, Gemini CLI, Factory Droid, +goose, Mistral Vibe, OpenHands, and Copilot via `agent-hooks`); the others get no `tool_call` entry and are +credited nothing for it. A gate's `outside_repo` globs let its PreToolUse write check see declared files outside +the repository (the turn-end check cannot). A `script` gate on `tool_call` also keeps a local per-session +tool-call log under `.chock/state/` that its script can read; see +[`spec/session-log.md`](../spec/session-log.md). A best-practice rule with no deterministic check compiles only +to `ambient-rule`. The compiler always pairs a control with the **strongest available backstop** — e.g. a git +hook plus a CI gate, because a git hook alone can be skipped with `--no-verify`. ## What happens when the guard cannot decide diff --git a/pyproject.toml b/pyproject.toml index 4f68c723..e0f3750f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,9 +58,10 @@ dependencies = [ # Build-time only: chock's plugin packaging consumes agentseam's per-agent layout # data (packaging.plan/supports/plugin_root/executable_ref) to render plugin bundles. # It never ships to an adopter repo -- the vendored PreToolUse/SessionStart runners - # stay self-contained stdlib-only files, unaffected by this dependency. Pinned exact - # per plan/spine-a/contract.md: the wave boundary is the published PyPI artifact. - "agentseam==0.3.5", + # stay self-contained stdlib-only files, unaffected by this dependency. Pre-1.0 it is + # pinned to an agentseam main commit (its newest adapters are unreleased); release week + # swaps this for the released version, since PyPI refuses a direct reference. + "agentseam @ git+https://github.com/open-coder-ai/agentseam@bebbfa6f85cc6512238ecad5114c00d99952f664", ] [project.optional-dependencies] diff --git a/requirements/brand-assets.in b/requirements/brand-assets.in index a2cff98f..4e376693 100644 --- a/requirements/brand-assets.in +++ b/requirements/brand-assets.in @@ -1,5 +1,5 @@ pyyaml>=6.0 jsonschema>=4.18,<5 referencing>=0.35,<0.38 -agentseam==0.3.5 +agentseam @ https://github.com/open-coder-ai/agentseam/archive/bebbfa6f85cc6512238ecad5114c00d99952f664.tar.gz cairosvg==2.9.0 diff --git a/requirements/brand-assets.txt b/requirements/brand-assets.txt index bee68487..ec32a6c7 100644 --- a/requirements/brand-assets.txt +++ b/requirements/brand-assets.txt @@ -4,9 +4,8 @@ # # pip-compile --generate-hashes --no-index --output-file=requirements/brand-assets.txt --strip-extras requirements/brand-assets.in # -agentseam==0.3.5 \ - --hash=sha256:429bc5d55930fdad9a6c102a19f516cab9e84a1ed43fdecb1ac02b52060ac51d \ - --hash=sha256:7a34880b81fc2b6f125874f7b05da35a754d03ed8ecc35947df7c22108d9f87e +agentseam @ https://github.com/open-coder-ai/agentseam/archive/bebbfa6f85cc6512238ecad5114c00d99952f664.tar.gz \ + --hash=sha256:0000000000000000000000000000000000000000000000000000000000000000 # via -r requirements/brand-assets.in attrs==26.1.0 \ --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ diff --git a/src/chock/compile/emitters/in_agent_hooks.py b/src/chock/compile/emitters/in_agent_hooks.py index c9c3bc0a..ec4d14a8 100644 --- a/src/chock/compile/emitters/in_agent_hooks.py +++ b/src/chock/compile/emitters/in_agent_hooks.py @@ -14,6 +14,9 @@ #: A client hook timeout fails OPEN, so it must fire after the engine's own (ask-a-person) timers. TIMEOUT_SECONDS = ENGINE_BUDGET_SECONDS + STARTUP_MARGIN_SECONDS +#: A vendor's hook-config document: a JSON object, or a flat list of rules (mistral_vibe's TOML `[[hooks]]`). +HookDoc = dict[str, Any] | list[dict[str, Any]] + _MS_PER_SECOND = 1000 #: The hooks-schema timeout field and unit of each generic vendor, with the doc it was read from. VENDOR_TIMEOUTS: dict[str, dict[str, Any]] = json.loads( @@ -63,7 +66,7 @@ def vendor_timeout(vendor: str) -> tuple[str, int] | None: return facts["field"], TIMEOUT_SECONDS * (_MS_PER_SECOND if facts["unit"] == "ms" else 1) -def with_timeout(vendor: str, doc: dict[str, Any]) -> dict[str, Any]: +def with_timeout(vendor: str, doc: HookDoc) -> HookDoc: """`doc` (agentseam's rendering) with `vendor`'s timeout on every hook entry; unchanged where it has no key.""" timeout = vendor_timeout(vendor) if timeout is not None: @@ -84,7 +87,7 @@ def _stamp(node: Any, field: str, value: int) -> None: _stamp(child, field, value) -def generic_hooks_file(vendor: str, command: str) -> dict[str, Any]: +def generic_hooks_file(vendor: str, command: str) -> HookDoc: """`vendor`'s full hook-config document for one guard command, agentseam's rendering plus its timeout. Paths inside `command` are repo-relative and resolve wherever the session started: the @@ -93,17 +96,17 @@ def generic_hooks_file(vendor: str, command: str) -> dict[str, Any]: return generic_pre_tool_config(vendor, command, vendors.shell_matcher(vendor)) -def generic_pre_tool_config(vendor: str, command: str, matcher: str | None) -> dict[str, Any]: +def generic_pre_tool_config(vendor: str, command: str, matcher: str | None) -> HookDoc: """The vendor's pre-tool hook-config document running `command`, with its timeout.""" return with_timeout(vendor, vendors.pre_tool_hook_config(vendor, command, matcher=matcher)) -def generic_stop_config(vendor: str, command: str) -> dict[str, Any]: +def generic_stop_config(vendor: str, command: str) -> HookDoc: """The vendor's turn-end hook-config document running `command`, with its timeout.""" return with_timeout(vendor, vendors.stop_hook_config(vendor, command)) -def generic_post_tool_config(vendor: str, command: str) -> dict[str, Any]: +def generic_post_tool_config(vendor: str, command: str) -> HookDoc: """The vendor's after-tool hook-config document running `command`, with its timeout.""" return with_timeout(vendor, vendors.post_tool_hook_config(vendor, command)) diff --git a/src/chock/compile/emitters/in_agent_tool_call.py b/src/chock/compile/emitters/in_agent_tool_call.py index c87639ec..0e47fdbb 100644 --- a/src/chock/compile/emitters/in_agent_tool_call.py +++ b/src/chock/compile/emitters/in_agent_tool_call.py @@ -111,7 +111,13 @@ def _generic(vendor: str, output_dir: Path, policy_id: str, spec: dict[str, Any] pre, record = _commands(vendor, policy_id, PRE_SURFACE) doc = generic_pre_tool_config(vendor, pre, _matcher(spec)) if records_session(spec): - doc["hooks"] = {**doc["hooks"], **generic_post_tool_config(vendor, record)["hooks"]} + post = generic_post_tool_config(vendor, record) + if isinstance(doc, list): # mistral_vibe: a flat list of rules + doc = [*doc, *post] + elif vendors.hook_entry_bare(vendor): # factory_droid: the document is the event map itself + doc = {**doc, **post} + else: + doc["hooks"] = {**doc["hooks"], **post["hooks"]} _write(output_dir, f"{vendor}{GENERIC_SUFFIX}", doc, written) diff --git a/src/chock/data/claims.json b/src/chock/data/claims.json index 91a95532..1e8c2247 100644 --- a/src/chock/data/claims.json +++ b/src/chock/data/claims.json @@ -78,5 +78,37 @@ "honours": false, "evidence": "tested", "test": "tests/test_guard_fail_to_ask.py::test_a_crashed_guard_asks_rather_than_allowing" + }, + { + "agent": "factory_droid", + "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": "goose", + "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": "openhands", + "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": "mistral_vibe", + "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" } ] diff --git a/src/chock/data/hook_timeouts.json b/src/chock/data/hook_timeouts.json index a99caf9b..9c93074b 100644 --- a/src/chock/data/hook_timeouts.json +++ b/src/chock/data/hook_timeouts.json @@ -17,18 +17,42 @@ "default_seconds": 600, "source": "https://docs.devin.ai/cli/extensibility/hooks/overview" }, + "factory_droid": { + "field": "timeout", + "unit": "s", + "default_seconds": 60, + "source": "https://docs.factory.ai/reference/hooks-reference" + }, "gemini_cli": { "field": "timeout", "unit": "ms", "default_seconds": 60, "source": "https://github.com/google-gemini/gemini-cli/blob/main/docs/hooks/reference.md" }, + "goose": { + "field": "timeout", + "unit": "s", + "default_seconds": 30, + "source": "https://goose-docs.ai/docs/guides/context-engineering/hooks/" + }, "grok": { "field": "timeout", "unit": "s", "default_seconds": 5, "source": "https://docs.x.ai/build/features/hooks" }, + "mistral_vibe": { + "field": "timeout", + "unit": "s", + "default_seconds": 60, + "source": "https://docs.mistral.ai/vibe/code/cli/hooks" + }, + "openhands": { + "field": "timeout", + "unit": "s", + "default_seconds": 60, + "source": "https://docs.openhands.dev/openhands/usage/customization/hooks" + }, "tabnine": { "field": "timeout", "unit": "ms", diff --git a/src/chock/hooks/in_agent_generic.py b/src/chock/hooks/in_agent_generic.py index 98122eb8..5eb10d39 100644 --- a/src/chock/hooks/in_agent_generic.py +++ b/src/chock/hooks/in_agent_generic.py @@ -8,8 +8,11 @@ from chock import vendors from chock.emit import write_generated_json +from chock.hooks.in_agent_toml import install_toml, installed_toml_ids from chock.hooks.runtime_vendor import runtime_rel, vendor_runtime +_TOML = "toml" + def load_config(path: Path) -> dict: """The vendor's config file as a dict; a file that is not readable JSON is refused.""" @@ -81,21 +84,31 @@ def _merge(settings: dict, fragment: dict) -> None: FRAGMENT_SURFACES = ("pre-tool-use", "stop") -def _fragments(repo_root: Path, vendor: str) -> list[tuple[str, dict]]: +def _fragments(repo_root: Path, vendor: str) -> list[tuple[str, Any]]: compiled = repo_root / ".chock" / "compiled" globs = [f"*/{surface}/{vendor}-hooks.json" for surface in FRAGMENT_SURFACES] globs += [f"*/pre-tool-use/{vendor}-write-hooks.json", f"*/pre-tool-use/{vendor}-tool-call-hooks.json"] - found: list[tuple[str, dict]] = [] + found: list[tuple[str, Any]] = [] for path in sorted(path for glob in globs for path in compiled.glob(glob)): try: fragment = json.loads(path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): continue - if isinstance(fragment, dict): + if isinstance(fragment, dict) or (isinstance(fragment, list) and vendors.config_format(vendor) == _TOML): found.append((path.parent.parent.name, fragment)) return found +def _sync_runtime(repo_root: Path, vendor: str, *, wanted: bool) -> None: + """Vendor `vendor`'s runtime while a hook entry runs it; otherwise drop it.""" + if wanted: + vendor_runtime(repo_root, vendor) + return + vendored = repo_root / runtime_rel(vendor) + if vendored.exists(): + vendored.unlink() + + def install_generic(repo_root: Path, vendor: str, *, uninstall: bool = False) -> list[str]: """Merge `vendor`'s compiled fragments into its recorded config file, keeping entries not ours. @@ -106,6 +119,10 @@ def install_generic(repo_root: Path, vendor: str, *, uninstall: bool = False) -> repo_root = Path(repo_root) marker = _marker(vendor) fragments = [] if uninstall else _fragments(repo_root, vendor) + if vendors.config_format(vendor) == _TOML: + installed = install_toml(repo_root, vendor, fragments) + _sync_runtime(repo_root, vendor, wanted=bool(installed)) + return installed config_path = repo_root / vendors.config_path(vendor) settings = load_config(config_path) prior: dict[str, dict] = {} @@ -113,9 +130,7 @@ def install_generic(repo_root: Path, vendor: str, *, uninstall: bool = False) -> _strip_ours(settings, marker) if not fragments: - vendored = repo_root / runtime_rel(vendor) - if vendored.exists(): - vendored.unlink() + _sync_runtime(repo_root, vendor, wanted=False) if config_path.exists() and prior: if settings: write_generated_json(config_path, settings) @@ -134,6 +149,8 @@ def install_generic(repo_root: Path, vendor: str, *, uninstall: bool = False) -> 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) + if vendors.config_format(vendor) == _TOML: + return installed_toml_ids(repo_root, vendor, _fragments(repo_root, vendor)) marker = _marker(vendor) config_path = repo_root / vendors.config_path(vendor) if not config_path.exists(): diff --git a/src/chock/hooks/in_agent_toml.py b/src/chock/hooks/in_agent_toml.py new file mode 100644 index 00000000..4e316eed --- /dev/null +++ b/src/chock/hooks/in_agent_toml.py @@ -0,0 +1,70 @@ +"""TOML in-agent install (mistral_vibe): chock's rules as one owned block in the vendor's own file.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from agentseam import adapters +from agentseam.install_config import ConfigUnreadableError, block_bounds, read_text, remove_block, write_block + +from chock import vendors + +#: The block owner: `# >>> agentseam >>> chock` ... `# <<< agentseam <<< chock`, beside any other owner's. +OWNER = "chock" + + +def _read(path: Path) -> str: + """The file's text; one that is not readable text is refused, never overwritten.""" + try: + return str(read_text(str(path))) + except (OSError, ConfigUnreadableError): + msg = f"{path} is not readable text; leaving it untouched" + raise ValueError(msg) from None + + +def _rules(fragment: Any) -> list[dict[str, Any]]: + return [rule for rule in fragment if isinstance(rule, dict)] if isinstance(fragment, list) else [] + + +def _render(vendor: str, rules: list[dict[str, Any]]) -> str: + return str(adapters.get(vendor).render_config(rules)) + + +def _owned_block(path: Path) -> str: + """Our block's text in `path`, or "" where the file or the block is absent.""" + if not path.is_file(): + return "" + text = _read(path) + bounds = block_bounds(text, OWNER) + return text[bounds[0] : bounds[1]] if bounds else "" + + +def install_toml(repo_root: Path, vendor: str, fragments: list[tuple[str, Any]]) -> list[str]: + """Write every fragment's rules as our one block; with none, remove the block (and a file left empty).""" + path = Path(repo_root) / vendors.config_path(vendor) + rules = [rule for _policy_id, fragment in fragments for rule in _rules(fragment)] + if not rules: + if _owned_block(path): + remove_block(str(path), OWNER) + if not _read(path).strip(): + path.unlink() + return [] + if path.exists(): + _read(path) + path.parent.mkdir(parents=True, exist_ok=True) + write_block(str(path), _render(vendor, rules), OWNER) + return [policy_id for policy_id, fragment in fragments if _rules(fragment)] + + +def installed_toml_ids(repo_root: Path, vendor: str, fragments: list[tuple[str, Any]]) -> set[str]: + """Policy ids whose every rule is rendered inside our block.""" + try: + block = _owned_block(Path(repo_root) / vendors.config_path(vendor)) + except ValueError: + return set() + return { + policy_id + for policy_id, fragment in fragments + if _rules(fragment) and all(_render(vendor, [rule]).strip() in block for rule in _rules(fragment)) + } diff --git a/src/chock/hooks/sync_notes.py b/src/chock/hooks/sync_notes.py new file mode 100644 index 00000000..dc9a59d1 --- /dev/null +++ b/src/chock/hooks/sync_notes.py @@ -0,0 +1,39 @@ +"""What `chock sync` says about each wired agent: agentseam's pre-tool row, and whether chock has seen it run.""" + +from __future__ import annotations + +from agentseam import tier_table + +from chock import evidence + +#: Ledger surfaces that witness chock's own hooks deciding in a client; git-hook-env witnesses a marker only. +_HOOK_SURFACES = ("pre-tool-use", "agent-hooks", evidence.PLUGIN_HOOK) + +UNTESTED = ( + "Untested: chock has not witnessed its hooks run in this client; the wiring follows agentseam's record. " + "What you see there is wanted as evidence: open-coder-ai/agentseam issues labelled 'evidence wanted'." +) + + +def tier_line(vendor: str) -> str | None: + """agentseam's pre-tool row for `vendor`, its basis carried through; None where it has no row.""" + row = next((r for r in tier_table.rows() if r["agent"] == vendor), None) + if row is None: + return None + return ( + f"pre-tool: {row['tier']}, {row['grade']}, fail mode {row['fail_mode']}" + f" (agentseam basis {row['basis']}, recorded {row['recorded']})" + ) + + +def witnessed(vendor: str) -> bool: + """Whether the ledger holds a live observation of chock's hooks deciding in `vendor`'s client.""" + return any(evidence.witness(vendor, surface) for surface in _HOOK_SURFACES) + + +def notes(vendor: str) -> list[str]: + """The lines sync prints under a vendor it registered hooks for.""" + lines = [line for line in (tier_line(vendor),) if line] + if not witnessed(vendor): + lines.append(UNTESTED) + return [f" {vendor}: {line}" for line in lines] diff --git a/src/chock/scaffold/recompile.py b/src/chock/scaffold/recompile.py index e6d87c50..e9de0e2a 100644 --- a/src/chock/scaffold/recompile.py +++ b/src/chock/scaffold/recompile.py @@ -13,6 +13,7 @@ from chock.compile.levels import DISABLED, Grade from chock.config import agents_from_config, guidance_mcp_enabled, load_config, policy_status from chock.emit import write_generated_json +from chock.hooks import sync_notes from chock.hooks.in_agent_install import ( WIRED_VENDORS, agent_hooks_rel, @@ -144,6 +145,8 @@ def _install_vendors(repo_root: Path, wired: tuple[str, ...]) -> list[str]: else: if installed: print(f"Registered {len(installed)} {install_label(vendor)}") + for line in sync_notes.notes(vendor): + print(line) if hint := vendors.trust_hint(vendor): # Codex skips an untrusted project hook without a word: wired is not live. print(f" ACTION NEEDED ({vendor}): hooks are not live until trusted -- {hint}") diff --git a/src/chock/vendors.py b/src/chock/vendors.py index b92bb522..ef058ad1 100644 --- a/src/chock/vendors.py +++ b/src/chock/vendors.py @@ -9,7 +9,9 @@ from agentseam import matrix as _matrix from agentseam.vendor_config import VENDOR_CONFIG -CHOCK_AGENT: dict[str, str] = { +#: chock's short agent names where they differ from agentseam's vendor id, plus agents agentseam has +#: no adapter for (aider, replit: instruction files only). +_ALIASES: dict[str, str] = { "claude": "claude_code", "cursor": "cursor", "windsurf": "windsurf", @@ -27,6 +29,21 @@ "antigravity": "antigravity", } +#: Every agent name `supported_agents` accepts: the aliases, then each agentseam adapter not yet +#: named, under its own id with `-` for `_`. A new upstream adapter needs no change here. +CHOCK_AGENT: dict[str, str] = { + **_ALIASES, + **{v.replace("_", "-"): v for v in sorted(VENDOR_CONFIG) if v not in _ALIASES.values()}, +} + +#: Config formats `chock sync` merges into: a JSON document, or an owned block in a TOML file. +#: `hook_dir` (Cline: one executable per event) is not one: it runs one command per event, and +#: chock's per-policy entries would need a single multi-policy dispatcher there. +SYNC_FORMATS = ("json", "toml") +#: agentseam names its own plugin directory in a path whose owner is the installer (goose). +_UPSTREAM_OWNER_SLOT = "/agentseam/" +_OWNER_SLOT = "/chock/" + def entry(vendor: str) -> dict[str, Any]: """agentseam's vendor-config entry for `vendor`; chock reads it, never re-records it.""" @@ -34,14 +51,19 @@ def entry(vendor: str) -> dict[str, Any]: def repo_wirable(vendor: str) -> bool: - """Whether `chock sync --repo` can reach the vendor's hook config: a repo-relative JSON file. + """Whether `chock sync --repo` can reach the vendor's hook config: a repo-relative JSON or TOML file. - A home-anchored config path (junie, kimi_code) or a non-JSON format is outside the + A home-anchored config path (junie, kimi_code) or a hooks directory (cline) 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("~") + return facts["config_format"] in SYNC_FORMATS and not str(facts["config_path"]).startswith("~") + + +def config_format(vendor: str) -> str: + """The vendor's hook-config format as agentseam records it (`json`, `toml`, `hook_dir`).""" + return str(entry(vendor)["config_format"]) #: Vendors chock wires through its OWN hooks file (.github/hooks/chock.json) instead of the @@ -76,8 +98,8 @@ def stop_vendors() -> tuple[str, ...]: def config_path(vendor: str) -> str: - """Repo-relative path of the file the vendor reads hook wiring from.""" - return str(entry(vendor)["config_path"]) + """Repo-relative path of the file the vendor reads hook wiring from; chock's own plugin slot for goose.""" + return str(entry(vendor)["config_path"]).replace(_UPSTREAM_OWNER_SLOT, _OWNER_SLOT, 1) def repo_root_token(vendor: str) -> str | None: @@ -155,7 +177,7 @@ def tool_call_vendors() -> tuple[str, ...]: ) -def post_tool_hook_config(vendor: str, command: str) -> dict[str, Any]: +def post_tool_hook_config(vendor: str, command: str) -> Any: """The vendor's hook-config document running `command` after a tool call, with no matcher.""" return _adapters.get(vendor).hook_config((_contract.POST_TOOL,), command, None) @@ -193,12 +215,12 @@ def hook_entry_flat(vendor: str) -> bool: return (entry or {}).get("wrapper") == "cursor" -def pre_tool_hook_config(vendor: str, command: str, matcher: str | None = None) -> dict[str, Any]: +def pre_tool_hook_config(vendor: str, command: str, matcher: str | None = None) -> 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 stop_hook_config(vendor: str, command: str) -> dict[str, Any]: +def stop_hook_config(vendor: str, command: str) -> Any: """The vendor's complete hook-config document running `command` when a turn ends. No matcher: the turn-end event carries no tool to match on, which is why this surface diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/factory_droid-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/factory_droid-hooks.json new file mode 100644 index 00000000..b1fd105e --- /dev/null +++ b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/factory_droid-hooks.json @@ -0,0 +1,14 @@ +{ + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/factory_droid.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", + "timeout": 45 + } + ], + "matcher": "Execute" + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/goose-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/goose-hooks.json new file mode 100644 index 00000000..f98aeada --- /dev/null +++ b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/goose-hooks.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/goose.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", + "on_failure": "block", + "timeout": 45 + } + ], + "matcher": "shell" + } + ] + } +} \ No newline at end of file diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/mistral_vibe-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/mistral_vibe-hooks.json new file mode 100644 index 00000000..ebc8a77d --- /dev/null +++ b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/mistral_vibe-hooks.json @@ -0,0 +1,9 @@ +[ + { + "type": "pre_tool", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/mistral_vibe.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", + "match": "bash|powershell|git_bash", + "name": "agentseam-pre_tool-2cdbf332", + "timeout": 45 + } +] \ No newline at end of file diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/openhands-hooks.json b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/openhands-hooks.json new file mode 100644 index 00000000..48781090 --- /dev/null +++ b/tests/fixtures/emitter_stability/golden/stability-hook/pre-tool-use/openhands-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/openhands.py --guard \"tests/fixtures/emitter_stability/policies/stability-hook/implementations/stability-hook.sh\"", + "timeout": 45 + } + ], + "matcher": "terminal" + } + ] + } +} \ No newline at end of file diff --git a/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/factory_droid-write-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/factory_droid-write-hooks.json new file mode 100644 index 00000000..5b8516d9 --- /dev/null +++ b/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/factory_droid-write-hooks.json @@ -0,0 +1,14 @@ +{ + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/factory_droid.py --gate \".chock/compiled/stability-script/pre-tool-use/gate.json\"", + "timeout": 45 + } + ], + "matcher": "Create|Edit|MultiEdit|ApplyPatch" + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/goose-write-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/goose-write-hooks.json new file mode 100644 index 00000000..3b36d858 --- /dev/null +++ b/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/goose-write-hooks.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/goose.py --gate \".chock/compiled/stability-script/pre-tool-use/gate.json\"", + "on_failure": "block", + "timeout": 45 + } + ], + "matcher": "write|edit" + } + ] + } +} \ No newline at end of file diff --git a/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/mistral_vibe-write-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/mistral_vibe-write-hooks.json new file mode 100644 index 00000000..35e96c8d --- /dev/null +++ b/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/mistral_vibe-write-hooks.json @@ -0,0 +1,9 @@ +[ + { + "type": "pre_tool", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/mistral_vibe.py --gate \".chock/compiled/stability-script/pre-tool-use/gate.json\"", + "match": "write_file|edit", + "name": "agentseam-pre_tool-ab03e5ae", + "timeout": 45 + } +] \ No newline at end of file diff --git a/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/openhands-write-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/openhands-write-hooks.json new file mode 100644 index 00000000..e85a8aae --- /dev/null +++ b/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/openhands-write-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/openhands.py --gate \".chock/compiled/stability-script/pre-tool-use/gate.json\"", + "timeout": 45 + } + ], + "matcher": "file_editor" + } + ] + } +} \ No newline at end of file diff --git a/tests/fixtures/emitter_stability/golden/stability-script/stop/factory_droid-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/stop/factory_droid-hooks.json new file mode 100644 index 00000000..6a59bec4 --- /dev/null +++ b/tests/fixtures/emitter_stability/golden/stability-script/stop/factory_droid-hooks.json @@ -0,0 +1,13 @@ +{ + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/factory_droid.py --gate \".chock/compiled/stability-script/stop/gate.json\"", + "timeout": 45 + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/emitter_stability/golden/stability-script/stop/goose-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/stop/goose-hooks.json new file mode 100644 index 00000000..9bf01e21 --- /dev/null +++ b/tests/fixtures/emitter_stability/golden/stability-script/stop/goose-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/goose.py --gate \".chock/compiled/stability-script/stop/gate.json\"", + "on_failure": "block", + "timeout": 45 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/tests/fixtures/emitter_stability/golden/stability-script/stop/mistral_vibe-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/stop/mistral_vibe-hooks.json new file mode 100644 index 00000000..f0201222 --- /dev/null +++ b/tests/fixtures/emitter_stability/golden/stability-script/stop/mistral_vibe-hooks.json @@ -0,0 +1,8 @@ +[ + { + "type": "post_agent", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/mistral_vibe.py --gate \".chock/compiled/stability-script/stop/gate.json\"", + "name": "agentseam-post_agent-ba650ba3", + "timeout": 45 + } +] \ No newline at end of file diff --git a/tests/fixtures/emitter_stability/golden/stability-script/stop/openhands-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/stop/openhands-hooks.json new file mode 100644 index 00000000..cb993f6b --- /dev/null +++ b/tests/fixtures/emitter_stability/golden/stability-script/stop/openhands-hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/openhands.py --gate \".chock/compiled/stability-script/stop/gate.json\"", + "timeout": 45 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/tests/fixtures/runtime_goldens/antigravity.py b/tests/fixtures/runtime_goldens/antigravity.py index 72ca2602..1a4b6164 100644 --- a/tests/fixtures/runtime_goldens/antigravity.py +++ b/tests/fixtures/runtime_goldens/antigravity.py @@ -29,6 +29,7 @@ from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings +import zlib as _zlib # ------------------------------------------------------------------------------ # contract (agentseam 0.3.5) @@ -253,12 +254,25 @@ def _segment(node, part): 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 _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + def _walk(node, path): - """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None if "[]." in path: head, sub = path.split("[].", 1) - items = _walk(node, head) - if not isinstance(items, (list, tuple)): + items = _list_at(node, head) + if items is None: return None joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) return joined or None @@ -288,7 +302,7 @@ def _tool_input_raw(cfg, raw): if not isinstance(raw, dict): return None for key in cfg["fields"].get("tool_input", ("tool_input",)): - value = raw.get(key) + value = _walk(raw, key) if value is not None: return value return None @@ -479,6 +493,8 @@ def hj_respond(cfg, decision, event, wire=None): return "", 0 if gate["grammar"] == "G2": return _g2(v, gate, decision, name) + if gate["grammar"] == "G7": + return _g7(v, decision, wire) return _g1(v, gate, decision, wire, name) _WINDOWS_KEYS = ("commandWindows", "windows") @@ -494,16 +510,25 @@ def _hook_dict(cfg, command): entry[key] = value return entry +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): rules = [] for ev in canonical_events: name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: - rule["matcher"] = matcher - rules.append(rule) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) return rules def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): @@ -532,6 +557,10 @@ def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): hooks.setdefault(extra, []).append({"command": command}) return {"hooks": hooks} +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] hooks = {} @@ -562,6 +591,8 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) if wrapper == "flat_entries": return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) return _default_wrapper(cfg, reverse, canonical_events, command, matcher) def antigravity_wire(raw): diff --git a/tests/fixtures/runtime_goldens/claude_code.py b/tests/fixtures/runtime_goldens/claude_code.py index f39b0fd3..f8e1fa2a 100644 --- a/tests/fixtures/runtime_goldens/claude_code.py +++ b/tests/fixtures/runtime_goldens/claude_code.py @@ -29,6 +29,7 @@ from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings +import zlib as _zlib # ------------------------------------------------------------------------------ # contract (agentseam 0.3.5) @@ -235,11 +236,31 @@ def tool_input_of(raw): "effort", ) +_MCP_PREFIX = "mcp_" + +_CLAUDE_MCP_PREFIX = "mcp__" + 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 names_a_single_underscore_mcp_tool(raw): + """True when tool_name is an MCP tool ID in a spelling Claude Code never sends.""" + name = raw.get("tool_name") if isinstance(raw, dict) else None + return isinstance(name, str) and name.startswith(_MCP_PREFIX) and not name.startswith(_CLAUDE_MCP_PREFIX) + +def event_keys_agree(raw): + """True when hookEventName names hook_event_name's event: the same text, or Grok Build's snake_case twin.""" + snake, pascal = (raw.get("hookEventName"), raw.get("hook_event_name")) if isinstance(raw, dict) else (None, None) + if not isinstance(pascal, str) or not isinstance(snake, str): + return False + return snake == pascal or (snake.islower() and snake.replace("_", "") == pascal.lower()) + +PROBES = { + "looks_like_claude_code": looks_like_claude_code, + "names_a_single_underscore_mcp_tool": names_a_single_underscore_mcp_tool, + "event_keys_agree": event_keys_agree, +} UNREADABLE_NAME = "" @@ -265,12 +286,22 @@ def _rejected_by_markers(c, raw): for marker in c.get("reject_markers", ()): if marker in raw: return True + for key, values in c.get("reject_values", {}).items(): + if key in raw and raw[key] in values: + return True for probe, markers in c.get("reject_markers_unless_probe", {}).items(): if any(marker in raw for marker in markers) and not PROBES[probe](raw): return True return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) +def _accepted_by_values(c, raw): + """True unless the entry names accepted values and the payload carries none of them.""" + values = c.get("accept_values") + return not values or any(key in raw and raw[key] in allowed for key, allowed in values.items()) + def _accepted_by_markers(c, raw, name): + if not _accepted_by_values(c, raw): + return False accept = c.get("accept_markers", ()) if not accept or any(marker in raw for marker in accept): return True @@ -309,12 +340,25 @@ def _segment(node, part): 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 _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + def _walk(node, path): - """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None if "[]." in path: head, sub = path.split("[].", 1) - items = _walk(node, head) - if not isinstance(items, (list, tuple)): + items = _list_at(node, head) + if items is None: return None joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) return joined or None @@ -344,7 +388,7 @@ def _tool_input_raw(cfg, raw): if not isinstance(raw, dict): return None for key in cfg["fields"].get("tool_input", ("tool_input",)): - value = raw.get(key) + value = _walk(raw, key) if value is not None: return value return None @@ -566,6 +610,8 @@ def hj_respond(cfg, decision, event, wire=None): return "", 0 if gate["grammar"] == "G2": return _g2(v, gate, decision, name) + if gate["grammar"] == "G7": + return _g7(v, decision, wire) return _g1(v, gate, decision, wire, name) _WINDOWS_KEYS = ("commandWindows", "windows") @@ -581,16 +627,25 @@ def _hook_dict(cfg, command): entry[key] = value return entry +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): rules = [] for ev in canonical_events: name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: - rule["matcher"] = matcher - rules.append(rule) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) return rules def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): @@ -619,6 +674,10 @@ def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): hooks.setdefault(extra, []).append({"command": command}) return {"hooks": hooks} +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] hooks = {} @@ -649,6 +708,8 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) if wrapper == "flat_entries": return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) return _default_wrapper(cfg, reverse, canonical_events, command, matcher) @@ -657,7 +718,7 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos AGENT = "claude_code" -VENDOR = {'agent': 'claude_code', 'claims': {'client_types': (None, 'claude_code'), 'event_key': ('hook_event_name',), 'mode': 'marker', 'notes': 'prompt_id rejects only when looks_like_claude_code(raw) is also false; a real Claude Code payload may carry prompt_id and must still be accepted (matrix-notes.json: fixed 2026-08-27).', 'reject_markers': ('turn_id', 'project_path', 'timestamp'), 'reject_markers_unless_probe': {'looks_like_claude_code': ('prompt_id',)}}, 'config_format': 'json', 'config_path': '.claude/settings.json', 'display': 'Claude Code', 'events': {'FileChanged': 'file_changed', 'InstructionsLoaded': 'instructions_loaded', 'PostToolUse': 'post_tool', 'PostToolUseFailure': 'tool_failure', 'PreCompact': 'pre_compact', 'PreToolUse': 'pre_tool', 'SessionEnd': 'session_end', 'SessionStart': 'session_start', 'Stop': 'stop', 'SubagentStart': 'subagent_start', 'SubagentStop': 'subagent_stop', 'UserPromptSubmit': 'prompt_submit'}, 'evidence': {'claims': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'repo_root_token': {'basis': 'vendor-docs', 'date': '2026-09-01', 'test': 'tests/test_vendor_config.py::test_repo_root_token_is_recorded_only_where_primary_sourced'}, 'tools': {'basis': 'vendor-docs', 'date': '2026-09-27', 'test': 'tests/test_adapter_claude_code.py::test_powershell_is_a_shell_tool_and_its_command_is_parsed'}, 'verdicts': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'hook_json', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content', 'tool_input.new_string', 'tool_input.new_source', 'content', 'tool_input.edits[].new_string'), 'cwd': ('cwd',), 'output': ('tool_output',), 'path': ('tool_input.file_path', 'tool_input.path', 'tool_input.notebook_path', 'file_path'), 'prompt': ('prompt',), 'session_id': ('session_id',), 'tool': ('tool_name',), 'tool_use_id': ('tool_use_id',)}, 'hook_entry': {'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'repo_root_token': '${CLAUDE_PROJECT_DIR}', 'tools': {'shell': ('Bash', 'PowerShell'), 'write': ('Write', 'Edit', 'MultiEdit', 'NotebookEdit')}, 'verdicts': {'answer_events': ('PreToolUse', 'Stop', 'UserPromptSubmit'), 'bare_allow': 'silent', 'context_events': ('SessionStart', 'UserPromptSubmit'), 'context_source': 'context', 'degrade_notes': {'escalate': 'confirmation requested; this event cannot prompt, so it blocks', 'transform': 'input rewrite requested; this event cannot modify input, so it blocks'}, 'echo': 'reverse_map', 'gates': {'PreToolUse': {'grammar': 'G2', 'honours_escalate': True, 'honours_transform': True}, 'Stop': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'UserPromptSubmit': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'suffix', 'reason_defaults': {'deny_gate': 'blocked', 'escalate_gate': 'confirmation required'}, 'transform_grammar': 'hook_specific_updated_input', 'vocabulary': ('allow', 'ask', 'block', 'deny'), 'vocabulary_basis': 'verified', 'words': {'block': 'block', 'deny': 'deny', 'escalate': 'ask', 'transform': 'allow', 'vouch': 'allow'}}} +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). A value Claude Code never sends beats every marker key, looks_like_claude_code's included (value-based detection, D1): permission_mode values off, spec, auto-low, auto-medium and auto-high are Factory Droid's (docs.factory.com/harness/hooks, read 2026-10-11); tool_name values fs_read, fsRead, fs_write, fsWrite, execute_bash, execute_cmd and use_aws (kiro.dev/docs/reference/built-in-tools, updated 2026-10-10), and an MCP tool spelled mcp__ rather than Claude's mcp____ (kiro.dev/docs/hooks/types, updated 2026-10-10), are Kiro's. Kiro's V3 primary names (read, write, shell) and its tool-less events carry nothing Claude Code does not, so those still read as claude_code. factory_droid claims Droid's payloads on those same permission_mode values (accept_values, D2a); Kiro has no adapter, so its separable payloads detect as no agent rather than as claude_code.", 'reject_markers': ('turn_id', 'project_path', 'timestamp'), 'reject_markers_unless_probe': {'looks_like_claude_code': ('prompt_id',)}, 'reject_probes': ('names_a_single_underscore_mcp_tool',), 'reject_values': {'permission_mode': ('off', 'spec', 'auto-low', 'auto-medium', 'auto-high'), 'tool_name': ('fs_read', 'fsRead', 'fs_write', 'fsWrite', 'execute_bash', 'execute_cmd', 'use_aws')}}, 'config_format': 'json', 'config_path': '.claude/settings.json', 'display': 'Claude Code', 'events': {'FileChanged': 'file_changed', 'InstructionsLoaded': 'instructions_loaded', 'PostToolUse': 'post_tool', 'PostToolUseFailure': 'tool_failure', 'PreCompact': 'pre_compact', 'PreToolUse': 'pre_tool', 'SessionEnd': 'session_end', 'SessionStart': 'session_start', 'Stop': 'stop', 'SubagentStart': 'subagent_start', 'SubagentStop': 'subagent_stop', 'UserPromptSubmit': 'prompt_submit'}, 'evidence': {'claims': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'repo_root_token': {'basis': 'vendor-docs', 'date': '2026-09-01', 'test': 'tests/test_vendor_config.py::test_repo_root_token_is_recorded_only_where_primary_sourced'}, 'tools': {'basis': 'vendor-docs', 'date': '2026-09-27', 'test': 'tests/test_adapter_claude_code.py::test_powershell_is_a_shell_tool_and_its_command_is_parsed'}, 'verdicts': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'hook_json', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content', 'tool_input.new_string', 'tool_input.new_source', 'content', 'tool_input.edits[].new_string'), 'cwd': ('cwd',), 'output': ('tool_output',), 'path': ('tool_input.file_path', 'tool_input.path', 'tool_input.notebook_path', 'file_path'), 'prompt': ('prompt',), 'session_id': ('session_id',), 'tool': ('tool_name',), 'tool_use_id': ('tool_use_id',)}, 'hook_entry': {'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'repo_root_token': '${CLAUDE_PROJECT_DIR}', 'tools': {'shell': ('Bash', 'PowerShell'), 'write': ('Write', 'Edit', 'MultiEdit', 'NotebookEdit')}, 'verdicts': {'answer_events': ('PreToolUse', 'Stop', 'UserPromptSubmit'), 'bare_allow': 'silent', 'context_events': ('SessionStart', 'UserPromptSubmit'), 'context_source': 'context', 'degrade_notes': {'escalate': 'confirmation requested; this event cannot prompt, so it blocks', 'transform': 'input rewrite requested; this event cannot modify input, so it blocks'}, 'echo': 'reverse_map', 'gates': {'PreToolUse': {'grammar': 'G2', 'honours_escalate': True, 'honours_transform': True}, 'Stop': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'UserPromptSubmit': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'suffix', 'reason_defaults': {'deny_gate': 'blocked', 'escalate_gate': 'confirmation required'}, 'transform_grammar': 'hook_specific_updated_input', 'vocabulary': ('allow', 'ask', 'block', 'deny'), 'vocabulary_basis': 'verified', 'words': {'block': 'block', 'deny': 'deny', 'escalate': 'ask', 'transform': 'allow', 'vouch': 'allow'}}} def claims(raw): diff --git a/tests/fixtures/runtime_goldens/codex_cli.py b/tests/fixtures/runtime_goldens/codex_cli.py index f1e41dba..6f7a3a65 100644 --- a/tests/fixtures/runtime_goldens/codex_cli.py +++ b/tests/fixtures/runtime_goldens/codex_cli.py @@ -29,6 +29,7 @@ from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings +import zlib as _zlib # ------------------------------------------------------------------------------ # contract (agentseam 0.3.5) @@ -262,12 +263,22 @@ def _rejected_by_markers(c, raw): for marker in c.get("reject_markers", ()): if marker in raw: return True + for key, values in c.get("reject_values", {}).items(): + if key in raw and raw[key] in values: + return True for probe, markers in c.get("reject_markers_unless_probe", {}).items(): if any(marker in raw for marker in markers) and not PROBES[probe](raw): return True return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) +def _accepted_by_values(c, raw): + """True unless the entry names accepted values and the payload carries none of them.""" + values = c.get("accept_values") + return not values or any(key in raw and raw[key] in allowed for key, allowed in values.items()) + def _accepted_by_markers(c, raw, name): + if not _accepted_by_values(c, raw): + return False accept = c.get("accept_markers", ()) if not accept or any(marker in raw for marker in accept): return True @@ -306,12 +317,25 @@ def _segment(node, part): 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 _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + def _walk(node, path): - """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None if "[]." in path: head, sub = path.split("[].", 1) - items = _walk(node, head) - if not isinstance(items, (list, tuple)): + items = _list_at(node, head) + if items is None: return None joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) return joined or None @@ -341,7 +365,7 @@ def _tool_input_raw(cfg, raw): if not isinstance(raw, dict): return None for key in cfg["fields"].get("tool_input", ("tool_input",)): - value = raw.get(key) + value = _walk(raw, key) if value is not None: return value return None @@ -563,6 +587,8 @@ def hj_respond(cfg, decision, event, wire=None): return "", 0 if gate["grammar"] == "G2": return _g2(v, gate, decision, name) + if gate["grammar"] == "G7": + return _g7(v, decision, wire) return _g1(v, gate, decision, wire, name) _WINDOWS_KEYS = ("commandWindows", "windows") @@ -578,16 +604,25 @@ def _hook_dict(cfg, command): entry[key] = value return entry +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): rules = [] for ev in canonical_events: name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: - rule["matcher"] = matcher - rules.append(rule) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) return rules def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): @@ -616,6 +651,10 @@ def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): hooks.setdefault(extra, []).append({"command": command}) return {"hooks": hooks} +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] hooks = {} @@ -646,6 +685,8 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) if wrapper == "flat_entries": return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) return _default_wrapper(cfg, reverse, canonical_events, command, matcher) diff --git a/tests/fixtures/runtime_goldens/cursor.py b/tests/fixtures/runtime_goldens/cursor.py index af70c81c..b4757b40 100644 --- a/tests/fixtures/runtime_goldens/cursor.py +++ b/tests/fixtures/runtime_goldens/cursor.py @@ -29,6 +29,7 @@ from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings +import zlib as _zlib # ------------------------------------------------------------------------------ # contract (agentseam 0.3.5) @@ -253,12 +254,25 @@ def _segment(node, part): 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 _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + def _walk(node, path): - """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None if "[]." in path: head, sub = path.split("[].", 1) - items = _walk(node, head) - if not isinstance(items, (list, tuple)): + items = _list_at(node, head) + if items is None: return None joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) return joined or None @@ -288,7 +302,7 @@ def _tool_input_raw(cfg, raw): if not isinstance(raw, dict): return None for key in cfg["fields"].get("tool_input", ("tool_input",)): - value = raw.get(key) + value = _walk(raw, key) if value is not None: return value return None @@ -369,16 +383,25 @@ def _hook_dict(cfg, command): entry[key] = value return entry +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): rules = [] for ev in canonical_events: name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: - rule["matcher"] = matcher - rules.append(rule) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) return rules def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): @@ -407,6 +430,10 @@ def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): hooks.setdefault(extra, []).append({"command": command}) return {"hooks": hooks} +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] hooks = {} @@ -437,6 +464,8 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) if wrapper == "flat_entries": return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) return _default_wrapper(cfg, reverse, canonical_events, command, matcher) _AMBIGUOUS_NAMES = ( diff --git a/tests/fixtures/runtime_goldens/devin.py b/tests/fixtures/runtime_goldens/devin.py index e93a7a95..a977783b 100644 --- a/tests/fixtures/runtime_goldens/devin.py +++ b/tests/fixtures/runtime_goldens/devin.py @@ -29,6 +29,7 @@ from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings +import zlib as _zlib # ------------------------------------------------------------------------------ # contract (agentseam 0.3.5) @@ -235,11 +236,31 @@ def tool_input_of(raw): "effort", ) +_MCP_PREFIX = "mcp_" + +_CLAUDE_MCP_PREFIX = "mcp__" + 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 names_a_single_underscore_mcp_tool(raw): + """True when tool_name is an MCP tool ID in a spelling Claude Code never sends.""" + name = raw.get("tool_name") if isinstance(raw, dict) else None + return isinstance(name, str) and name.startswith(_MCP_PREFIX) and not name.startswith(_CLAUDE_MCP_PREFIX) + +def event_keys_agree(raw): + """True when hookEventName names hook_event_name's event: the same text, or Grok Build's snake_case twin.""" + snake, pascal = (raw.get("hookEventName"), raw.get("hook_event_name")) if isinstance(raw, dict) else (None, None) + if not isinstance(pascal, str) or not isinstance(snake, str): + return False + return snake == pascal or (snake.islower() and snake.replace("_", "") == pascal.lower()) + +PROBES = { + "looks_like_claude_code": looks_like_claude_code, + "names_a_single_underscore_mcp_tool": names_a_single_underscore_mcp_tool, + "event_keys_agree": event_keys_agree, +} UNREADABLE_NAME = "" @@ -265,12 +286,22 @@ def _rejected_by_markers(c, raw): for marker in c.get("reject_markers", ()): if marker in raw: return True + for key, values in c.get("reject_values", {}).items(): + if key in raw and raw[key] in values: + return True for probe, markers in c.get("reject_markers_unless_probe", {}).items(): if any(marker in raw for marker in markers) and not PROBES[probe](raw): return True return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) +def _accepted_by_values(c, raw): + """True unless the entry names accepted values and the payload carries none of them.""" + values = c.get("accept_values") + return not values or any(key in raw and raw[key] in allowed for key, allowed in values.items()) + def _accepted_by_markers(c, raw, name): + if not _accepted_by_values(c, raw): + return False accept = c.get("accept_markers", ()) if not accept or any(marker in raw for marker in accept): return True @@ -309,12 +340,25 @@ def _segment(node, part): 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 _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + def _walk(node, path): - """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None if "[]." in path: head, sub = path.split("[].", 1) - items = _walk(node, head) - if not isinstance(items, (list, tuple)): + items = _list_at(node, head) + if items is None: return None joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) return joined or None @@ -344,7 +388,7 @@ def _tool_input_raw(cfg, raw): if not isinstance(raw, dict): return None for key in cfg["fields"].get("tool_input", ("tool_input",)): - value = raw.get(key) + value = _walk(raw, key) if value is not None: return value return None @@ -535,6 +579,8 @@ def hj_respond(cfg, decision, event, wire=None): return "", 0 if gate["grammar"] == "G2": return _g2(v, gate, decision, name) + if gate["grammar"] == "G7": + return _g7(v, decision, wire) return _g1(v, gate, decision, wire, name) _WINDOWS_KEYS = ("commandWindows", "windows") @@ -550,16 +596,25 @@ def _hook_dict(cfg, command): entry[key] = value return entry +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): rules = [] for ev in canonical_events: name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: - rule["matcher"] = matcher - rules.append(rule) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) return rules def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): @@ -588,6 +643,10 @@ def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): hooks.setdefault(extra, []).append({"command": command}) return {"hooks": hooks} +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] hooks = {} @@ -618,6 +677,8 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) if wrapper == "flat_entries": return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) return _default_wrapper(cfg, reverse, canonical_events, command, matcher) diff --git a/tests/fixtures/runtime_goldens/factory_droid.py b/tests/fixtures/runtime_goldens/factory_droid.py new file mode 100644 index 00000000..1d4ae174 --- /dev/null +++ b/tests/fixtures/runtime_goldens/factory_droid.py @@ -0,0 +1,2438 @@ +# Generated by agentseam 0.3.5 -- bundle("factory_droid"). 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("factory_droid")` (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 contextlib +import io +import json +_json = json +import os +import sys + +import hashlib as _chock_hashlib +import fnmatch as _chock_fnmatch +import re as _chock_re +import os as _chock_os +import shlex as _chock_shlex +import shutil as _chock_shutil +import subprocess as _chock_subprocess +import time as _chock_time +from datetime import datetime as _chock_datetime, timezone as _chock_timezone +from pathlib import Path as _chock_Path +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath +import traceback +import warnings as _warnings +import zlib as _zlib + +# ------------------------------------------------------------------------------ +# contract (agentseam 0.3.5) + +"""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", + "command", + "content", + "cwd", + "event", + "output", + "path", + "prompt", + "raw", + "session_id", + "tool", + "tool_use_id", + ) + + 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__ = ("context", "evidence", "outcome", "reason", "updated_input") + + #: 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.lstrip()[:1] == "{": + try: + parsed = _json.loads(raw) + except (ValueError, RecursionError): + # JSONDecodeError is a ValueError; a string nested past the interpreter's + # limit raises RecursionError instead, and neither is an input to crash on. + return {} + if isinstance(parsed, dict): + return parsed + return {} + + +# ------------------------------------------------------------------------------ +# hook_json family engine (trimmed to what this entry uses) + +UNREADABLE_NAME = "" + +def wire_name_of(raw, key): + """The event name under `key`: text, None when absent, UNREADABLE_NAME when not text. + + Total over anything `json.loads` can return: a payload that is not an object names no + event, and a name that is not a string is not one any adapter can map. + """ + name = raw.get(key) if isinstance(raw, dict) else None + if name is None or isinstance(name, str): + return name + return UNREADABLE_NAME + +def _wire_name(cfg, raw): + for key in cfg["claims"].get("event_key", ()): + name = wire_name_of(raw, key) + if name is not None: + return name + return None + +def _rejected_by_markers(c, raw): + for marker in c.get("reject_markers", ()): + if marker in raw: + return True + for key, values in c.get("reject_values", {}).items(): + if key in raw and raw[key] in values: + return True + for probe, markers in c.get("reject_markers_unless_probe", {}).items(): + if any(marker in raw for marker in markers) and not PROBES[probe](raw): + return True + return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) + +def _accepted_by_values(c, raw): + """True unless the entry names accepted values and the payload carries none of them.""" + values = c.get("accept_values") + return not values or any(key in raw and raw[key] in allowed for key, allowed in values.items()) + +def _accepted_by_markers(c, raw, name): + if not _accepted_by_values(c, raw): + return False + accept = c.get("accept_markers", ()) + if not accept or any(marker in raw for marker in accept): + return True + return any( + name == event_name and all(key in raw for key in required) + for event_name, required in c.get("accept_when_all", {}).items() + ) + +def _disqualified(cfg, c, raw, name): + """Every reason an entry declines a payload once its wire event name is known.""" + if name not in cfg["events"] and not (c.get("accept_any_name") and isinstance(name, str)): + return True + if "client_types" in c and raw.get("client_type") not in c["client_types"]: + return True + return _rejected_by_markers(c, raw) + +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) + # A positive self-identification beats a shared event name, accept_names included. + if raw.get("client_type") in c.get("reject_client_types", ()): + return False + if name in c.get("accept_names", ()): + return True + return not _disqualified(cfg, c, raw, name) and _accepted_by_markers(c, raw, name) + +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 _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + +def _walk(node, path): + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None + if "[]." in path: + head, sub = path.split("[].", 1) + items = _list_at(node, head) + if items is None: + 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): + if not isinstance(raw, dict): + return None + for key in cfg["fields"].get("tool_input", ("tool_input",)): + value = _walk(raw, 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, + ) + +_ESCALATE_FROM_TRANSFORM = "escalate_from_transform" + +_TRANSFORM_MISSING_INPUT = "transform_missing_input" + +def hj_reverse(cfg): + """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides. + + One wire name per canonical event, which is a real limit and not an oversight (R3, gap 4). + Cursor is where it bites: `pre_tool` pins to `preToolUse`, and Cursor honours `ask` only at + `beforeShellExecution` / `beforeMCPExecution`. So installing at `pre_tool` forecloses `ask` + before dispatch is ever reached -- the runtime degrade to `deny` is honest about it, but the + install already chose. Deny-style policies, which is all that ships today, are unaffected. + + The day an ask-style Cursor policy exists, this map has to select by decision dialect + (deny -> `preToolUse`, ask -> `beforeShellExecution`) and become one-to-many. Recorded here + rather than built, so the resolution is not re-litigated from scratch; the three facts it + rests on are pinned in tests/test_cursor_ask_dialect.py. + """ + 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_allow(v, decision, wire, name, words): + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + if wire in v.get("allow_silent_events", ()): + return "", 0 + if "allow" in words: + out = {"decision": words["allow"]} + if v.get("allow_context_key") and decision.outcome == ALLOW and value: + out[v["allow_context_key"]] = value + return _json.dumps(out), 0 + return "", 0 + +def _g1_transform(v, decision, name, words): + """None when the transform isn't representable here; `_g1` falls through to the block path.""" + if v.get("transform_grammar") == "hook_specific_tool_input": + return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0 + if decision.updated_input is None: + return None + if v.get("transform_grammar") == "top_level_updated_input": + out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input} + if decision.reason: + out["reason"] = decision.reason + return _json.dumps(out), 0 + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}}), 0 + +def _g1(v, gate, decision, wire, name): + """Block dialect: a top-level decision word, or silence/context where nothing is read.""" + words = dict(v.get("words", {})) + words.update(v.get("words_at", {}).get(wire, {})) + at_context_event = wire in v.get("context_events", ()) + if decision.outcome in (ALLOW, VOUCH, WARN): + return _g1_allow(v, decision, wire, name, words) + if decision.outcome == TRANSFORM and gate["honours_transform"]: + transformed = _g1_transform(v, decision, name, words) + if transformed is not None: + return transformed + if ( + decision.outcome == ESCALATE + and gate["honours_escalate"] + and "escalate" in words + # An escalate the dispatcher degraded a transform into is a block where the entry + # names that degradation (antigravity): prompting would offer the unmodified call. + and not (degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in v.get("degrade_notes", {})) + ): + reason = decision.reason or _default_for(v, decision, at_gate=True, wire=wire) + return _json.dumps({"decision": words["escalate"], "reason": reason}), 0 + out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, at_gate=False, wire=wire)} + if at_context_event and v.get("context_source") == "context" and decision.context: + out["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": decision.context} + return _json.dumps(out), 0 + +_PERMISSION_DECISION_REASON = "permissionDecisionReason" + +def _g2(v, gate, decision, name): + """Permission gate: `hookSpecificOutput.permissionDecision`, filled from the word table.""" + if decision.outcome in (ALLOW, WARN): + 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[_PERMISSION_DECISION_REASON] = decision.reason + elif ( + decision.outcome == TRANSFORM + and gate["honours_transform"] + and not (decision.updated_input is None and _TRANSFORM_MISSING_INPUT in v.get("degrade_notes", {})) + ): + out["permissionDecision"] = words.get("transform", "allow") + out["updatedInput"] = decision.updated_input + if decision.reason: + out[_PERMISSION_DECISION_REASON] = decision.reason + elif decision.outcome == ESCALATE and gate["honours_escalate"] and "escalate" in words: + out["permissionDecision"] = words["escalate"] + out[_PERMISSION_DECISION_REASON] = decision.reason or _default_for(v, decision, at_gate=True) + else: + out["permissionDecision"] = words.get("deny", "deny") + out[_PERMISSION_DECISION_REASON] = _refusal_text(v, decision, at_gate=True) + return _json.dumps({"hookSpecificOutput": out}), 0 + +def hj_respond(cfg, decision, event, wire=None): + """(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) + if gate["grammar"] == "G7": + return _g7(v, decision, wire) + return _g1(v, gate, decision, wire, name) + +_WINDOWS_KEYS = ("commandWindows", "windows") + +_MATCHER_EVENTS = (PRE_TOOL, POST_TOOL, TOOL_FAILURE) + +def _hook_dict(cfg, command): + entry = {"type": "command", "command": command} + for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): + if key in _WINDOWS_KEYS: + entry[key] = powershell_command(command) + else: + entry[key] = value + return entry + +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + +def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): + rules = [] + for ev in canonical_events: + name = reverse.get(ev) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) + return rules + +def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): + gates = cfg["verdicts"]["answer_events"] + allow_silent = cfg["verdicts"].get("allow_silent_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 and name not in allow_silent: + entry["failClosed"] = True + hooks.setdefault(name, []).append(entry) + return {"version": 1, "hooks": hooks} + +def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + hooks.setdefault(name, []).append({"command": command}) + extra = hook_entry.get("also_wires", {}).get(ev) + if extra: + hooks.setdefault(extra, []).append({"command": command}) + return {"hooks": hooks} + +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + +def _default_wrapper(cfg, reverse, canonical_events, command, matcher): + hook_entry = cfg["hook_entry"] + 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"] and ev in _MATCHER_EVENTS: + 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 hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_closed=True): + """The vendor's hooks-config fragment wiring `command` for these canonical events. + + `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the + entry says otherwise; a False installs an observer, not a gate. + """ + hook_entry = cfg["hook_entry"] + reverse = hj_reverse(cfg) + wrapper = hook_entry["wrapper"] + if wrapper == "flat_list": + return _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher) + if wrapper == "cursor": + return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) + if wrapper == "flat_entries": + return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) + return _default_wrapper(cfg, reverse, canonical_events, command, matcher) + + +# ------------------------------------------------------------------------------ +# factory_droid vendor config + engine binding + +AGENT = "factory_droid" + +VENDOR = {'agent': 'factory_droid', 'claims': {'accept_values': {'permission_mode': ('off', 'spec', 'auto-low', 'auto-medium', 'auto-high')}, 'event_key': ('hook_event_name',), 'mode': 'marker', 'notes': "Droid's payload carries Claude Code's keys, so only a value separates it: every Droid event carries permission_mode, and Droid sends only off, spec, auto-low, auto-medium or auto-high there (docs.factory.com/harness/hooks, read 2026-10-11; droid 0.238.0's own hook-input builder maps every autonomy mode to one of those five, defaulting to off). claude_code rejects exactly those values (reject_values), so a payload is claimed by one of the two and never by both. A Droid build that sent a Claude Code permission_mode value would detect as claude_code again, whose deny Droid also reads."}, 'config_format': 'json', 'config_path': '.factory/hooks.json', 'display': 'Factory Droid', 'events': {'PostToolUse': 'post_tool', 'PreCompact': 'pre_compact', 'PreToolUse': 'pre_tool', 'SessionEnd': 'session_end', 'SessionStart': 'session_start', 'Stop': 'stop', 'SubagentStop': 'subagent_stop', 'UserPromptSubmit': 'prompt_submit'}, 'evidence': {'claims': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'vendor-docs', 'date': '2026-10-11', '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_str', 'tool_input.changes[].new_str', 'tool_input.input'), 'cwd': ('cwd',), 'output': ('tool_response',), 'path': ('tool_input.file_path',), 'prompt': ('prompt',), 'session_id': ('session_id',), 'tool': ('tool_name',)}, 'hook_entry': {'bare': True, 'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'tools': {'shell': ('Execute',), 'write': ('Create', 'Edit', 'MultiEdit', 'ApplyPatch')}, 'verdicts': {'answer_events': ('PreToolUse', 'Stop', 'UserPromptSubmit'), 'bare_allow': 'silent', 'context_events': ('SessionStart', 'UserPromptSubmit'), 'context_source': 'context', 'degrade_notes': {'escalate': "Droid's ask does not force a prompt, so this is a block", 'escalate_from_transform': 'Droid merges updatedInput into the original input rather than replacing it, so this is a block', 'transform': 'Droid merges updatedInput into the original input rather than replacing it, so this is a block'}, 'echo': 'reverse_map', 'gates': {'PreToolUse': {'grammar': 'G2', 'honours_escalate': False, 'honours_transform': False}, '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'}, 'vocabulary': ('allow', 'ask', 'block', 'deny'), 'vocabulary_basis': 'verified', 'words': {'block': 'block', 'deny': '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 factory_droid) + +_VOUCH_SPEAKS = False +_WARN_SPEAKS = False +_TRANSFORM_EVENTS = frozenset(()) + + +def degrade(decision, event): + """Reduce a decision to what 'factory_droid' 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 >>> +ENGINE_BUDGET_SECONDS = 30 + +def engine_deadline(): + """The monotonic instant this invocation's budget runs out.""" + return _chock_time.monotonic() + ENGINE_BUDGET_SECONDS + +def engine_remaining(deadline): + """Seconds left before `deadline`, never negative.""" + return max(deadline - _chock_time.monotonic(), 0.0) + +def time_left(deadline, cmd): + """Seconds left for a subprocess; raises TimeoutExpired when none are, so the caller's timeout path runs.""" + left = deadline - _chock_time.monotonic() + if left <= 0: + raise _chock_subprocess.TimeoutExpired(cmd, 0) + return left + +def allowed_seconds(expired): + """The time a timed-out call was actually given, as text (what the deadline left it, not the full budget).""" + return f'{max(float(expired.timeout or 0), 0.0):.1f}s' + +GUARD_VIOLATION = 1 + +GUARD_ASK_EXIT = 3 + +PYTHON_SUFFIX = '.py' + +_CRASH_MARKERS = ('traceback (most recent call last)', 'syntax error', 'syntaxerror', 'unexpected eof') + +TOOL_UNKNOWN = 'unknown' + +ARGV_FALLBACK_ENV = 'CHOCK_ARGV_FALLBACK' + +CAN_ASK_ENV = 'CHOCK_CAN_ASK' + +_POSIX_BASH = ('bash', '/bin/bash', '/usr/bin/bash') + +_GIT_FOR_WINDOWS = ('C:\\Program Files\\Git', 'C:\\Program Files (x86)\\Git') + +_GIT_BASH_DIRS = (('bin',), ('usr', 'bin')) + +_BASH_EXE = 'bash.exe' + +_WINDOWS_STUB_DIRS = ('/system32/', '/windowsapps/') + +_COREUTILS_MARKER = 'sed.exe' + +_WINDOWS = 'nt' + +_FOUND_BASH = {} + +GATE_LOG_ENV = 'CHOCK_GATE_LOG' + +_LOG_MAX_BYTES = 1048576 + +_BASH_PROBE_SECONDS = 10 + +GUARD_BLOCKED = 'blocked' + +GUARD_CLEAN = 'clean' + +GUARD_ASKED = 'asked' + +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 _git_roots() -> list[_chock_Path]: + """Git for Windows install roots: from `git` on PATH (cmd/ or mingw64/bin/), then the defaults.""" + git = _chock_shutil.which('git') + near = [_chock_Path(git).parent.parent, _chock_Path(git).parent.parent.parent] if git else [] + return [*near, *(_chock_Path(root) for root in _GIT_FOR_WINDOWS)] + +def bash_candidates() -> list[str]: + """Bash interpreters to try, best first; on Windows Git's own, never the WSL launcher.""" + if _chock_os.name != _WINDOWS: + return list(_POSIX_BASH) + found = [str(root.joinpath(*sub, _BASH_EXE)) for root in _git_roots() for sub in _GIT_BASH_DIRS] + on_path = _chock_shutil.which('bash') + if on_path and (not any((stub in on_path.lower().replace('\\', '/') for stub in _WINDOWS_STUB_DIRS))): + found.append(on_path) + return [c for i, c in enumerate(found) if c not in found[:i] and _chock_Path(c).is_file()] + +def interpreter_env(interpreter: str) -> dict[str, str]: + """The environment a guard runs in: on Windows, Git's usr/bin ahead on PATH for sed and grep.""" + env = dict(_chock_os.environ) + env.pop(CAN_ASK_ENV, None) + if _chock_os.name != _WINDOWS: + return env + home = _chock_Path(interpreter).parent + usr_bin = next((d for d in (home.parent / 'usr' / 'bin', home) if (d / _COREUTILS_MARKER).is_file()), None) + if usr_bin is not None: + env['PATH'] = str(usr_bin) + _chock_os.pathsep + env.get('PATH', '') + return env + +def find_bash(guard: _chock_Path, deadline: float | None=None) -> str | None: + """First bash that can actually see `guard`, probed once per process; the probes draw on `deadline`.""" + if 'bash' in _FOUND_BASH: + return _FOUND_BASH['bash'] + for candidate in bash_candidates(): + argv = [candidate, '-c', f'test -f "{guard.as_posix()}"'] + try: + limit = _BASH_PROBE_SECONDS if deadline is None else min(_BASH_PROBE_SECONDS, time_left(deadline, argv)) + proc = _chock_subprocess.run(argv, capture_output=True, timeout=limit, check=False) + except (OSError, _chock_subprocess.SubprocessError): + if deadline is not None and engine_remaining(deadline) <= 0: + return None + continue + if proc.returncode == 0: + _FOUND_BASH['bash'] = candidate + return candidate + return None + +def find_interpreter(guard: _chock_Path, deadline: float | None=None) -> str | None: + """The interpreter that can run `guard`: this Python for `.py`, otherwise a usable bash.""" + if guard.suffix == PYTHON_SUFFIX: + return sys.executable or None + return find_bash(guard, deadline) + +def normalize_tool(tool: str | None) -> str: + """The hook payload's tool name as `bash` | `powershell` | `shell` | `unknown`.""" + name = (tool or '').lower() + if name in ('powershell', 'pwsh'): + return 'powershell' + if name == 'bash': + return 'bash' + if name == 'sh' or 'shell' in name: + return 'shell' + return TOOL_UNKNOWN + +def is_guard_crash(output: str) -> bool: + """True when a guard's exit-1 output is empty or shows an interpreter crash, not a refusal.""" + lowered = output.lower() + return not lowered or any((marker in lowered for marker in _CRASH_MARKERS)) + +def split_command(command: str) -> tuple[list[str], bool]: + """POSIX `shlex.split` argv, else a whitespace split; the flag is True on the fallback.""" + try: + return (_chock_shlex.split(command), False) + except ValueError: + return (command.split(), True) + +def run_guard(guard: _chock_Path, command: str, tool: str='') -> str: + """`GUARD_BLOCKED` / `GUARD_ASKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + return run_guard_detailed(guard, command, tool)[0] + +def _no_interpreter(guard: _chock_Path, deadline: float) -> tuple[str, str]: + """The ask a guard earns when no shell could run it: the budget ran out probing, or none can see it.""" + if engine_remaining(deadline) <= 0: + print(f'chock: guard timed out after {ENGINE_BUDGET_SECONDS}s finding a shell, not checked', file=sys.stderr) + return (GUARD_ERRORED, '') + reason = f'no usable bash was found to run {guard.name}; on Windows install Git for Windows (it ships bash), elsewhere put bash on PATH' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) + +def run_guard_detailed(guard: _chock_Path, command: str, tool: str='', *, can_ask: bool | None=None) -> tuple[str, str]: + """`run_guard`'s verdict plus the guard's own first line, which an ask carries to the user.""" + args, fallback = split_command(command) + if not args: + return (GUARD_UNCHECKED, '') + deadline = engine_deadline() + interpreter = find_interpreter(guard, deadline) + if interpreter is None: + return _no_interpreter(guard, deadline) + try: + env = {**interpreter_env(interpreter), 'CHOCK_RAW_COMMAND': command, 'CHOCK_TOOL': normalize_tool(tool)} + if fallback: + env[ARGV_FALLBACK_ENV] = '1' + env.update({} if can_ask is None else {CAN_ASK_ENV: '1' if can_ask else '0'}) + proc = _chock_subprocess.run([interpreter, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=time_left(deadline, [interpreter, str(guard)]), check=False) + except _chock_subprocess.TimeoutExpired: + print(f'chock: guard timed out after {ENGINE_BUDGET_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, '') + output = ((proc.stderr or '') + (proc.stdout or '')).strip() + first_line = output.splitlines()[0].strip() if output else '' + if proc.returncode == GUARD_VIOLATION: + if is_guard_crash(output): + print(f'chock: guard {_chock_Path(guard).name} exited 1 without a reason (crash?), not checked', file=sys.stderr) + return (GUARD_ERRORED, '') + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + return (GUARD_BLOCKED, first_line) + if proc.returncode == GUARD_ASK_EXIT: + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + return (GUARD_ASKED, first_line) + if proc.returncode != 0: + print(f'chock: guard exited {proc.returncode}, not checked' + (f': {first_line[:120]}' if first_line else ''), file=sys.stderr) + return (GUARD_ERRORED, '') + return (GUARD_CLEAN, '') + +def append_gate_log(chock_root: _chock_Path, record: dict) -> None: + """Append one record, stamped, to `/log/gate-events.jsonl`, rotating past the size cap.""" + log_dir = chock_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') + stamped = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), **record} + with log_path.open('a', encoding='utf-8') as fh: + fh.write(json.dumps(stamped, ensure_ascii=False) + '\n') + +def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> 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 + record = {'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': verdict} + append_gate_log(artifact_root, record) + except Exception: + return + +def evaluate(argv: list[str], command: str, tool: str='', *, can_ask: bool | None=None) -> tuple[str, str] | None: + """Run the guard named on `argv` (`--guard `) against `command`.""" + guard = guard_path_from_argv(argv) + if guard is None: + return None + if not guard.exists(): + return (VERDICT_DENY, f"chock guard {guard} is missing, so this command cannot be checked. Run `chock sync --repo .` to reinstall the policy's guards.") + verdict, message = run_guard_detailed(guard, command, tool, can_ask=can_ask) + logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} + if verdict in logged: + log_outcome(guard, tool, verdict=logged[verdict]) + if verdict == GUARD_BLOCKED: + return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + if verdict == GUARD_ASKED: + return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') + if verdict == GUARD_ERRORED: + why = message or f"the {guard.stem} guard did not complete (see this hook's stderr)" + return (VERDICT_ESCALATE, f'chock could not check this command against {guard.stem}: {why}. Approving runs it unchecked.') + return None + +_EDIT_KEYS = (('old_string', 'new_string'), ('oldString', 'newString'), ('old_str', 'new_str')) + +_EDIT_LIST = 'edits' + +_CRLF = '\r\n' + +_CREATE_TEXT = 'file_text' + +def _edit_call_input(event): + raw = getattr(event, 'raw', None) + tool_input = raw.get('tool_input') if isinstance(raw, dict) else None + if isinstance(tool_input, str) and tool_input[:1] == '{': + try: + tool_input = json.loads(tool_input) + except ValueError: + return {} + return tool_input if isinstance(tool_input, dict) else {} + +def _pair(item): + if not isinstance(item, dict): + return None + for old, new in _EDIT_KEYS: + if isinstance(item.get(old), str) and isinstance(item.get(new), str): + return (item[old], item[new], item.get('replace_all') is True) + return None + +def edit_replacements(event): + """The (old, new, replace_all) replacements an edit call applies, in order; None if not an edit.""" + tool_input = _edit_call_input(event) + listed = tool_input.get(_EDIT_LIST) + found = [_pair(item) for item in (listed if isinstance(listed, list) else [tool_input])] + if not found or None in found: + return None + return found + +def _replace(text, old, new, every): + """`text` with the replacement applied, or None when `old` is not there to replace. + + The file is read with universal newlines, so a CRLF file (a Windows checkout) arrives as + LF; the call's strings are brought to LF too, whichever ending the client sent them with, + rather than calling a real edit a failure over line endings. + """ + old, new = (old.replace(_CRLF, '\n'), new.replace(_CRLF, '\n')) + if old == '': + return new if text == '' else None + if old not in text: + return None + return text.replace(old, new) if every else text.replace(old, new, 1) + +def edited_text(event, root=None): + """The file an edit call would leave behind, or None when it is not an edit or cannot be rebuilt.""" + replacements = edit_replacements(event) + path = getattr(event, 'path', None) + if replacements is None or not path: + return None + target = _chock_Path(path) + if not target.is_absolute(): + target = _chock_Path(root if root is not None else _chock_Path.cwd()) / target + try: + text = target.read_text(encoding='utf-8') if target.exists() else '' + except (OSError, UnicodeDecodeError): + return None + for old, new, every in replacements: + text = _replace(text, old, new, every) + if text is None: + return None + return text + +def created_text(event): + """The whole file a write call carries: the adapter's content, else `tool_input.file_text`; None otherwise.""" + content = getattr(event, 'content', None) + if isinstance(content, str): + return content + text = _edit_call_input(event).get(_CREATE_TEXT) + return text if isinstance(text, str) else None + +def added_from_event(event): + """What an edit call introduces -- what `added_lines` means for it -- or {} for any other call.""" + replacements = edit_replacements(event) + path = getattr(event, 'path', None) + if replacements is None or not path: + return {} + return {str(path): '\n'.join((new for _, new, _ in replacements))} + +_BEGIN = '*** Begin Patch' + +_END = '*** End Patch' + +_ADD = '*** Add File: ' + +_DELETE = '*** Delete File: ' + +_UPDATE = '*** Update File: ' + +_MOVE = '*** Move to: ' + +_EOF = '*** End of File' + +_HUNK = '@@' + +_OPS = (('add', _ADD), ('delete', _DELETE), ('update', _UPDATE)) + +_PATCH_KEYS = ('command', 'input', 'patch') + +def patch_text(event): + """The patch an apply_patch call carries, from its first to its last marker; None otherwise.""" + raw = getattr(event, 'raw', None) + tool_input = raw.get('tool_input') if isinstance(raw, dict) else None + command = tool_input if isinstance(tool_input, str) else None + for key in _PATCH_KEYS if isinstance(tool_input, dict) else (): + if tool_input.get(key) is not None: + command = tool_input[key] + break + if isinstance(command, list): + command = '\n'.join((part for part in command if isinstance(part, str))) + start = command.find(_BEGIN) if isinstance(command, str) else -1 + end = command.find(_END, start) if start >= 0 else -1 + return command[start:end + len(_END)] if end >= 0 else None + +def _body_line(current, line): + """Add one line under the file operation `current` is reading.""" + if line.startswith(_MOVE): + current[2] = line[len(_MOVE):].strip() + elif line.startswith(_HUNK): + current[3].append([line[len(_HUNK):].strip(), [], False]) + elif line.startswith(_EOF): + if current[3]: + current[3][-1][2] = True + elif current[0] == 'add': + current[3].append(line[1:] if line.startswith('+') else line) + else: + if not current[3]: + current[3].append(['', [], False]) + current[3][-1][1].append(line if line[:1] in (' ', '-', '+') else ' ' + line) + +def parse_patch(patch): + """[(op, path, moved_to, hunks)]: op is add/delete/update; a hunk is (anchor, lines, at_eof).""" + operations = [] + for line in patch.replace('\r\n', '\n').split('\n')[1:]: + if line.startswith(_END): + break + opened = next(((op, marker) for op, marker in _OPS if line.startswith(marker)), None) + if opened is not None: + operations.append([opened[0], line[len(opened[1]):].strip(), None, []]) + elif operations: + _body_line(operations[-1], line) + return [tuple(operation) for operation in operations] + +def _find(lines, wanted, start): + """Where `wanted` starts in `lines` at or after `start`, exactly or ignoring trailing space.""" + for loose in (False, True): + for index in range(start, len(lines) - len(wanted) + 1): + window = lines[index:index + len(wanted)] + if window == wanted or (loose and [w.rstrip() for w in window] == [w.rstrip() for w in wanted]): + return index + return None + +def apply_hunks(text, hunks): + """`text` with the hunks applied in order, or None when one cannot be placed.""" + lines = text.split('\n') + cursor = 0 + for anchor, body, at_eof in hunks: + if anchor: + found = _find(lines, [anchor], cursor) + if found is None: + found = next((i for i in range(cursor, len(lines)) if anchor.strip() in lines[i]), None) + if found is None: + return None + cursor = found + 1 + old = [line[1:] for line in body if line[:1] in (' ', '-')] + new = [line[1:] for line in body if line[:1] in (' ', '+')] + if not old: + at = len(lines) - (1 if lines and lines[-1] == '' else 0) if at_eof else cursor + else: + at = _find(lines, old, cursor) + if at is None: + return None + lines[at:at + len(old)] = new + cursor = at + len(new) + return '\n'.join(lines) + +def _read(root, path): + target = _chock_Path(path) + if not target.is_absolute(): + target = _chock_Path(root if root is not None else _chock_Path.cwd()) / target + try: + return target.read_text(encoding='utf-8') + except (OSError, UnicodeDecodeError): + return None + +def patched_files(event, root=None): + """{path: text} for every file an apply_patch call adds or updates and can be rebuilt.""" + patch = patch_text(event) + if patch is None: + return {} + files = {} + for op, path, moved_to, body in parse_patch(patch): + if op == 'add': + files[path] = '\n'.join(body) + '\n' + elif op == 'update': + before = _read(root, path) + after = apply_hunks(before, body) if before is not None else None + if after is not None: + files[moved_to or path] = after + return files + +def patch_added(event): + """{path: the lines the patch adds}, which is what `added_lines` means for it.""" + patch = patch_text(event) + if patch is None: + return {} + added = {} + for op, path, moved_to, body in parse_patch(patch): + if op == 'add': + added[path] = '\n'.join(body) + elif op == 'update': + added[moved_to or path] = '\n'.join((line[1:] for _, hunk, _ in body for line in hunk if line[:1] == '+')) + return added + +GATE_BLOCKED = 'blocked' + +GATE_CLEAN = 'clean' + +GATE_ERRORED = 'errored' + +GATE_ASKED = 'asked' + +GATE_WARNED = 'warned' + +VERDICT_WARN = 'warn' + +_EXIT_OUTCOME = {0: GATE_CLEAN, 1: GATE_BLOCKED, 3: GATE_ASKED, 4: GATE_WARNED} + +def runner_outcome(returncode, stderr): + """(outcome, message) for the runner's exit code and stderr: its words, only when it refused or spoke.""" + outcome = _EXIT_OUTCOME.get(returncode, GATE_ERRORED) + return (outcome, '' if outcome == GATE_CLEAN else (stderr or '').strip()) + +def gate_decision(outcome, message, gate): + """The decision an outcome earns, or None when the gate allowed: deny, escalate (ask) or warn.""" + policy = gate.parent.parent.name + spoken = {GATE_BLOCKED: (VERDICT_DENY, message or f'Blocked by chock policy: {policy}'), GATE_ASKED: (VERDICT_ESCALATE, message or f'Chock policy {policy} asks before this write.'), GATE_WARNED: (VERDICT_WARN, message or f'Chock policy {policy} warns about this write.'), GATE_ERRORED: (VERDICT_DENY, f'chock could not check this write: {message}. Refusing rather than reporting an allow it never established.')} + return spoken.get(outcome) + +OUTSIDE_KEY = 'outside_repo' + +_UP = '..' + +_DRIVE_AT = 1 + +_SEPARATORS = ('/', '\\') + +def _slashed(path): + return str(path).replace('\\', '/') + +def is_outside(path): + """Whether a path as the runtime names it lies outside the repository: absolute, or climbing out.""" + text = _slashed(path) + return text.startswith(('/', _UP + '/')) or text[_DRIVE_AT:_DRIVE_AT + 1] == ':' or text == _UP + +def expand_home(pattern): + """`~` (alone or before a separator) as this machine's home directory; anything else as given.""" + text = str(pattern) + if text[:1] == '~' and (len(text) == 1 or text[1] in _SEPARATORS): + return _chock_os.path.expanduser('~') + text[1:] + return text + +def _outside_fold(path): + """`path` with `.` and `..` folded lexically and doubled slashes dropped; a root's leading slash kept.""" + parts = [] + for part in _slashed(path).split('/'): + if part == _UP and parts and (parts[-1] not in ('', _UP)): + parts.pop() + elif part != '.' and (part or not parts): + parts.append(part) + return '/'.join(parts) + +def declared_outside(path, root, patterns, *, windows=None): + """The absolute, slash-separated path when it is outside `root` and matches a declared glob; else None.""" + windows = _chock_os.name == 'nt' if windows is None else windows + text = _slashed(path) + if not is_outside(text) or not patterns: + return None + absolute = text if not text.startswith(_UP) else _slashed(root) + '/' + text + absolute = _outside_fold(absolute) + subject = absolute.lower() if windows else absolute + for pattern in patterns: + wanted = _outside_fold(expand_home(pattern)) + if _chock_fnmatch.fnmatchcase(subject, wanted.lower() if windows else wanted): + return absolute + return None + +def outside_globs(gate): + """The `outside_repo` globs the compiled gate declares; none when it declares none or cannot be read.""" + try: + declared = json.loads(gate.read_text(encoding='utf-8')).get(OUTSIDE_KEY) + except (OSError, ValueError, AttributeError): + return [] + return [g for g in declared if isinstance(g, str)] if isinstance(declared, list) else [] + +def judged_files(files, root, patterns, repo_names): + """`files` ({path: text}) keyed the way a gate judges them. + + A path inside the repository is named by `repo_names(path)`; one outside is kept, under its + absolute path, only when a declared glob matches it. + """ + kept = {} + for path, text in files.items(): + for name in repo_names(path): + if not is_outside(name): + kept[name] = text + continue + found = declared_outside(name, root, patterns) + if found is not None: + kept[found] = text + return kept + +SESSION_MAX_ENTRIES = 500 + +SESSION_PRUNE_SECONDS = 7 * 24 * 3600 + +SESSION_FIELD_CAP = 300 + +SESSION_TRIM_BYTES = 131072 + +SESSION_DEDUPE_TAIL = 8 + +SESSION_STATE_PARTS = ('.chock', 'state') + +SESSION_INPUT_KEYS = ('tool_input', 'toolInput', 'toolArgs', 'input') + +SESSION_PATH_KEYS = ('file_path', 'filePath', 'path', 'notebook_path', 'target_file', 'TargetFile') + +SESSION_URL_KEYS = ('url', 'uri') + +SESSION_RESPONSE_KEYS = ('tool_response', 'toolResponse', 'tool_result', 'toolResult') + +SESSION_UNKNOWN_ID = 'unknown' + +SESSION_ID_MAX = 128 + +SESSION_MASK = '***' + +def tool_call_input(event): + """The tool's arguments as a dict, decoding the JSON-string form some vendors send; {} when absent.""" + raw = getattr(event, 'raw', None) + if not isinstance(raw, dict): + return {} + for key in SESSION_INPUT_KEYS: + value = raw.get(key) + if isinstance(value, str) and value[:1] == '{': + try: + value = json.loads(value) + except ValueError: + continue + if isinstance(value, dict): + return value + return {} + +def session_id_of(event): + """The session id as a safe file stem: separators and leading dots removed, never empty.""" + raw = getattr(event, 'raw', None) or {} + given = getattr(event, 'session_id', None) or (raw.get('session_id') if isinstance(raw, dict) else None) + stem = ''.join((c for c in str(given or '') if c.isalnum() or c in '._-')).lstrip('.') + return stem[:SESSION_ID_MAX] or SESSION_UNKNOWN_ID + +def session_log_path(root, session_id): + return _chock_Path(root).joinpath(*SESSION_STATE_PARTS, session_id + '.jsonl') + +def session_for(event, root): + """What a script gate receives as `session`: the id, the log's absolute path, this call's id.""" + session_id = session_id_of(event) + return {'id': session_id, 'log_path': session_log_path(root, session_id).as_posix(), 'tool_use_id': getattr(event, 'tool_use_id', None)} + +def _mask_assignments(command): + """`command` with the value of every `NAME=value` word masked: an env prefix is a secret's usual home.""" + words = [] + for word in command.split(' '): + name, eq, _ = word.partition('=') + words.append(name + eq + SESSION_MASK if eq and name.isidentifier() else word) + return ' '.join(words) + +def _bare_url(url): + """`url` without userinfo, query and fragment: where a token or a secret is usually carried.""" + url = url.split('#', 1)[0].split('?', 1)[0] + scheme, sep, rest = url.partition('://') + if not sep: + return url + authority, slash, tail = rest.partition('/') + return scheme + sep + authority.rsplit('@', 1)[-1] + slash + tail + +def _first_str(mapping, keys): + for key in keys: + value = mapping.get(key) + if isinstance(value, str) and value: + return value + return None + +def session_summary(event): + """The input of a tool call as {path?, command?, url?}: what a policy asks about, nothing else.""" + tool_input = tool_call_input(event) + path = getattr(event, 'path', None) or _first_str(tool_input, SESSION_PATH_KEYS) + command = getattr(event, 'command', None) or _first_str(tool_input, ('command',)) + url = _first_str(tool_input, SESSION_URL_KEYS) + summary = {} + if isinstance(path, str) and path: + summary['path'] = path[:SESSION_FIELD_CAP] + if isinstance(command, str) and command: + summary['command'] = _mask_assignments(command.splitlines()[0] if command.strip() else '')[:SESSION_FIELD_CAP] + if url: + summary['url'] = _bare_url(url)[:SESSION_FIELD_CAP] + return summary + +def _response_failed(response): + if not isinstance(response, dict): + return False + if response.get('is_error') is True or response.get('isError') is True or response.get('success') is False: + return True + code = response.get('exit_code', response.get('exitCode')) + return bool(response.get('error')) or (isinstance(code, int) and code != 0) + +def session_outcome(event): + """`ok` or `error` for a finished call, from what the payload says; None before it ran.""" + if event.event == 'tool_failure': + return 'error' + if event.event != 'post_tool': + return None + raw = getattr(event, 'raw', None) or {} + failed = any((_response_failed(raw.get(key)) for key in SESSION_RESPONSE_KEYS)) if isinstance(raw, dict) else False + return 'error' if failed else 'ok' + +def _same_call(line, record): + """Whether `line` is `record` already logged: same call id, or, with none, the same second and input.""" + try: + seen = json.loads(line) + except ValueError: + return False + if seen.get('phase') != record['phase']: + return False + if record['tool_use_id']: + return seen.get('tool_use_id') == record['tool_use_id'] + return not seen.get('tool_use_id') and all((seen.get(key) == record[key] for key in ('ts', 'tool', 'input', 'outcome'))) + +def _prune_old(directory): + cutoff = _chock_datetime.now(_chock_timezone.utc).timestamp() - SESSION_PRUNE_SECONDS + for old in directory.glob('*.jsonl'): + try: + if old.stat().st_mtime < cutoff: + old.unlink() + except OSError: + continue + +def _append_bounded(path, line, record): + lines = path.read_text(encoding='utf-8', errors='replace').splitlines() if path.exists() else [] + if any((_same_call(seen, record) for seen in lines[-SESSION_DEDUPE_TAIL:])): + return + if len(lines) < SESSION_MAX_ENTRIES and path.exists() and (path.stat().st_size < SESSION_TRIM_BYTES): + with path.open('a', encoding='utf-8') as fh: + fh.write(line + '\n') + return + kept = [*lines, line][-SESSION_MAX_ENTRIES:] + scratch = path.with_suffix('.tmp') + scratch.write_text('\n'.join(kept) + '\n', encoding='utf-8') + scratch.replace(path) + +def session_record(root, event, phase, outcome=None): + """Append one record for this call to its session's log. Best effort: never raises.""" + try: + path = session_log_path(root, session_id_of(event)) + path.parent.mkdir(parents=True, exist_ok=True) + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'session_id': path.stem, 'phase': phase, 'tool': str(getattr(event, 'tool', None) or ''), 'input': session_summary(event), 'outcome': outcome, 'tool_use_id': getattr(event, 'tool_use_id', None)} + _append_bounded(path, json.dumps(record, ensure_ascii=False, sort_keys=True), record) + _prune_old(path.parent) + except Exception: + return + +REENTRY_CAP = 3 + +STOP_LEDGER_SUFFIX = '.stop.jsonl' + +GATE_LOG_PARTS = ('.chock', 'log', 'gate-events.jsonl') + +STOP_MODE_MASK = 61440 + +STOP_MODE_FILE = 32768 + +STOP_MODE_DIR = 16384 + +STOP_PHASE = 'stop' + +STOP_TURN_KEYS = ('turn_id', 'generation_id') + +STOP_COUNT_KEY = 'loop_count' + +STOP_DIGEST_CHARS = 16 + +REENTRY_CLEAN = 'clean' + +REENTRY_REFUSED = 'refused' + +REENTRY_CAPPED = 'cap-reached' + +REENTRY_UNTRACKED = 'untracked' + +STOP_LOG_BLOCK = 'block' + +STOP_LOG_ALLOW = 'allow' + +STOP_LOG_WARN = 'warn' + +_STOP_LAST = "\nThis is refusal {cap} of {cap} for this turn's end: the next stop ends the turn with these findings still on disk, and a commit will refuse them. A person must review them." + +_STOP_WARNING = 'chock {policy}: this turn ended with findings still on disk{why}. The turn was allowed to end so the client does not loop; a commit will refuse these findings, and a person must review them.\n' + +_STOP_WHY = {REENTRY_CAPPED: ' after {cap} refused stops', REENTRY_UNTRACKED: ' (the stop record under .chock/state could not be written, so re-entries cannot be counted)'} + +_STOP_SINCE = {True: ' (changed since the last refusal)', False: ' (unchanged since the last refusal)'} + +def reentries(event): + """How many times the client says this stop re-entered its hook: `loop_count`, else 1 for `stop_hook_active`.""" + raw = event.raw if isinstance(event.raw, dict) else {} + try: + counted = int(raw.get(STOP_COUNT_KEY) or 0) + except (TypeError, ValueError): + counted = 1 + return max(counted, 1 if raw.get('stop_hook_active') else 0) + +def _stop_turn(event): + """The vendor's turn id, when the payload carries one; None otherwise.""" + raw = event.raw if isinstance(event.raw, dict) else {} + for key in STOP_TURN_KEYS: + value = raw.get(key) + if isinstance(value, str) and value: + return value[:SESSION_ID_MAX] + return None + +def _stop_digest(*parts): + digest = _chock_hashlib.sha256() + for part in parts: + digest.update(str(part).encode('utf-8', 'replace') + b'\x00') + return digest.hexdigest()[:STOP_DIGEST_CHARS] + +def finding_digests(message, writes): + """One digest per flagged file, of its path and content: never the text itself, never the message's wording.""" + if not message: + return [] + lines = [line.strip().lstrip('-').strip() for line in message.splitlines()] + flagged = [p for p in writes if any((line.startswith(p + ':') for line in lines))] or list(writes) + if not flagged: + return [_stop_digest(message)] + return sorted({_stop_digest(p, writes[p]) for p in flagged}) + +def stop_ledger_path(root, session_id): + return _chock_Path(root).joinpath(*SESSION_STATE_PARTS, session_id + STOP_LEDGER_SUFFIX) + +def stop_ledger_safe(path): + """Whether `path`, its directory and `.chock` are what chock made: real directories and a regular file or none. + + lstat, so a symlink, FIFO, socket, device or directory in any place is refused before anything opens it: + opening a FIFO blocks the hook until its timeout, which a client may read as an allow. + """ + for target, kind in ((path, STOP_MODE_FILE), (path.parent, STOP_MODE_DIR), (path.parent.parent, STOP_MODE_DIR)): + try: + mode = _chock_os.lstat(target).st_mode + except FileNotFoundError: + continue + except OSError: + return False + if mode & STOP_MODE_MASK != kind: + return False + return True + +def _stop_lines(path): + """The ledger's lines; none when it is missing, unsafe or cannot be read.""" + if not stop_ledger_safe(path): + return [] + try: + fd = _chock_os.open(path, _chock_os.O_RDONLY | getattr(_chock_os, 'O_NOFOLLOW', 0) | getattr(_chock_os, 'O_NONBLOCK', 0)) + with _chock_os.fdopen(fd, encoding='utf-8', errors='replace') as fh: + return fh.read().splitlines() + except OSError: + return [] + +def _stop_parse(line): + try: + return json.loads(line) + except (ValueError, RecursionError): + return None + +def _stop_well_formed(seen, session_id): + stamp, index = (seen.get('ts'), seen.get('reentry')) + return isinstance(stamp, str) and bool(stamp) and (seen.get('session_id') == session_id) and (type(index) is int) and (index >= 0) + +def _stop_chain(lines, session_id, policy, turn): + """This turn's records, newest first, counting down without a gap to its first stop; None when broken.""" + chain = [] + for line in reversed(lines): + seen = _stop_parse(line) + if not isinstance(seen, dict) or seen.get('phase') != STOP_PHASE or seen.get('policy') != policy: + continue + if turn is not None and seen.get('turn') != turn: + continue + if not _stop_well_formed(seen, session_id): + return None + if chain and seen['reentry'] != chain[-1]['reentry'] - 1: + return None + chain.append(seen) + if seen['reentry'] == 0 or seen.get('anchor') is True: + return chain + return None + +def _stop_append(path, lines, record): + """Append one record, keeping the last `SESSION_MAX_ENTRIES`; whether it landed.""" + line = json.dumps(record, sort_keys=True) + if not stop_ledger_safe(path): + return False + try: + path.parent.mkdir(parents=True, exist_ok=True) + if len(lines) < SESSION_MAX_ENTRIES and (not path.exists() or path.stat().st_size < SESSION_TRIM_BYTES): + flags = _chock_os.O_WRONLY | _chock_os.O_APPEND | _chock_os.O_CREAT | getattr(_chock_os, 'O_NOFOLLOW', 0) | getattr(_chock_os, 'O_NONBLOCK', 0) + with _chock_os.fdopen(_chock_os.open(path, flags, 384), 'a', encoding='utf-8') as fh: + fh.write(line + '\n') + else: + scratch = path.with_name('%s.%d.tmp' % (path.name, _chock_os.getpid())) + scratch.write_text('\n'.join([*lines[-(SESSION_MAX_ENTRIES - 1):], line]) + '\n', encoding='utf-8') + scratch.replace(path) + _prune_old(path.parent) + except OSError: + return False + return True + +def _stop_gate_log(root, policy, verdict, reentry, findings): + """One gate-log record per re-entry; a warn is held (`would_block`), so it is kept even with the log off. + + `reentry` is (index, reentry_verdict, findings_changed or None). + """ + try: + if not stop_ledger_safe(_chock_Path(root).joinpath(*GATE_LOG_PARTS)): + return + held = verdict == STOP_LOG_WARN + if _chock_os.environ.get(GATE_LOG_ENV) == '0' and (not held): + return + index, reentry_verdict, changed = reentry + record = {'policy_id': policy, 'surface': 'stop-reentry', 'event': 'stop', 'kind': 'reentry', 'reentry': index, 'verdict': verdict, 'reentry_verdict': reentry_verdict, 'match_count': len(findings), 'matches': []} + if held: + record.update({'would_action': STOP_LOG_BLOCK, 'would_block': True}) + if changed is not None: + record['findings_changed'] = changed + append_gate_log(_chock_Path(root).joinpath(SESSION_STATE_PARTS[0]), record) + except Exception: + return + +def _stop_warn(policy, why, changed, message): + """The warning a turn ends with: said on stderr for every client, and returned for a `systemMessage`.""" + text = _STOP_WARNING.format(policy=policy, why=why.format(cap=REENTRY_CAP) + _STOP_SINCE.get(changed, '')) + text += message + with contextlib.suppress(Exception): + sys.stderr.write(text + '\n') + sys.stderr.flush() + return (VERDICT_WARN, text) + +def _stop_changed(chain, findings): + """Whether the findings differ from the last refusal's; None when there is no refusal to compare with.""" + refused = next((r for r in chain or () if r.get('verdict') == STOP_LOG_BLOCK), None) + if refused is None or not isinstance(refused.get('findings'), list): + return None + return sorted(refused['findings']) != findings + +def settle_stop(event, root, gate, decision, writes=None): + """The decision a Stop earns once this turn's earlier Stops are weighed against `decision`.""" + if decision is not None and decision[0] != VERDICT_DENY: + return decision + policy = gate.parent.parent.name + session_id = session_id_of(event) + path = stop_ledger_path(root, session_id) + lines = _stop_lines(path) + message = decision[1] if decision else '' + findings = finding_digests(message, writes or {}) + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'session_id': session_id, 'phase': STOP_PHASE, 'policy': policy, 'turn': _stop_turn(event), 'findings': findings} + signalled = reentries(event) + if not signalled: + _stop_append(path, lines, {**record, 'reentry': 0, 'verdict': STOP_LOG_BLOCK if decision else STOP_LOG_ALLOW}) + return decision + chain = _stop_chain(lines, session_id, policy, record['turn']) + counted = isinstance(event.raw, dict) and STOP_COUNT_KEY in event.raw + index = signalled if counted else chain[0]['reentry'] + 1 if chain else 1 + capped = index > REENTRY_CAP + verdict = STOP_LOG_ALLOW if not decision else STOP_LOG_WARN if capped else STOP_LOG_BLOCK + landed = _stop_append(path, lines, {**record, 'reentry': index, 'verdict': verdict, 'anchor': chain is None}) + changed = _stop_changed(chain, findings) + if decision is None: + _stop_gate_log(root, policy, verdict, (index, REENTRY_CLEAN, None), findings) + return None + if capped: + _stop_gate_log(root, policy, verdict, (index, REENTRY_CAPPED, changed), findings) + return _stop_warn(policy, _STOP_WHY[REENTRY_CAPPED], changed, message) + if not landed and (not counted): + _stop_gate_log(root, policy, STOP_LOG_WARN, (index, REENTRY_UNTRACKED, None), findings) + return _stop_warn(policy, _STOP_WHY[REENTRY_UNTRACKED], None, message) + tracked = REENTRY_REFUSED if chain and landed else REENTRY_UNTRACKED + _stop_gate_log(root, policy, verdict, (index, tracked, changed), findings) + return (VERDICT_DENY, message + (_STOP_LAST.format(cap=REENTRY_CAP) if index == REENTRY_CAP else '')) + +UNREADABLE_STOP_LEDGER = 'unreadable-stop.jsonl' + +UNREADABLE_STOP_PHASE = 'unreadable-stop' + +UNREADABLE_STOP_WINDOW_SECONDS = 600 + +REENTRY_UNREADABLE = 'unreadable' + +_UNREADABLE_STOP_WARNING = 'chock {policy}: {cap} stops in a row had a payload chock could not read, so none of them was judged. The turn was allowed to end so the client does not loop; a commit will judge what is on disk, and a person must look at why the payload is unreadable.\n' + +_UNREADABLE_STOP_LAST = '\nThis is refusal {cap} of {cap} for stops chock cannot read: the next one ends the turn unchecked, and a commit will judge what is on disk. A person must look at why the payload is unreadable.' + +def _unreadable_stop_recent(lines, now): + """The refusals recorded inside the window; a record that does not parse, or has no time, counts as none.""" + kept = [] + for line in lines: + seen = _stop_parse(line) + stamp = seen.get('at') if isinstance(seen, dict) else None + refused = isinstance(seen, dict) and seen.get('phase') == UNREADABLE_STOP_PHASE + if refused and seen.get('verdict') == STOP_LOG_BLOCK and (type(stamp) in (int, float)) and (0 <= now - stamp < UNREADABLE_STOP_WINDOW_SECONDS): + kept.append(stamp) + return kept + +def settle_unreadable_stop(root, policy, refusal): + """(verdict, text) this unreadable Stop earns: a refusal with `refusal`, or a warning once the cap is spent. + + A ledger that is not a regular file in real directories, or cannot be written, keeps refusing: with nothing to count, only a refusal is safe. + """ + path = _chock_Path(root).joinpath(*SESSION_STATE_PARTS, UNREADABLE_STOP_LEDGER) + if not stop_ledger_safe(path): + return (VERDICT_DENY, refusal) + lines = _stop_lines(path) + now = _chock_datetime.now(_chock_timezone.utc).timestamp() + index = len(_unreadable_stop_recent(lines, now)) + 1 + capped = index > REENTRY_CAP + verdict = STOP_LOG_WARN if capped else STOP_LOG_BLOCK + _stop_append(path, lines, {'phase': UNREADABLE_STOP_PHASE, 'verdict': verdict, 'at': now, 'reentry': index}) + _stop_gate_log(root, policy, verdict, (index, REENTRY_UNREADABLE, None), []) + if capped: + text = _UNREADABLE_STOP_WARNING.format(policy=policy, cap=REENTRY_CAP) + with contextlib.suppress(Exception): + sys.stderr.write(text) + sys.stderr.flush() + return (VERDICT_WARN, text) + return (VERDICT_DENY, refusal + (_UNREADABLE_STOP_LAST.format(cap=REENTRY_CAP) if index == REENTRY_CAP else '')) + +_PARENT = '..' + +_DRIVE_COLON = ':' + +_ROOTS_KEY = 'workspace_roots' + +def _folded(pure): + """`pure` with `..` folded lexically; an absolute path never climbs above its anchor.""" + parts = [] + for part in pure.parts: + if part != _PARENT: + parts.append(part) + elif len(parts) > 1: + parts.pop() + return type(pure)(*parts) + +def _workspace_root(text): + """A workspace root as a path: `/C:/x` is `C:/x`; anything else as given.""" + if text[:1] == '/' and text[2:3] == _DRIVE_COLON and text[1:2].isalpha() and (text[3:4] in ('', '/', '\\')): + return text[1:] + return text + +def _holds(root, path): + """Whether `path`, absolute or relative, lies under `root`, compared the way `root` is spelled.""" + flavour = _chock_PureWindowsPath if root[1:2] == _DRIVE_COLON else _chock_PurePosixPath + try: + _folded(flavour(root) / str(path)).relative_to(flavour(root)) + except ValueError: + return False + return True + +def workspace_roots(event): + """The folders the client says the agent works in, normalised; [] when it names none.""" + raw = getattr(event, 'raw', None) + roots = raw.get(_ROOTS_KEY) if isinstance(raw, dict) else None + if not isinstance(roots, list): + return [] + return [_workspace_root(root) for root in roots if isinstance(root, str) and root] + +def workspace_root(event): + """The workspace root holding the event's path, else the first one; None when the client names none.""" + roots = workspace_roots(event) + path = getattr(event, 'path', None) + return next((root for root in roots if path and _holds(root, path)), roots[0] if roots else None) + +_SEVERITY = (VERDICT_DENY, VERDICT_ESCALATE, VERDICT_WARN) + +_GIT_DIR = '.git' + +def _repository(folder): + """The repository `folder` is in: the nearest folder holding `.git`, or None outside one.""" + here = _chock_Path(folder).absolute() + return next((f for f in (here, *here.parents) if (f / _GIT_DIR).exists()), None) + +def stop_roots(event, fallback): + """The event's `cwd` and every workspace root inside a repository, the first per repository; else [fallback].""" + by_repository = {} + for named in filter(None, (getattr(event, 'cwd', None), *workspace_roots(event))): + repository = _repository(named) + if repository is not None: + by_repository.setdefault(repository, _chock_Path(named)) + return list(by_repository.values()) or [fallback] + +def per_root(said): + """One (decision, judged files) for each root's own: the most severe verdict, findings under each root's name.""" + if len(said) == 1: + return said[0][1:] + spoken = [(root, decision) for root, decision, _judged in said if decision] + judged = {path: text for _root, _decision, files in said for path, text in files.items()} + if not spoken: + return (None, judged) + verdict = min((decision[0] for _root, decision in spoken), key=_SEVERITY.index) + return ((verdict, '\n'.join((f'In {root}:\n{decision[1]}' for root, decision in spoken))), judged) + +GATE_FLAG = '--gate' + +STOP_FLAG = '--stop' + +_GATE_DEPTH_TO_CHOCK = 3 + +_RUNNER_PARTS = ('bin', 'gate.py') + +_PACKAGED_RUNNER = 'gate.py' + +_GIT = 'git' + +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + +_DELETED = 'D' + +_RENAMED = 'R' + +VERDICT_DENY = 'deny' + +def gate_path_from_argv(argv): + """The `--gate ` argument a vendored runtime was invoked with, or None.""" + if GATE_FLAG in argv: + index = argv.index(GATE_FLAG) + if index + 1 < len(argv): + return _chock_Path(argv[index + 1]) + return None + +def runner_for(gate): + """The gate runner: beside the gate in a plugin, under .chock/bin in a repository, else None.""" + packaged = gate.resolve().parent / _PACKAGED_RUNNER + if packaged.exists(): + return packaged + parents = gate.resolve().parents + if len(parents) <= _GATE_DEPTH_TO_CHOCK: + return None + runner = parents[_GATE_DEPTH_TO_CHOCK].joinpath(*_RUNNER_PARTS) + return runner if runner.exists() else None + +def writes_from_event(event, root=None): + """The one file this tool call would write, or none when it carries no file text. + + An edit call carries only the text it inserts; judged alone, that fragment has no imports, + no class and no neighbours, so a gate that reads a whole file (a script kind) finds nothing + in it. An edit is therefore judged as the file it would leave: the file on disk with the + call's replacements applied. When that cannot be rebuilt -- the file is unreadable, or the + text to replace is not in it, so the call itself will fail -- the fragment is judged as + before and the turn's end judges what actually landed. A Codex apply_patch call names its + files only inside the patch; each one it adds or updates is judged as the patch leaves it. + """ + patched = patched_files(event, root) + if patched: + return patched + path = getattr(event, 'path', None) + content = created_text(event) + edited = edited_text(event, root) + if path and edited is not None: + return {str(path): edited} + if not path or not isinstance(content, str): + return {} + return {str(path): content} + +def repo_relative(path, root): + """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" + text = str(path) + if root is None: + return text + windows = _chock_os.name == 'nt' or str(root)[1:2] == _DRIVE_COLON + flavour = _chock_PureWindowsPath if windows else _chock_PurePosixPath + try: + return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() + except ValueError: + pass + if windows != (_chock_os.name == 'nt'): + return text + try: + return _chock_Path(root, text).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return text + +def repo_paths(path, root): + """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" + lexical = repo_relative(path, root) + if root is None or (str(root)[1:2] == _DRIVE_COLON) != (_chock_os.name == 'nt'): + return (lexical,) + try: + resolved = _chock_Path(root, str(path)).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return (lexical,) + return tuple(dict.fromkeys((lexical, resolved))) + +def changed_paths(repo_root, deadline=None, why=None): + """Every uncommitted path; None (reason appended to `why`) when git gave no answer, [] only outside a repository.""" + deadline, why = (engine_deadline() if deadline is None else deadline, [] if why is None else why) + argv = [_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'] + try: + timeout = time_left(deadline, argv) + proc = _chock_subprocess.run(argv, capture_output=True, text=True, encoding=_UTF8, errors=_PATH_ERRORS, timeout=timeout, check=False) + except _chock_subprocess.TimeoutExpired as exc: + why.append(f'git status did not finish within {allowed_seconds(exc)}') + except (OSError, _chock_subprocess.SubprocessError) as exc: + why.append(f'git status could not run ({exc})') + else: + first = ((proc.stderr or '').strip().splitlines() or [''])[0] + if proc.returncode and 'not a git repository' in first: + return [] + if proc.returncode: + why.append(f'git status failed (exit {proc.returncode}): {first}') + if why: + sys.stderr.write(f'chock: {why[-1]}, worktree not checked\n') + return None + fields = iter([field for field in (proc.stdout or '').split('\x00') if field]) + paths = [] + for field in fields: + status, path = (field[:2], field[3:]) + if status.startswith(_RENAMED): + next(fields, None) + if _DELETED not in status and path: + paths.append(path) + return paths + +def writes_from_worktree(repo_root, deadline=None, why=None): + """What this turn actually left on disk, however it was written; None when it could not be listed. + + The write path sees only writes it recognises; a shell heredoc carries no file argument. + Reading final state is what makes that stop mattering, so this deliberately does not care + which tool produced the bytes. + """ + paths = changed_paths(repo_root, deadline, why) + if paths is None: + return None + writes = {} + for path in paths: + try: + writes[path] = _chock_Path(repo_root, path).read_text(encoding='utf-8') + except (OSError, UnicodeDecodeError): + continue + return writes + +def run_gate(gate, writes, event, run_in, deadline): + """Ask the vendored runner. Returns (outcome, message) and never decides for itself. + + `run_in` is (root, extra): the working directory, and the stdin keys `extra` adds: `added` (per + edited path, only the text the edit introduces; a runner that predates it judges the whole file, + which only ever refuses more) and the script `session`. The runner gets what is left of `deadline`. + """ + root, extra = run_in + runner = runner_for(gate) + if runner is None: + return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') + argv = [sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event] + try: + proc = _chock_subprocess.run(argv, input=json.dumps({'writes': writes, **(extra or {})}), capture_output=True, text=True, encoding=_UTF8, errors='replace', timeout=time_left(deadline, argv), check=False, cwd=str(root) if root is not None else None) + except _chock_subprocess.TimeoutExpired as exc: + return (GATE_ERRORED, f'the gate runner gave no verdict within {allowed_seconds(exc)}') + except (OSError, _chock_subprocess.SubprocessError) as exc: + return (GATE_ERRORED, str(exc)) + return runner_outcome(proc.returncode, proc.stderr) + +_EVENT_ARG = {'pre_tool': 'pre-tool-use', 'stop': 'stop'} + +PRE_TOOL = 'pre_tool' + +def root_for(gate): + """The repository this compiled gate belongs to, or None when the layout is not that.""" + parents = gate.resolve().parents + if len(parents) <= _GATE_DEPTH_TO_CHOCK: + return None + if parents[_GATE_DEPTH_TO_CHOCK - 1].name != 'compiled' or parents[_GATE_DEPTH_TO_CHOCK].name != '.chock': + return None + return parents[_GATE_DEPTH_TO_CHOCK].parent + +def repo_root_for(event, gate): + """The repository under judgement: the compiled layout's root, else the event's cwd, else its workspace root. + + Of several workspace roots, the one holding the written path; this process's cwd only when there are none. + """ + root = root_for(gate) + if root is not None: + return root + cwd = getattr(event, 'cwd', None) or workspace_root(event) + return _chock_Path(cwd) if cwd else _chock_Path.cwd() + +def writes_for(event, gate, deadline=None, why=None, root=None): + """What this event puts under judgement: the call's own text, or what the turn left behind (None: unlisted).""" + root = repo_root_for(event, gate) if root is None else root + if event.event == PRE_TOOL: + return writes_from_event(event, root) + return writes_from_worktree(root, deadline, why) + +def _missing_gate(gate): + """A gate the hook names but that is not on disk: a broken install, so a refusal that says so.""" + return (VERDICT_DENY, f'chock gate {gate} is missing, so this write cannot be checked. Run `chock sync --repo .` to rebuild the compiled gates.') + +def _gate_says(gate, event, name, deadline, root): + """(decision, judged files): what the compiled gate says about this event in `root`, before a re-entered stop.""" + if not gate.exists(): + return (_missing_gate(gate), {}) + outside = outside_globs(gate) + why = [] + listed = writes_for(event, gate, deadline, why, root) + if listed is None: + return (gate_decision(GATE_ERRORED, why[-1] if why else 'git status gave no answer', gate), {}) + writes = judged_files(listed, root, outside, lambda path: repo_paths(path, root)) + if not writes: + return (None, writes) + added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} + added = judged_files(added, root, outside, lambda path: repo_paths(path, root)) + added = {path: text for path, text in added.items() if path in writes} + extra = {**({'added': added} if added else {}), 'session': session_for(event, root)} + outcome, message = run_gate(gate, writes, name, (root, extra), deadline) + return (gate_decision(outcome, message, gate), writes) + +def evaluate_gate(argv, event): + """The decision this event earns from a compiled gate, or None when it has nothing to say.""" + gate = gate_path_from_argv(argv) + name = _EVENT_ARG.get(getattr(event, 'event', '')) + if gate is None or name is None: + return None + deadline = engine_deadline() + if event.event == PRE_TOOL: + return _gate_says(gate, event, name, deadline, repo_root_for(event, gate))[0] + roots = [repo_root_for(event, gate)] if root_for(gate) else stop_roots(event, repo_root_for(event, gate)) + decision, judged = per_root([(root, *_gate_says(gate, event, name, deadline, root)) for root in roots]) + return settle_stop(event, roots[0], gate, decision, judged) + +TOOL_CALL_FLAG = '--tool-call' + +RECORD_FLAG = '--record' + +TOOL_CALL_EVENT = 'tool_call' + +_TOOL_CALL_PRE = 'pre_tool' + +_TOOL_CALL_POST = ('post_tool', 'tool_failure') + +_TOOL_CALL_SCRIPT_KIND = 'script' + +_TOOL_CALL_REGEX_KIND = 'content_regex' + +_TOOL_CALL_BLOCKED = 'blocked' + +_TOOL_CALL_NEEDS_SESSION = frozenset({_TOOL_CALL_SCRIPT_KIND}) + +_TOOL_CALL_EXIT_VERDICTS = {0: None, 1: 'deny', 3: 'escalate', 4: 'warn'} + +_TOOL_CALL_CEILING = {'block': 'deny', 'ask': 'escalate', 'warn': 'warn'} + +_TOOL_CALL_RANK = {'warn': 0, 'escalate': 1, 'deny': 2} + +_TOOL_CALL_UNDECIDED = ' -- refusing rather than allowing what it never judged' + +def _flag_path(argv, flag): + if flag in argv: + index = argv.index(flag) + if index + 1 < len(argv): + return _chock_Path(argv[index + 1]) + return None + +def tool_call_matches(globs, tool): + """Whether `tool` (a tool name, exact case) matches any glob; no globs match nothing.""" + return any((_chock_fnmatch.fnmatchcase(tool, str(glob)) for glob in globs)) + +def _tool_call_refusal(spec, spoken): + return spoken or str(spec.get('message') or '').strip() or 'blocked by a chock tool_call gate' + +def _tool_call_regex(spec, tool_input): + """`deny` verdict when the input, as JSON text, matches the gate's `content_pattern`.""" + text = json.dumps(tool_input, sort_keys=True, ensure_ascii=False) + if _chock_re.search(str((spec.get('params') or {}).get('content_pattern', '')), text): + return ('deny', _tool_call_refusal(spec, '')) + return None + +def _tool_call_script(spec, root, payload, deadline=None): + """Run the policy's script on `payload`; a missing script, crash or timeout refuses.""" + named = str((spec.get('params') or {}).get('script', '')) + script = _chock_Path(root) / named + if not script.is_file(): + return ('deny', f'script gate: {named!r} is not installed{_TOOL_CALL_UNDECIDED}') + deadline = engine_deadline() if deadline is None else deadline + argv = [sys.executable, str(script)] + try: + proc = _chock_subprocess.run(argv, input=json.dumps(payload), capture_output=True, text=True, encoding='utf-8', errors='replace', cwd=str(root), timeout=time_left(deadline, argv), check=False) + except _chock_subprocess.TimeoutExpired as exc: + budget = f'gave no verdict within {allowed_seconds(exc)}' + return ('deny', f'script gate: {script.name} {budget}{_TOOL_CALL_UNDECIDED}') + except OSError as exc: + return ('deny', f'script gate: {script.name} could not run ({exc}){_TOOL_CALL_UNDECIDED}') + spoken = ((proc.stderr or '') + (proc.stdout or '')).strip() + if proc.returncode in _TOOL_CALL_EXIT_VERDICTS: + verdict = _TOOL_CALL_EXIT_VERDICTS[proc.returncode] + return None if verdict is None else (verdict, spoken or f'blocked by {script.name}') + first = spoken.splitlines()[0] if spoken else '' + detail = f': {first}' if first else '' + return ('deny', f'script gate: {script.name} exited {proc.returncode}{detail}{_TOOL_CALL_UNDECIDED}') + +def _tool_call_verdict(spec, root, event, deadline): + """None to allow, else (verdict, message) for this call; a kind a tool call cannot answer refuses.""" + tool, tool_input = (str(event.tool or ''), tool_call_input(event)) + kind = spec.get('kind') + if kind == _TOOL_CALL_REGEX_KIND: + return _tool_call_regex(spec, tool_input) + if kind == _TOOL_CALL_SCRIPT_KIND: + payload = {'event': TOOL_CALL_EVENT, 'repo_root': str(root), 'tool': tool, 'input': tool_input, 'session': session_for(event, root)} + return _tool_call_script(spec, root, payload, deadline) + return ('deny', f'chock tool_call gate: kind {kind!r} cannot judge a tool call{_TOOL_CALL_UNDECIDED}') + +def _tool_call_capped(spec, verdict): + """`verdict` held to the gate's declared action; an unknown action keeps the verdict as spoken.""" + ceiling = _TOOL_CALL_CEILING.get(spec.get('action', 'block')) + if verdict is None or ceiling is None: + return verdict + return (min(verdict[0], ceiling, key=_TOOL_CALL_RANK.__getitem__), verdict[1]) + +def _tool_call_spec(gate): + """The compiled gate as a dict, or (None, refusal) when the install is broken.""" + if not gate.exists(): + return (None, f'chock gate {gate} is missing, so this tool call cannot be checked. Run `chock sync --repo .` to rebuild the compiled gates.') + try: + return (json.loads(gate.read_text(encoding='utf-8')), None) + except (OSError, ValueError) as exc: + return (None, f'chock could not read {gate}: {exc}. Refusing rather than reporting an allow it never established.') + +def evaluate_tool_call(argv, event): + """The decision a tool_call gate earns this call, or None; also logs the call for the session. + + `--tool-call ` judges a PreToolUse call and, for a gate whose check reads the session, + logs it after the verdict, so the script reads prior calls only. `--record ` logs what + a call came to, after it ran (a vendor's PostToolUse and failure events). + """ + recording = _flag_path(argv, RECORD_FLAG) + if recording is not None: + if event.event in _TOOL_CALL_POST: + session_record(repo_root_for(event, recording), event, 'post', session_outcome(event)) + return None + gate = _flag_path(argv, TOOL_CALL_FLAG) + if gate is None or event.event != _TOOL_CALL_PRE: + return None + spec, broken = _tool_call_spec(gate) + if spec is None: + return ('deny', broken) + root = repo_root_for(event, gate) + verdict = None + if TOOL_CALL_EVENT in spec.get('on', []) and tool_call_matches((spec.get('params') or {}).get('tools', []), str(event.tool or '')): + verdict = _tool_call_capped(spec, _tool_call_verdict(spec, root, event, engine_deadline())) + if spec.get('kind') in _TOOL_CALL_NEEDS_SESSION: + session_record(root, event, 'pre', _TOOL_CALL_BLOCKED if verdict and verdict[0] == 'deny' else None) + return verdict +_CHOCK_UNREADABLE = ( + "chock could not read this hook's payload, so it could not check the call. " + "Refusing rather than reporting an allow it never established." +) + +#: A payload naming only this vendor's own wire event, so `respond` picks that event's grammar. +_CHOCK_WIRE_RAW = {PRE_TOOL: {'hook_event_name': 'PreToolUse'}, STOP: {'hook_event_name': 'Stop'}} + +#: The words a client reads as a refusal, in any of the answer fields a vendor grammar uses. +_CHOCK_REFUSING = ("deny", "block", "ask", "continue") +_CHOCK_ANSWER_KEYS = ("decision", "permission", "permissionDecision") + + +def _chock_spec_action(path): + try: + action = json.loads(path.read_text(encoding="utf-8")).get("action") + except Exception: + return "block" + return action if action in ("ask", "warn") else "block" + + +def _chock_declared(): + """(strictest action, event, gate) of the judging flags; (None, None, None) where this run judges nothing.""" + argv = sys.argv[1:] + actions = ["block"] if guard_path_from_argv(argv) is not None else [] + event = PRE_TOOL + gate = _flag_path(argv, GATE_FLAG) + if gate is not None: + actions.append(_chock_spec_action(gate)) + event = STOP if STOP_FLAG in argv or gate.parent.name == _EVENT_ARG[STOP] else PRE_TOOL + called = _flag_path(argv, TOOL_CALL_FLAG) + if called is not None: + actions.append(_chock_spec_action(called)) + for action in ("block", "ask", "warn"): + if action in actions: + return action, event, gate + return None, None, None + + +def _chock_refuses(text, code): + if code == 2: + return True + try: + body = json.loads(text) + except Exception: + return False + if not isinstance(body, dict): + return False + nested = body.get("hookSpecificOutput") + answers = [body] + ([nested] if isinstance(nested, dict) else []) + return any(part.get(key) in _CHOCK_REFUSING for part in answers for key in _CHOCK_ANSWER_KEYS) + + +def _chock_unreadable_stop(gate): + """The bounded verdict of an unreadable Stop: a refusal, or a warning once the cap is spent.""" + root = root_for(gate) or _chock_Path.cwd() + return settle_unreadable_stop(root, gate.parent.parent.name, _CHOCK_UNREADABLE) + + +def _chock_unreadable(): + action, kind, gate = _chock_declared() + if action in (None, "warn"): + return "", 0 + event = Event(AGENT, kind, raw=dict(_CHOCK_WIRE_RAW[kind])) + reason = _CHOCK_UNREADABLE + if kind == STOP and gate is not None: + verdict, reason = _chock_unreadable_stop(gate) + if verdict == VERDICT_WARN: + return respond(degrade(Decision.warn(reason), event), event) + _report(reason + "\n") + decision = Decision.escalate(reason) if action == "ask" else Decision.deny(reason) + text, code = respond(degrade(decision, event), event) + return (text, code) if _chock_refuses(text, code) else ("", 2) +#: Per wire event mapped to pre_tool: whether the client shows a guard's ask as a prompt. +_CHOCK_CAN_ASK = {'PreToolUse': False} +_CHOCK_EVENT_KEYS = ('hook_event_name', 'hookEventName', 'agent_action_name') + + +def _chock_wire_name(raw): + names = (raw.get(key) for key in _CHOCK_EVENT_KEYS) if isinstance(raw, dict) else () + return next((name for name in names if isinstance(name, str)), None) + + +def _chock_can_ask(raw): + # An event this bundle cannot name, or one the vendor data does not list, cannot prompt. + return _CHOCK_CAN_ASK.get(_chock_wire_name(raw), False) + + +def _spoken(verdict): + outcome, reason = verdict + if outcome == VERDICT_WARN: + return Decision.warn(reason) + return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + + +def _judge(event): + if event.event == "pre_tool" and event.command: + verdict = evaluate(sys.argv[1:], event.command, event.tool or "", can_ask=_chock_can_ask(event.raw)) + if verdict is not None: + outcome, reason = verdict + return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + gated = evaluate_gate(sys.argv[1:], event) + if gated is not None: + return _spoken(gated) + called = evaluate_tool_call(sys.argv[1:], event) + if called is not None: + return _spoken(called) + return None + + +def handle(event): + # A door that cannot decide refuses. An exception escaping here would exit the hook + # with a traceback, which every client reads as a non-blocking error: fail-open, with + # the reason on a stderr nobody watches. The refusal carries the reason instead. + try: + return _judge(event) + except Exception as exc: # noqa: BLE001 -- any failure here must refuse, never fall through + return Decision.deny( + "chock could not check this call (%s: %s). Refusing rather than reporting an " + "allow it never established." % (type(exc).__name__, exc) + ) +# <<< 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 _report(text): + # Best effort, never raising: a diagnostic must not pre-empt the refusal it accompanies. + # A hook's stderr is whatever the host gave it -- closed (then sys.stderr is None, and + # the traceback module's default print would land on STDOUT, inside the verdict), a + # console code page that cannot hold the payload text quoted in the exception, a pipe + # nobody reads. + try: + sys.stderr.write(text) + sys.stderr.flush() + except Exception: + return + + +_STDERR_FD = 2 + + +def _divert_fd1(): + """Point fd 1 at stderr (devnull if there is none); the saved fd 1, or None if it could not.""" + try: + saved = os.dup(1) + except OSError: + return None + # With fd 2 closed, dup() hands back 2 itself, and "stderr" would be stdout again. + if saved > _STDERR_FD: + with contextlib.suppress(OSError): + os.dup2(_STDERR_FD, 1) + return saved + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +def _flush(streams): + """Flush each stream that exists; a broken one is no reason to lose the verdict.""" + for stream in streams: + if stream is not None: + with contextlib.suppress(Exception): + stream.flush() + + +@contextlib.contextmanager +def _stdout_to_stderr(): + # Stdout is the verdict channel. A handler's stray print (or a child process it runs) + # ahead of the JSON makes the host fail to parse it, and Claude Code and Gemini CLI then + # treat the hook as a non-blocking error: a deny became an allow, witnessed live. + sink = sys.stderr if sys.stderr is not None else io.StringIO() + held = (sys.stdout, sys.__stdout__) + _flush(held) + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + # A stream the handler held on to (sys.__stdout__, a reference cached at import) + # buffers past redirect_stdout; flushed now it lands on stderr, not after the verdict. + _flush((sink, *held)) + os.dup2(saved, 1) + os.close(saved) + + +def _decide(raw): + """(stdout_text, exit_code) for one decoded payload: agentseam.dispatch.handle(), inlined.""" + event = parse(raw) + 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. + return "", 0 + try: + with _stdout_to_stderr(): + result = handle(event) + decision = _coerce(result) + except Exception: + # A door that cannot decide refuses. Escaping here would exit 1 with a traceback, + # which every host reads as a non-blocking error and allows past; instead the + # failure is printed for the operator and the gate answers deny in 'factory_droid''s + # own dialect. Only the failure's class is named to the host: its message may quote + # the very payload content the policy was inspecting. + _report(traceback.format_exc()) + decision = Decision.deny( + "policy handler failed (%s); refusing rather than allowing what it could not judge" + % sys.exc_info()[0].__name__ + ) + return respond(degrade(decision, event), event) + + +def main(stdin=None, stdout=None, exit=True): + """Read one payload from stdin, dispatch, emit the 'factory_droid' 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: + raw = None + if not isinstance(raw, dict): + text, code = _chock_unreadable() + if text: + _emit(out, text) + if exit: + sys.exit(code) + return code + try: + text, code = _decide(raw) + except Exception: + # Past the handler, which _decide() already answers for: a fault in this file on a + # payload it did decode. With no Event to answer in dialect, the one refusal left is + # the host's blocking exit code, and nothing on stdout to be misread as a verdict. + _report(traceback.format_exc()) + text, code = "", 2 + 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 index 1afd4d41..98ecf634 100644 --- a/tests/fixtures/runtime_goldens/gemini_cli.py +++ b/tests/fixtures/runtime_goldens/gemini_cli.py @@ -29,6 +29,7 @@ from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings +import zlib as _zlib # ------------------------------------------------------------------------------ # contract (agentseam 0.3.5) @@ -235,11 +236,31 @@ def tool_input_of(raw): "effort", ) +_MCP_PREFIX = "mcp_" + +_CLAUDE_MCP_PREFIX = "mcp__" + 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 names_a_single_underscore_mcp_tool(raw): + """True when tool_name is an MCP tool ID in a spelling Claude Code never sends.""" + name = raw.get("tool_name") if isinstance(raw, dict) else None + return isinstance(name, str) and name.startswith(_MCP_PREFIX) and not name.startswith(_CLAUDE_MCP_PREFIX) + +def event_keys_agree(raw): + """True when hookEventName names hook_event_name's event: the same text, or Grok Build's snake_case twin.""" + snake, pascal = (raw.get("hookEventName"), raw.get("hook_event_name")) if isinstance(raw, dict) else (None, None) + if not isinstance(pascal, str) or not isinstance(snake, str): + return False + return snake == pascal or (snake.islower() and snake.replace("_", "") == pascal.lower()) + +PROBES = { + "looks_like_claude_code": looks_like_claude_code, + "names_a_single_underscore_mcp_tool": names_a_single_underscore_mcp_tool, + "event_keys_agree": event_keys_agree, +} UNREADABLE_NAME = "" @@ -265,12 +286,22 @@ def _rejected_by_markers(c, raw): for marker in c.get("reject_markers", ()): if marker in raw: return True + for key, values in c.get("reject_values", {}).items(): + if key in raw and raw[key] in values: + return True for probe, markers in c.get("reject_markers_unless_probe", {}).items(): if any(marker in raw for marker in markers) and not PROBES[probe](raw): return True return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) +def _accepted_by_values(c, raw): + """True unless the entry names accepted values and the payload carries none of them.""" + values = c.get("accept_values") + return not values or any(key in raw and raw[key] in allowed for key, allowed in values.items()) + def _accepted_by_markers(c, raw, name): + if not _accepted_by_values(c, raw): + return False accept = c.get("accept_markers", ()) if not accept or any(marker in raw for marker in accept): return True @@ -309,12 +340,25 @@ def _segment(node, part): 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 _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + def _walk(node, path): - """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None if "[]." in path: head, sub = path.split("[].", 1) - items = _walk(node, head) - if not isinstance(items, (list, tuple)): + items = _list_at(node, head) + if items is None: return None joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) return joined or None @@ -344,7 +388,7 @@ def _tool_input_raw(cfg, raw): if not isinstance(raw, dict): return None for key in cfg["fields"].get("tool_input", ("tool_input",)): - value = raw.get(key) + value = _walk(raw, key) if value is not None: return value return None @@ -535,6 +579,8 @@ def hj_respond(cfg, decision, event, wire=None): return "", 0 if gate["grammar"] == "G2": return _g2(v, gate, decision, name) + if gate["grammar"] == "G7": + return _g7(v, decision, wire) return _g1(v, gate, decision, wire, name) _WINDOWS_KEYS = ("commandWindows", "windows") @@ -550,16 +596,25 @@ def _hook_dict(cfg, command): entry[key] = value return entry +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): rules = [] for ev in canonical_events: name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: - rule["matcher"] = matcher - rules.append(rule) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) return rules def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): @@ -588,6 +643,10 @@ def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): hooks.setdefault(extra, []).append({"command": command}) return {"hooks": hooks} +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] hooks = {} @@ -618,6 +677,8 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) if wrapper == "flat_entries": return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) return _default_wrapper(cfg, reverse, canonical_events, command, matcher) diff --git a/tests/fixtures/runtime_goldens/goose.py b/tests/fixtures/runtime_goldens/goose.py new file mode 100644 index 00000000..4903a7c3 --- /dev/null +++ b/tests/fixtures/runtime_goldens/goose.py @@ -0,0 +1,2407 @@ +# Generated by agentseam 0.3.5 -- bundle("goose"). 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("goose")` (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 contextlib +import io +import json +_json = json +import os +import sys + +import hashlib as _chock_hashlib +import fnmatch as _chock_fnmatch +import re as _chock_re +import os as _chock_os +import shlex as _chock_shlex +import shutil as _chock_shutil +import subprocess as _chock_subprocess +import time as _chock_time +from datetime import datetime as _chock_datetime, timezone as _chock_timezone +from pathlib import Path as _chock_Path +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath +import traceback +import warnings as _warnings +import zlib as _zlib + +# ------------------------------------------------------------------------------ +# contract (agentseam 0.3.5) + +"""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", + "command", + "content", + "cwd", + "event", + "output", + "path", + "prompt", + "raw", + "session_id", + "tool", + "tool_use_id", + ) + + 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__ = ("context", "evidence", "outcome", "reason", "updated_input") + + #: 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.lstrip()[:1] == "{": + try: + parsed = _json.loads(raw) + except (ValueError, RecursionError): + # JSONDecodeError is a ValueError; a string nested past the interpreter's + # limit raises RecursionError instead, and neither is an input to crash on. + return {} + if isinstance(parsed, dict): + return parsed + return {} + + +# ------------------------------------------------------------------------------ +# flat_decision family engine (trimmed to what this entry uses) + +UNREADABLE_NAME = "" + +def wire_name_of(raw, key): + """The event name under `key`: text, None when absent, UNREADABLE_NAME when not text. + + Total over anything `json.loads` can return: a payload that is not an object names no + event, and a name that is not a string is not one any adapter can map. + """ + name = raw.get(key) if isinstance(raw, dict) else None + if name is None or isinstance(name, str): + return name + return UNREADABLE_NAME + +def _wire_name(cfg, raw): + for key in cfg["claims"].get("event_key", ()): + name = wire_name_of(raw, key) + if name is not None: + return name + return None + +def _rejected_by_markers(c, raw): + for marker in c.get("reject_markers", ()): + if marker in raw: + return True + for key, values in c.get("reject_values", {}).items(): + if key in raw and raw[key] in values: + return True + for probe, markers in c.get("reject_markers_unless_probe", {}).items(): + if any(marker in raw for marker in markers) and not PROBES[probe](raw): + return True + return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) + +def _accepted_by_values(c, raw): + """True unless the entry names accepted values and the payload carries none of them.""" + values = c.get("accept_values") + return not values or any(key in raw and raw[key] in allowed for key, allowed in values.items()) + +def _accepted_by_markers(c, raw, name): + if not _accepted_by_values(c, raw): + return False + accept = c.get("accept_markers", ()) + if not accept or any(marker in raw for marker in accept): + return True + return any( + name == event_name and all(key in raw for key in required) + for event_name, required in c.get("accept_when_all", {}).items() + ) + +def _disqualified(cfg, c, raw, name): + """Every reason an entry declines a payload once its wire event name is known.""" + if name not in cfg["events"] and not (c.get("accept_any_name") and isinstance(name, str)): + return True + if "client_types" in c and raw.get("client_type") not in c["client_types"]: + return True + return _rejected_by_markers(c, raw) + +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) + # A positive self-identification beats a shared event name, accept_names included. + if raw.get("client_type") in c.get("reject_client_types", ()): + return False + if name in c.get("accept_names", ()): + return True + return not _disqualified(cfg, c, raw, name) and _accepted_by_markers(c, raw, name) + +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 _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + +def _walk(node, path): + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None + if "[]." in path: + head, sub = path.split("[].", 1) + items = _list_at(node, head) + if items is None: + 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): + if not isinstance(raw, dict): + return None + for key in cfg["fields"].get("tool_input", ("tool_input",)): + value = _walk(raw, 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, + ) + +_ESCALATE_FROM_TRANSFORM = "escalate_from_transform" + +_TRANSFORM_MISSING_INPUT = "transform_missing_input" + +def hj_reverse(cfg): + """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides. + + One wire name per canonical event, which is a real limit and not an oversight (R3, gap 4). + Cursor is where it bites: `pre_tool` pins to `preToolUse`, and Cursor honours `ask` only at + `beforeShellExecution` / `beforeMCPExecution`. So installing at `pre_tool` forecloses `ask` + before dispatch is ever reached -- the runtime degrade to `deny` is honest about it, but the + install already chose. Deny-style policies, which is all that ships today, are unaffected. + + The day an ask-style Cursor policy exists, this map has to select by decision dialect + (deny -> `preToolUse`, ask -> `beforeShellExecution`) and become one-to-many. Recorded here + rather than built, so the resolution is not re-litigated from scratch; the three facts it + rests on are pinned in tests/test_cursor_ask_dialect.py. + """ + 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_allow(v, decision, wire, name, words): + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + if wire in v.get("allow_silent_events", ()): + return "", 0 + if "allow" in words: + out = {"decision": words["allow"]} + if v.get("allow_context_key") and decision.outcome == ALLOW and value: + out[v["allow_context_key"]] = value + return _json.dumps(out), 0 + return "", 0 + +def _g1_transform(v, decision, name, words): + """None when the transform isn't representable here; `_g1` falls through to the block path.""" + if v.get("transform_grammar") == "hook_specific_tool_input": + return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0 + if decision.updated_input is None: + return None + if v.get("transform_grammar") == "top_level_updated_input": + out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input} + if decision.reason: + out["reason"] = decision.reason + return _json.dumps(out), 0 + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}}), 0 + +def _g1(v, gate, decision, wire, name): + """Block dialect: a top-level decision word, or silence/context where nothing is read.""" + words = dict(v.get("words", {})) + words.update(v.get("words_at", {}).get(wire, {})) + at_context_event = wire in v.get("context_events", ()) + if decision.outcome in (ALLOW, VOUCH, WARN): + return _g1_allow(v, decision, wire, name, words) + if decision.outcome == TRANSFORM and gate["honours_transform"]: + transformed = _g1_transform(v, decision, name, words) + if transformed is not None: + return transformed + if ( + decision.outcome == ESCALATE + and gate["honours_escalate"] + and "escalate" in words + # An escalate the dispatcher degraded a transform into is a block where the entry + # names that degradation (antigravity): prompting would offer the unmodified call. + and not (degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in v.get("degrade_notes", {})) + ): + reason = decision.reason or _default_for(v, decision, at_gate=True, wire=wire) + return _json.dumps({"decision": words["escalate"], "reason": reason}), 0 + out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, at_gate=False, wire=wire)} + if at_context_event and v.get("context_source") == "context" and decision.context: + out["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": decision.context} + return _json.dumps(out), 0 + +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) + if gate["grammar"] == "G7": + return _g7(v, decision, wire) + return _g1(v, gate, decision, wire, name) + +_WINDOWS_KEYS = ("commandWindows", "windows") + +_MATCHER_EVENTS = (PRE_TOOL, POST_TOOL, TOOL_FAILURE) + +def _hook_dict(cfg, command): + entry = {"type": "command", "command": command} + for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): + if key in _WINDOWS_KEYS: + entry[key] = powershell_command(command) + else: + entry[key] = value + return entry + +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + +def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): + rules = [] + for ev in canonical_events: + name = reverse.get(ev) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) + return rules + +def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): + gates = cfg["verdicts"]["answer_events"] + allow_silent = cfg["verdicts"].get("allow_silent_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 and name not in allow_silent: + entry["failClosed"] = True + hooks.setdefault(name, []).append(entry) + return {"version": 1, "hooks": hooks} + +def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + hooks.setdefault(name, []).append({"command": command}) + extra = hook_entry.get("also_wires", {}).get(ev) + if extra: + hooks.setdefault(extra, []).append({"command": command}) + return {"hooks": hooks} + +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + +def _default_wrapper(cfg, reverse, canonical_events, command, matcher): + hook_entry = cfg["hook_entry"] + 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"] and ev in _MATCHER_EVENTS: + 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 hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_closed=True): + """The vendor's hooks-config fragment wiring `command` for these canonical events. + + `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the + entry says otherwise; a False installs an observer, not a gate. + """ + hook_entry = cfg["hook_entry"] + reverse = hj_reverse(cfg) + wrapper = hook_entry["wrapper"] + if wrapper == "flat_list": + return _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher) + if wrapper == "cursor": + return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) + if wrapper == "flat_entries": + return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) + return _default_wrapper(cfg, reverse, canonical_events, command, matcher) + + +# ------------------------------------------------------------------------------ +# goose vendor config + engine binding + +AGENT = "goose" + +VENDOR = {'agent': 'goose', 'claims': {'event_key': ('event',), 'mode': 'marker', 'notes': "goose names the event under `event`, a key no other adapter's payload uses (every other marker family reads hook_event_name or hookEventName), so the event key alone separates it."}, 'config_format': 'json', 'config_path': '.agents/plugins/agentseam/hooks/hooks.json', 'display': 'goose', 'events': {'AfterFileEdit': 'file_changed', 'AfterShellExecution': 'unknown', 'BeforeReadFile': 'unknown', 'BeforeShellExecution': 'unknown', 'PostToolUse': 'post_tool', 'PostToolUseFailure': 'tool_failure', 'PreToolUse': 'pre_tool', 'PreToolUseResult': 'unknown', 'SessionEnd': 'session_end', 'SessionStart': 'session_start', 'Stop': 'stop', 'UserPromptSubmit': 'prompt_submit'}, 'evidence': {'claims': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'vendor-docs', 'date': '2026-10-11', '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.after'), 'cwd': ('working_dir',), 'path': ('tool_input.path',), 'prompt': ('message',), 'session_id': ('session_id',), 'tool': ('tool_name',), 'tool_use_id': ('tool_call_id',)}, 'hook_entry': {'entry_extra': {'on_failure': 'block'}, 'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'tools': {'shell': ('shell',), 'write': ('write', 'edit')}, 'verdicts': {'answer_events': ('PreToolUse', 'Stop'), 'bare_allow': 'silent', 'degrade_notes': {'escalate': 'goose cannot prompt for confirmation', 'escalate_from_transform': 'goose cannot modify a tool call', 'transform': 'goose cannot modify a tool call'}, 'gates': {'PreToolUse': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'Stop': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'because', 'vocabulary': ('allow', 'block'), 'vocabulary_basis': 'verified', 'words': {'block': 'block'}}} + + +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 goose) + +_VOUCH_SPEAKS = False +_WARN_SPEAKS = False +_TRANSFORM_EVENTS = frozenset(()) + + +def degrade(decision, event): + """Reduce a decision to what 'goose' 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 >>> +ENGINE_BUDGET_SECONDS = 30 + +def engine_deadline(): + """The monotonic instant this invocation's budget runs out.""" + return _chock_time.monotonic() + ENGINE_BUDGET_SECONDS + +def engine_remaining(deadline): + """Seconds left before `deadline`, never negative.""" + return max(deadline - _chock_time.monotonic(), 0.0) + +def time_left(deadline, cmd): + """Seconds left for a subprocess; raises TimeoutExpired when none are, so the caller's timeout path runs.""" + left = deadline - _chock_time.monotonic() + if left <= 0: + raise _chock_subprocess.TimeoutExpired(cmd, 0) + return left + +def allowed_seconds(expired): + """The time a timed-out call was actually given, as text (what the deadline left it, not the full budget).""" + return f'{max(float(expired.timeout or 0), 0.0):.1f}s' + +GUARD_VIOLATION = 1 + +GUARD_ASK_EXIT = 3 + +PYTHON_SUFFIX = '.py' + +_CRASH_MARKERS = ('traceback (most recent call last)', 'syntax error', 'syntaxerror', 'unexpected eof') + +TOOL_UNKNOWN = 'unknown' + +ARGV_FALLBACK_ENV = 'CHOCK_ARGV_FALLBACK' + +CAN_ASK_ENV = 'CHOCK_CAN_ASK' + +_POSIX_BASH = ('bash', '/bin/bash', '/usr/bin/bash') + +_GIT_FOR_WINDOWS = ('C:\\Program Files\\Git', 'C:\\Program Files (x86)\\Git') + +_GIT_BASH_DIRS = (('bin',), ('usr', 'bin')) + +_BASH_EXE = 'bash.exe' + +_WINDOWS_STUB_DIRS = ('/system32/', '/windowsapps/') + +_COREUTILS_MARKER = 'sed.exe' + +_WINDOWS = 'nt' + +_FOUND_BASH = {} + +GATE_LOG_ENV = 'CHOCK_GATE_LOG' + +_LOG_MAX_BYTES = 1048576 + +_BASH_PROBE_SECONDS = 10 + +GUARD_BLOCKED = 'blocked' + +GUARD_CLEAN = 'clean' + +GUARD_ASKED = 'asked' + +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 _git_roots() -> list[_chock_Path]: + """Git for Windows install roots: from `git` on PATH (cmd/ or mingw64/bin/), then the defaults.""" + git = _chock_shutil.which('git') + near = [_chock_Path(git).parent.parent, _chock_Path(git).parent.parent.parent] if git else [] + return [*near, *(_chock_Path(root) for root in _GIT_FOR_WINDOWS)] + +def bash_candidates() -> list[str]: + """Bash interpreters to try, best first; on Windows Git's own, never the WSL launcher.""" + if _chock_os.name != _WINDOWS: + return list(_POSIX_BASH) + found = [str(root.joinpath(*sub, _BASH_EXE)) for root in _git_roots() for sub in _GIT_BASH_DIRS] + on_path = _chock_shutil.which('bash') + if on_path and (not any((stub in on_path.lower().replace('\\', '/') for stub in _WINDOWS_STUB_DIRS))): + found.append(on_path) + return [c for i, c in enumerate(found) if c not in found[:i] and _chock_Path(c).is_file()] + +def interpreter_env(interpreter: str) -> dict[str, str]: + """The environment a guard runs in: on Windows, Git's usr/bin ahead on PATH for sed and grep.""" + env = dict(_chock_os.environ) + env.pop(CAN_ASK_ENV, None) + if _chock_os.name != _WINDOWS: + return env + home = _chock_Path(interpreter).parent + usr_bin = next((d for d in (home.parent / 'usr' / 'bin', home) if (d / _COREUTILS_MARKER).is_file()), None) + if usr_bin is not None: + env['PATH'] = str(usr_bin) + _chock_os.pathsep + env.get('PATH', '') + return env + +def find_bash(guard: _chock_Path, deadline: float | None=None) -> str | None: + """First bash that can actually see `guard`, probed once per process; the probes draw on `deadline`.""" + if 'bash' in _FOUND_BASH: + return _FOUND_BASH['bash'] + for candidate in bash_candidates(): + argv = [candidate, '-c', f'test -f "{guard.as_posix()}"'] + try: + limit = _BASH_PROBE_SECONDS if deadline is None else min(_BASH_PROBE_SECONDS, time_left(deadline, argv)) + proc = _chock_subprocess.run(argv, capture_output=True, timeout=limit, check=False) + except (OSError, _chock_subprocess.SubprocessError): + if deadline is not None and engine_remaining(deadline) <= 0: + return None + continue + if proc.returncode == 0: + _FOUND_BASH['bash'] = candidate + return candidate + return None + +def find_interpreter(guard: _chock_Path, deadline: float | None=None) -> str | None: + """The interpreter that can run `guard`: this Python for `.py`, otherwise a usable bash.""" + if guard.suffix == PYTHON_SUFFIX: + return sys.executable or None + return find_bash(guard, deadline) + +def normalize_tool(tool: str | None) -> str: + """The hook payload's tool name as `bash` | `powershell` | `shell` | `unknown`.""" + name = (tool or '').lower() + if name in ('powershell', 'pwsh'): + return 'powershell' + if name == 'bash': + return 'bash' + if name == 'sh' or 'shell' in name: + return 'shell' + return TOOL_UNKNOWN + +def is_guard_crash(output: str) -> bool: + """True when a guard's exit-1 output is empty or shows an interpreter crash, not a refusal.""" + lowered = output.lower() + return not lowered or any((marker in lowered for marker in _CRASH_MARKERS)) + +def split_command(command: str) -> tuple[list[str], bool]: + """POSIX `shlex.split` argv, else a whitespace split; the flag is True on the fallback.""" + try: + return (_chock_shlex.split(command), False) + except ValueError: + return (command.split(), True) + +def run_guard(guard: _chock_Path, command: str, tool: str='') -> str: + """`GUARD_BLOCKED` / `GUARD_ASKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + return run_guard_detailed(guard, command, tool)[0] + +def _no_interpreter(guard: _chock_Path, deadline: float) -> tuple[str, str]: + """The ask a guard earns when no shell could run it: the budget ran out probing, or none can see it.""" + if engine_remaining(deadline) <= 0: + print(f'chock: guard timed out after {ENGINE_BUDGET_SECONDS}s finding a shell, not checked', file=sys.stderr) + return (GUARD_ERRORED, '') + reason = f'no usable bash was found to run {guard.name}; on Windows install Git for Windows (it ships bash), elsewhere put bash on PATH' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) + +def run_guard_detailed(guard: _chock_Path, command: str, tool: str='', *, can_ask: bool | None=None) -> tuple[str, str]: + """`run_guard`'s verdict plus the guard's own first line, which an ask carries to the user.""" + args, fallback = split_command(command) + if not args: + return (GUARD_UNCHECKED, '') + deadline = engine_deadline() + interpreter = find_interpreter(guard, deadline) + if interpreter is None: + return _no_interpreter(guard, deadline) + try: + env = {**interpreter_env(interpreter), 'CHOCK_RAW_COMMAND': command, 'CHOCK_TOOL': normalize_tool(tool)} + if fallback: + env[ARGV_FALLBACK_ENV] = '1' + env.update({} if can_ask is None else {CAN_ASK_ENV: '1' if can_ask else '0'}) + proc = _chock_subprocess.run([interpreter, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=time_left(deadline, [interpreter, str(guard)]), check=False) + except _chock_subprocess.TimeoutExpired: + print(f'chock: guard timed out after {ENGINE_BUDGET_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, '') + output = ((proc.stderr or '') + (proc.stdout or '')).strip() + first_line = output.splitlines()[0].strip() if output else '' + if proc.returncode == GUARD_VIOLATION: + if is_guard_crash(output): + print(f'chock: guard {_chock_Path(guard).name} exited 1 without a reason (crash?), not checked', file=sys.stderr) + return (GUARD_ERRORED, '') + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + return (GUARD_BLOCKED, first_line) + if proc.returncode == GUARD_ASK_EXIT: + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + return (GUARD_ASKED, first_line) + if proc.returncode != 0: + print(f'chock: guard exited {proc.returncode}, not checked' + (f': {first_line[:120]}' if first_line else ''), file=sys.stderr) + return (GUARD_ERRORED, '') + return (GUARD_CLEAN, '') + +def append_gate_log(chock_root: _chock_Path, record: dict) -> None: + """Append one record, stamped, to `/log/gate-events.jsonl`, rotating past the size cap.""" + log_dir = chock_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') + stamped = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), **record} + with log_path.open('a', encoding='utf-8') as fh: + fh.write(json.dumps(stamped, ensure_ascii=False) + '\n') + +def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> 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 + record = {'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': verdict} + append_gate_log(artifact_root, record) + except Exception: + return + +def evaluate(argv: list[str], command: str, tool: str='', *, can_ask: bool | None=None) -> tuple[str, str] | None: + """Run the guard named on `argv` (`--guard `) against `command`.""" + guard = guard_path_from_argv(argv) + if guard is None: + return None + if not guard.exists(): + return (VERDICT_DENY, f"chock guard {guard} is missing, so this command cannot be checked. Run `chock sync --repo .` to reinstall the policy's guards.") + verdict, message = run_guard_detailed(guard, command, tool, can_ask=can_ask) + logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} + if verdict in logged: + log_outcome(guard, tool, verdict=logged[verdict]) + if verdict == GUARD_BLOCKED: + return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + if verdict == GUARD_ASKED: + return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') + if verdict == GUARD_ERRORED: + why = message or f"the {guard.stem} guard did not complete (see this hook's stderr)" + return (VERDICT_ESCALATE, f'chock could not check this command against {guard.stem}: {why}. Approving runs it unchecked.') + return None + +_EDIT_KEYS = (('old_string', 'new_string'), ('oldString', 'newString'), ('old_str', 'new_str')) + +_EDIT_LIST = 'edits' + +_CRLF = '\r\n' + +_CREATE_TEXT = 'file_text' + +def _edit_call_input(event): + raw = getattr(event, 'raw', None) + tool_input = raw.get('tool_input') if isinstance(raw, dict) else None + if isinstance(tool_input, str) and tool_input[:1] == '{': + try: + tool_input = json.loads(tool_input) + except ValueError: + return {} + return tool_input if isinstance(tool_input, dict) else {} + +def _pair(item): + if not isinstance(item, dict): + return None + for old, new in _EDIT_KEYS: + if isinstance(item.get(old), str) and isinstance(item.get(new), str): + return (item[old], item[new], item.get('replace_all') is True) + return None + +def edit_replacements(event): + """The (old, new, replace_all) replacements an edit call applies, in order; None if not an edit.""" + tool_input = _edit_call_input(event) + listed = tool_input.get(_EDIT_LIST) + found = [_pair(item) for item in (listed if isinstance(listed, list) else [tool_input])] + if not found or None in found: + return None + return found + +def _replace(text, old, new, every): + """`text` with the replacement applied, or None when `old` is not there to replace. + + The file is read with universal newlines, so a CRLF file (a Windows checkout) arrives as + LF; the call's strings are brought to LF too, whichever ending the client sent them with, + rather than calling a real edit a failure over line endings. + """ + old, new = (old.replace(_CRLF, '\n'), new.replace(_CRLF, '\n')) + if old == '': + return new if text == '' else None + if old not in text: + return None + return text.replace(old, new) if every else text.replace(old, new, 1) + +def edited_text(event, root=None): + """The file an edit call would leave behind, or None when it is not an edit or cannot be rebuilt.""" + replacements = edit_replacements(event) + path = getattr(event, 'path', None) + if replacements is None or not path: + return None + target = _chock_Path(path) + if not target.is_absolute(): + target = _chock_Path(root if root is not None else _chock_Path.cwd()) / target + try: + text = target.read_text(encoding='utf-8') if target.exists() else '' + except (OSError, UnicodeDecodeError): + return None + for old, new, every in replacements: + text = _replace(text, old, new, every) + if text is None: + return None + return text + +def created_text(event): + """The whole file a write call carries: the adapter's content, else `tool_input.file_text`; None otherwise.""" + content = getattr(event, 'content', None) + if isinstance(content, str): + return content + text = _edit_call_input(event).get(_CREATE_TEXT) + return text if isinstance(text, str) else None + +def added_from_event(event): + """What an edit call introduces -- what `added_lines` means for it -- or {} for any other call.""" + replacements = edit_replacements(event) + path = getattr(event, 'path', None) + if replacements is None or not path: + return {} + return {str(path): '\n'.join((new for _, new, _ in replacements))} + +_BEGIN = '*** Begin Patch' + +_END = '*** End Patch' + +_ADD = '*** Add File: ' + +_DELETE = '*** Delete File: ' + +_UPDATE = '*** Update File: ' + +_MOVE = '*** Move to: ' + +_EOF = '*** End of File' + +_HUNK = '@@' + +_OPS = (('add', _ADD), ('delete', _DELETE), ('update', _UPDATE)) + +_PATCH_KEYS = ('command', 'input', 'patch') + +def patch_text(event): + """The patch an apply_patch call carries, from its first to its last marker; None otherwise.""" + raw = getattr(event, 'raw', None) + tool_input = raw.get('tool_input') if isinstance(raw, dict) else None + command = tool_input if isinstance(tool_input, str) else None + for key in _PATCH_KEYS if isinstance(tool_input, dict) else (): + if tool_input.get(key) is not None: + command = tool_input[key] + break + if isinstance(command, list): + command = '\n'.join((part for part in command if isinstance(part, str))) + start = command.find(_BEGIN) if isinstance(command, str) else -1 + end = command.find(_END, start) if start >= 0 else -1 + return command[start:end + len(_END)] if end >= 0 else None + +def _body_line(current, line): + """Add one line under the file operation `current` is reading.""" + if line.startswith(_MOVE): + current[2] = line[len(_MOVE):].strip() + elif line.startswith(_HUNK): + current[3].append([line[len(_HUNK):].strip(), [], False]) + elif line.startswith(_EOF): + if current[3]: + current[3][-1][2] = True + elif current[0] == 'add': + current[3].append(line[1:] if line.startswith('+') else line) + else: + if not current[3]: + current[3].append(['', [], False]) + current[3][-1][1].append(line if line[:1] in (' ', '-', '+') else ' ' + line) + +def parse_patch(patch): + """[(op, path, moved_to, hunks)]: op is add/delete/update; a hunk is (anchor, lines, at_eof).""" + operations = [] + for line in patch.replace('\r\n', '\n').split('\n')[1:]: + if line.startswith(_END): + break + opened = next(((op, marker) for op, marker in _OPS if line.startswith(marker)), None) + if opened is not None: + operations.append([opened[0], line[len(opened[1]):].strip(), None, []]) + elif operations: + _body_line(operations[-1], line) + return [tuple(operation) for operation in operations] + +def _find(lines, wanted, start): + """Where `wanted` starts in `lines` at or after `start`, exactly or ignoring trailing space.""" + for loose in (False, True): + for index in range(start, len(lines) - len(wanted) + 1): + window = lines[index:index + len(wanted)] + if window == wanted or (loose and [w.rstrip() for w in window] == [w.rstrip() for w in wanted]): + return index + return None + +def apply_hunks(text, hunks): + """`text` with the hunks applied in order, or None when one cannot be placed.""" + lines = text.split('\n') + cursor = 0 + for anchor, body, at_eof in hunks: + if anchor: + found = _find(lines, [anchor], cursor) + if found is None: + found = next((i for i in range(cursor, len(lines)) if anchor.strip() in lines[i]), None) + if found is None: + return None + cursor = found + 1 + old = [line[1:] for line in body if line[:1] in (' ', '-')] + new = [line[1:] for line in body if line[:1] in (' ', '+')] + if not old: + at = len(lines) - (1 if lines and lines[-1] == '' else 0) if at_eof else cursor + else: + at = _find(lines, old, cursor) + if at is None: + return None + lines[at:at + len(old)] = new + cursor = at + len(new) + return '\n'.join(lines) + +def _read(root, path): + target = _chock_Path(path) + if not target.is_absolute(): + target = _chock_Path(root if root is not None else _chock_Path.cwd()) / target + try: + return target.read_text(encoding='utf-8') + except (OSError, UnicodeDecodeError): + return None + +def patched_files(event, root=None): + """{path: text} for every file an apply_patch call adds or updates and can be rebuilt.""" + patch = patch_text(event) + if patch is None: + return {} + files = {} + for op, path, moved_to, body in parse_patch(patch): + if op == 'add': + files[path] = '\n'.join(body) + '\n' + elif op == 'update': + before = _read(root, path) + after = apply_hunks(before, body) if before is not None else None + if after is not None: + files[moved_to or path] = after + return files + +def patch_added(event): + """{path: the lines the patch adds}, which is what `added_lines` means for it.""" + patch = patch_text(event) + if patch is None: + return {} + added = {} + for op, path, moved_to, body in parse_patch(patch): + if op == 'add': + added[path] = '\n'.join(body) + elif op == 'update': + added[moved_to or path] = '\n'.join((line[1:] for _, hunk, _ in body for line in hunk if line[:1] == '+')) + return added + +GATE_BLOCKED = 'blocked' + +GATE_CLEAN = 'clean' + +GATE_ERRORED = 'errored' + +GATE_ASKED = 'asked' + +GATE_WARNED = 'warned' + +VERDICT_WARN = 'warn' + +_EXIT_OUTCOME = {0: GATE_CLEAN, 1: GATE_BLOCKED, 3: GATE_ASKED, 4: GATE_WARNED} + +def runner_outcome(returncode, stderr): + """(outcome, message) for the runner's exit code and stderr: its words, only when it refused or spoke.""" + outcome = _EXIT_OUTCOME.get(returncode, GATE_ERRORED) + return (outcome, '' if outcome == GATE_CLEAN else (stderr or '').strip()) + +def gate_decision(outcome, message, gate): + """The decision an outcome earns, or None when the gate allowed: deny, escalate (ask) or warn.""" + policy = gate.parent.parent.name + spoken = {GATE_BLOCKED: (VERDICT_DENY, message or f'Blocked by chock policy: {policy}'), GATE_ASKED: (VERDICT_ESCALATE, message or f'Chock policy {policy} asks before this write.'), GATE_WARNED: (VERDICT_WARN, message or f'Chock policy {policy} warns about this write.'), GATE_ERRORED: (VERDICT_DENY, f'chock could not check this write: {message}. Refusing rather than reporting an allow it never established.')} + return spoken.get(outcome) + +OUTSIDE_KEY = 'outside_repo' + +_UP = '..' + +_DRIVE_AT = 1 + +_SEPARATORS = ('/', '\\') + +def _slashed(path): + return str(path).replace('\\', '/') + +def is_outside(path): + """Whether a path as the runtime names it lies outside the repository: absolute, or climbing out.""" + text = _slashed(path) + return text.startswith(('/', _UP + '/')) or text[_DRIVE_AT:_DRIVE_AT + 1] == ':' or text == _UP + +def expand_home(pattern): + """`~` (alone or before a separator) as this machine's home directory; anything else as given.""" + text = str(pattern) + if text[:1] == '~' and (len(text) == 1 or text[1] in _SEPARATORS): + return _chock_os.path.expanduser('~') + text[1:] + return text + +def _outside_fold(path): + """`path` with `.` and `..` folded lexically and doubled slashes dropped; a root's leading slash kept.""" + parts = [] + for part in _slashed(path).split('/'): + if part == _UP and parts and (parts[-1] not in ('', _UP)): + parts.pop() + elif part != '.' and (part or not parts): + parts.append(part) + return '/'.join(parts) + +def declared_outside(path, root, patterns, *, windows=None): + """The absolute, slash-separated path when it is outside `root` and matches a declared glob; else None.""" + windows = _chock_os.name == 'nt' if windows is None else windows + text = _slashed(path) + if not is_outside(text) or not patterns: + return None + absolute = text if not text.startswith(_UP) else _slashed(root) + '/' + text + absolute = _outside_fold(absolute) + subject = absolute.lower() if windows else absolute + for pattern in patterns: + wanted = _outside_fold(expand_home(pattern)) + if _chock_fnmatch.fnmatchcase(subject, wanted.lower() if windows else wanted): + return absolute + return None + +def outside_globs(gate): + """The `outside_repo` globs the compiled gate declares; none when it declares none or cannot be read.""" + try: + declared = json.loads(gate.read_text(encoding='utf-8')).get(OUTSIDE_KEY) + except (OSError, ValueError, AttributeError): + return [] + return [g for g in declared if isinstance(g, str)] if isinstance(declared, list) else [] + +def judged_files(files, root, patterns, repo_names): + """`files` ({path: text}) keyed the way a gate judges them. + + A path inside the repository is named by `repo_names(path)`; one outside is kept, under its + absolute path, only when a declared glob matches it. + """ + kept = {} + for path, text in files.items(): + for name in repo_names(path): + if not is_outside(name): + kept[name] = text + continue + found = declared_outside(name, root, patterns) + if found is not None: + kept[found] = text + return kept + +SESSION_MAX_ENTRIES = 500 + +SESSION_PRUNE_SECONDS = 7 * 24 * 3600 + +SESSION_FIELD_CAP = 300 + +SESSION_TRIM_BYTES = 131072 + +SESSION_DEDUPE_TAIL = 8 + +SESSION_STATE_PARTS = ('.chock', 'state') + +SESSION_INPUT_KEYS = ('tool_input', 'toolInput', 'toolArgs', 'input') + +SESSION_PATH_KEYS = ('file_path', 'filePath', 'path', 'notebook_path', 'target_file', 'TargetFile') + +SESSION_URL_KEYS = ('url', 'uri') + +SESSION_RESPONSE_KEYS = ('tool_response', 'toolResponse', 'tool_result', 'toolResult') + +SESSION_UNKNOWN_ID = 'unknown' + +SESSION_ID_MAX = 128 + +SESSION_MASK = '***' + +def tool_call_input(event): + """The tool's arguments as a dict, decoding the JSON-string form some vendors send; {} when absent.""" + raw = getattr(event, 'raw', None) + if not isinstance(raw, dict): + return {} + for key in SESSION_INPUT_KEYS: + value = raw.get(key) + if isinstance(value, str) and value[:1] == '{': + try: + value = json.loads(value) + except ValueError: + continue + if isinstance(value, dict): + return value + return {} + +def session_id_of(event): + """The session id as a safe file stem: separators and leading dots removed, never empty.""" + raw = getattr(event, 'raw', None) or {} + given = getattr(event, 'session_id', None) or (raw.get('session_id') if isinstance(raw, dict) else None) + stem = ''.join((c for c in str(given or '') if c.isalnum() or c in '._-')).lstrip('.') + return stem[:SESSION_ID_MAX] or SESSION_UNKNOWN_ID + +def session_log_path(root, session_id): + return _chock_Path(root).joinpath(*SESSION_STATE_PARTS, session_id + '.jsonl') + +def session_for(event, root): + """What a script gate receives as `session`: the id, the log's absolute path, this call's id.""" + session_id = session_id_of(event) + return {'id': session_id, 'log_path': session_log_path(root, session_id).as_posix(), 'tool_use_id': getattr(event, 'tool_use_id', None)} + +def _mask_assignments(command): + """`command` with the value of every `NAME=value` word masked: an env prefix is a secret's usual home.""" + words = [] + for word in command.split(' '): + name, eq, _ = word.partition('=') + words.append(name + eq + SESSION_MASK if eq and name.isidentifier() else word) + return ' '.join(words) + +def _bare_url(url): + """`url` without userinfo, query and fragment: where a token or a secret is usually carried.""" + url = url.split('#', 1)[0].split('?', 1)[0] + scheme, sep, rest = url.partition('://') + if not sep: + return url + authority, slash, tail = rest.partition('/') + return scheme + sep + authority.rsplit('@', 1)[-1] + slash + tail + +def _first_str(mapping, keys): + for key in keys: + value = mapping.get(key) + if isinstance(value, str) and value: + return value + return None + +def session_summary(event): + """The input of a tool call as {path?, command?, url?}: what a policy asks about, nothing else.""" + tool_input = tool_call_input(event) + path = getattr(event, 'path', None) or _first_str(tool_input, SESSION_PATH_KEYS) + command = getattr(event, 'command', None) or _first_str(tool_input, ('command',)) + url = _first_str(tool_input, SESSION_URL_KEYS) + summary = {} + if isinstance(path, str) and path: + summary['path'] = path[:SESSION_FIELD_CAP] + if isinstance(command, str) and command: + summary['command'] = _mask_assignments(command.splitlines()[0] if command.strip() else '')[:SESSION_FIELD_CAP] + if url: + summary['url'] = _bare_url(url)[:SESSION_FIELD_CAP] + return summary + +def _response_failed(response): + if not isinstance(response, dict): + return False + if response.get('is_error') is True or response.get('isError') is True or response.get('success') is False: + return True + code = response.get('exit_code', response.get('exitCode')) + return bool(response.get('error')) or (isinstance(code, int) and code != 0) + +def session_outcome(event): + """`ok` or `error` for a finished call, from what the payload says; None before it ran.""" + if event.event == 'tool_failure': + return 'error' + if event.event != 'post_tool': + return None + raw = getattr(event, 'raw', None) or {} + failed = any((_response_failed(raw.get(key)) for key in SESSION_RESPONSE_KEYS)) if isinstance(raw, dict) else False + return 'error' if failed else 'ok' + +def _same_call(line, record): + """Whether `line` is `record` already logged: same call id, or, with none, the same second and input.""" + try: + seen = json.loads(line) + except ValueError: + return False + if seen.get('phase') != record['phase']: + return False + if record['tool_use_id']: + return seen.get('tool_use_id') == record['tool_use_id'] + return not seen.get('tool_use_id') and all((seen.get(key) == record[key] for key in ('ts', 'tool', 'input', 'outcome'))) + +def _prune_old(directory): + cutoff = _chock_datetime.now(_chock_timezone.utc).timestamp() - SESSION_PRUNE_SECONDS + for old in directory.glob('*.jsonl'): + try: + if old.stat().st_mtime < cutoff: + old.unlink() + except OSError: + continue + +def _append_bounded(path, line, record): + lines = path.read_text(encoding='utf-8', errors='replace').splitlines() if path.exists() else [] + if any((_same_call(seen, record) for seen in lines[-SESSION_DEDUPE_TAIL:])): + return + if len(lines) < SESSION_MAX_ENTRIES and path.exists() and (path.stat().st_size < SESSION_TRIM_BYTES): + with path.open('a', encoding='utf-8') as fh: + fh.write(line + '\n') + return + kept = [*lines, line][-SESSION_MAX_ENTRIES:] + scratch = path.with_suffix('.tmp') + scratch.write_text('\n'.join(kept) + '\n', encoding='utf-8') + scratch.replace(path) + +def session_record(root, event, phase, outcome=None): + """Append one record for this call to its session's log. Best effort: never raises.""" + try: + path = session_log_path(root, session_id_of(event)) + path.parent.mkdir(parents=True, exist_ok=True) + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'session_id': path.stem, 'phase': phase, 'tool': str(getattr(event, 'tool', None) or ''), 'input': session_summary(event), 'outcome': outcome, 'tool_use_id': getattr(event, 'tool_use_id', None)} + _append_bounded(path, json.dumps(record, ensure_ascii=False, sort_keys=True), record) + _prune_old(path.parent) + except Exception: + return + +REENTRY_CAP = 3 + +STOP_LEDGER_SUFFIX = '.stop.jsonl' + +GATE_LOG_PARTS = ('.chock', 'log', 'gate-events.jsonl') + +STOP_MODE_MASK = 61440 + +STOP_MODE_FILE = 32768 + +STOP_MODE_DIR = 16384 + +STOP_PHASE = 'stop' + +STOP_TURN_KEYS = ('turn_id', 'generation_id') + +STOP_COUNT_KEY = 'loop_count' + +STOP_DIGEST_CHARS = 16 + +REENTRY_CLEAN = 'clean' + +REENTRY_REFUSED = 'refused' + +REENTRY_CAPPED = 'cap-reached' + +REENTRY_UNTRACKED = 'untracked' + +STOP_LOG_BLOCK = 'block' + +STOP_LOG_ALLOW = 'allow' + +STOP_LOG_WARN = 'warn' + +_STOP_LAST = "\nThis is refusal {cap} of {cap} for this turn's end: the next stop ends the turn with these findings still on disk, and a commit will refuse them. A person must review them." + +_STOP_WARNING = 'chock {policy}: this turn ended with findings still on disk{why}. The turn was allowed to end so the client does not loop; a commit will refuse these findings, and a person must review them.\n' + +_STOP_WHY = {REENTRY_CAPPED: ' after {cap} refused stops', REENTRY_UNTRACKED: ' (the stop record under .chock/state could not be written, so re-entries cannot be counted)'} + +_STOP_SINCE = {True: ' (changed since the last refusal)', False: ' (unchanged since the last refusal)'} + +def reentries(event): + """How many times the client says this stop re-entered its hook: `loop_count`, else 1 for `stop_hook_active`.""" + raw = event.raw if isinstance(event.raw, dict) else {} + try: + counted = int(raw.get(STOP_COUNT_KEY) or 0) + except (TypeError, ValueError): + counted = 1 + return max(counted, 1 if raw.get('stop_hook_active') else 0) + +def _stop_turn(event): + """The vendor's turn id, when the payload carries one; None otherwise.""" + raw = event.raw if isinstance(event.raw, dict) else {} + for key in STOP_TURN_KEYS: + value = raw.get(key) + if isinstance(value, str) and value: + return value[:SESSION_ID_MAX] + return None + +def _stop_digest(*parts): + digest = _chock_hashlib.sha256() + for part in parts: + digest.update(str(part).encode('utf-8', 'replace') + b'\x00') + return digest.hexdigest()[:STOP_DIGEST_CHARS] + +def finding_digests(message, writes): + """One digest per flagged file, of its path and content: never the text itself, never the message's wording.""" + if not message: + return [] + lines = [line.strip().lstrip('-').strip() for line in message.splitlines()] + flagged = [p for p in writes if any((line.startswith(p + ':') for line in lines))] or list(writes) + if not flagged: + return [_stop_digest(message)] + return sorted({_stop_digest(p, writes[p]) for p in flagged}) + +def stop_ledger_path(root, session_id): + return _chock_Path(root).joinpath(*SESSION_STATE_PARTS, session_id + STOP_LEDGER_SUFFIX) + +def stop_ledger_safe(path): + """Whether `path`, its directory and `.chock` are what chock made: real directories and a regular file or none. + + lstat, so a symlink, FIFO, socket, device or directory in any place is refused before anything opens it: + opening a FIFO blocks the hook until its timeout, which a client may read as an allow. + """ + for target, kind in ((path, STOP_MODE_FILE), (path.parent, STOP_MODE_DIR), (path.parent.parent, STOP_MODE_DIR)): + try: + mode = _chock_os.lstat(target).st_mode + except FileNotFoundError: + continue + except OSError: + return False + if mode & STOP_MODE_MASK != kind: + return False + return True + +def _stop_lines(path): + """The ledger's lines; none when it is missing, unsafe or cannot be read.""" + if not stop_ledger_safe(path): + return [] + try: + fd = _chock_os.open(path, _chock_os.O_RDONLY | getattr(_chock_os, 'O_NOFOLLOW', 0) | getattr(_chock_os, 'O_NONBLOCK', 0)) + with _chock_os.fdopen(fd, encoding='utf-8', errors='replace') as fh: + return fh.read().splitlines() + except OSError: + return [] + +def _stop_parse(line): + try: + return json.loads(line) + except (ValueError, RecursionError): + return None + +def _stop_well_formed(seen, session_id): + stamp, index = (seen.get('ts'), seen.get('reentry')) + return isinstance(stamp, str) and bool(stamp) and (seen.get('session_id') == session_id) and (type(index) is int) and (index >= 0) + +def _stop_chain(lines, session_id, policy, turn): + """This turn's records, newest first, counting down without a gap to its first stop; None when broken.""" + chain = [] + for line in reversed(lines): + seen = _stop_parse(line) + if not isinstance(seen, dict) or seen.get('phase') != STOP_PHASE or seen.get('policy') != policy: + continue + if turn is not None and seen.get('turn') != turn: + continue + if not _stop_well_formed(seen, session_id): + return None + if chain and seen['reentry'] != chain[-1]['reentry'] - 1: + return None + chain.append(seen) + if seen['reentry'] == 0 or seen.get('anchor') is True: + return chain + return None + +def _stop_append(path, lines, record): + """Append one record, keeping the last `SESSION_MAX_ENTRIES`; whether it landed.""" + line = json.dumps(record, sort_keys=True) + if not stop_ledger_safe(path): + return False + try: + path.parent.mkdir(parents=True, exist_ok=True) + if len(lines) < SESSION_MAX_ENTRIES and (not path.exists() or path.stat().st_size < SESSION_TRIM_BYTES): + flags = _chock_os.O_WRONLY | _chock_os.O_APPEND | _chock_os.O_CREAT | getattr(_chock_os, 'O_NOFOLLOW', 0) | getattr(_chock_os, 'O_NONBLOCK', 0) + with _chock_os.fdopen(_chock_os.open(path, flags, 384), 'a', encoding='utf-8') as fh: + fh.write(line + '\n') + else: + scratch = path.with_name('%s.%d.tmp' % (path.name, _chock_os.getpid())) + scratch.write_text('\n'.join([*lines[-(SESSION_MAX_ENTRIES - 1):], line]) + '\n', encoding='utf-8') + scratch.replace(path) + _prune_old(path.parent) + except OSError: + return False + return True + +def _stop_gate_log(root, policy, verdict, reentry, findings): + """One gate-log record per re-entry; a warn is held (`would_block`), so it is kept even with the log off. + + `reentry` is (index, reentry_verdict, findings_changed or None). + """ + try: + if not stop_ledger_safe(_chock_Path(root).joinpath(*GATE_LOG_PARTS)): + return + held = verdict == STOP_LOG_WARN + if _chock_os.environ.get(GATE_LOG_ENV) == '0' and (not held): + return + index, reentry_verdict, changed = reentry + record = {'policy_id': policy, 'surface': 'stop-reentry', 'event': 'stop', 'kind': 'reentry', 'reentry': index, 'verdict': verdict, 'reentry_verdict': reentry_verdict, 'match_count': len(findings), 'matches': []} + if held: + record.update({'would_action': STOP_LOG_BLOCK, 'would_block': True}) + if changed is not None: + record['findings_changed'] = changed + append_gate_log(_chock_Path(root).joinpath(SESSION_STATE_PARTS[0]), record) + except Exception: + return + +def _stop_warn(policy, why, changed, message): + """The warning a turn ends with: said on stderr for every client, and returned for a `systemMessage`.""" + text = _STOP_WARNING.format(policy=policy, why=why.format(cap=REENTRY_CAP) + _STOP_SINCE.get(changed, '')) + text += message + with contextlib.suppress(Exception): + sys.stderr.write(text + '\n') + sys.stderr.flush() + return (VERDICT_WARN, text) + +def _stop_changed(chain, findings): + """Whether the findings differ from the last refusal's; None when there is no refusal to compare with.""" + refused = next((r for r in chain or () if r.get('verdict') == STOP_LOG_BLOCK), None) + if refused is None or not isinstance(refused.get('findings'), list): + return None + return sorted(refused['findings']) != findings + +def settle_stop(event, root, gate, decision, writes=None): + """The decision a Stop earns once this turn's earlier Stops are weighed against `decision`.""" + if decision is not None and decision[0] != VERDICT_DENY: + return decision + policy = gate.parent.parent.name + session_id = session_id_of(event) + path = stop_ledger_path(root, session_id) + lines = _stop_lines(path) + message = decision[1] if decision else '' + findings = finding_digests(message, writes or {}) + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'session_id': session_id, 'phase': STOP_PHASE, 'policy': policy, 'turn': _stop_turn(event), 'findings': findings} + signalled = reentries(event) + if not signalled: + _stop_append(path, lines, {**record, 'reentry': 0, 'verdict': STOP_LOG_BLOCK if decision else STOP_LOG_ALLOW}) + return decision + chain = _stop_chain(lines, session_id, policy, record['turn']) + counted = isinstance(event.raw, dict) and STOP_COUNT_KEY in event.raw + index = signalled if counted else chain[0]['reentry'] + 1 if chain else 1 + capped = index > REENTRY_CAP + verdict = STOP_LOG_ALLOW if not decision else STOP_LOG_WARN if capped else STOP_LOG_BLOCK + landed = _stop_append(path, lines, {**record, 'reentry': index, 'verdict': verdict, 'anchor': chain is None}) + changed = _stop_changed(chain, findings) + if decision is None: + _stop_gate_log(root, policy, verdict, (index, REENTRY_CLEAN, None), findings) + return None + if capped: + _stop_gate_log(root, policy, verdict, (index, REENTRY_CAPPED, changed), findings) + return _stop_warn(policy, _STOP_WHY[REENTRY_CAPPED], changed, message) + if not landed and (not counted): + _stop_gate_log(root, policy, STOP_LOG_WARN, (index, REENTRY_UNTRACKED, None), findings) + return _stop_warn(policy, _STOP_WHY[REENTRY_UNTRACKED], None, message) + tracked = REENTRY_REFUSED if chain and landed else REENTRY_UNTRACKED + _stop_gate_log(root, policy, verdict, (index, tracked, changed), findings) + return (VERDICT_DENY, message + (_STOP_LAST.format(cap=REENTRY_CAP) if index == REENTRY_CAP else '')) + +UNREADABLE_STOP_LEDGER = 'unreadable-stop.jsonl' + +UNREADABLE_STOP_PHASE = 'unreadable-stop' + +UNREADABLE_STOP_WINDOW_SECONDS = 600 + +REENTRY_UNREADABLE = 'unreadable' + +_UNREADABLE_STOP_WARNING = 'chock {policy}: {cap} stops in a row had a payload chock could not read, so none of them was judged. The turn was allowed to end so the client does not loop; a commit will judge what is on disk, and a person must look at why the payload is unreadable.\n' + +_UNREADABLE_STOP_LAST = '\nThis is refusal {cap} of {cap} for stops chock cannot read: the next one ends the turn unchecked, and a commit will judge what is on disk. A person must look at why the payload is unreadable.' + +def _unreadable_stop_recent(lines, now): + """The refusals recorded inside the window; a record that does not parse, or has no time, counts as none.""" + kept = [] + for line in lines: + seen = _stop_parse(line) + stamp = seen.get('at') if isinstance(seen, dict) else None + refused = isinstance(seen, dict) and seen.get('phase') == UNREADABLE_STOP_PHASE + if refused and seen.get('verdict') == STOP_LOG_BLOCK and (type(stamp) in (int, float)) and (0 <= now - stamp < UNREADABLE_STOP_WINDOW_SECONDS): + kept.append(stamp) + return kept + +def settle_unreadable_stop(root, policy, refusal): + """(verdict, text) this unreadable Stop earns: a refusal with `refusal`, or a warning once the cap is spent. + + A ledger that is not a regular file in real directories, or cannot be written, keeps refusing: with nothing to count, only a refusal is safe. + """ + path = _chock_Path(root).joinpath(*SESSION_STATE_PARTS, UNREADABLE_STOP_LEDGER) + if not stop_ledger_safe(path): + return (VERDICT_DENY, refusal) + lines = _stop_lines(path) + now = _chock_datetime.now(_chock_timezone.utc).timestamp() + index = len(_unreadable_stop_recent(lines, now)) + 1 + capped = index > REENTRY_CAP + verdict = STOP_LOG_WARN if capped else STOP_LOG_BLOCK + _stop_append(path, lines, {'phase': UNREADABLE_STOP_PHASE, 'verdict': verdict, 'at': now, 'reentry': index}) + _stop_gate_log(root, policy, verdict, (index, REENTRY_UNREADABLE, None), []) + if capped: + text = _UNREADABLE_STOP_WARNING.format(policy=policy, cap=REENTRY_CAP) + with contextlib.suppress(Exception): + sys.stderr.write(text) + sys.stderr.flush() + return (VERDICT_WARN, text) + return (VERDICT_DENY, refusal + (_UNREADABLE_STOP_LAST.format(cap=REENTRY_CAP) if index == REENTRY_CAP else '')) + +_PARENT = '..' + +_DRIVE_COLON = ':' + +_ROOTS_KEY = 'workspace_roots' + +def _folded(pure): + """`pure` with `..` folded lexically; an absolute path never climbs above its anchor.""" + parts = [] + for part in pure.parts: + if part != _PARENT: + parts.append(part) + elif len(parts) > 1: + parts.pop() + return type(pure)(*parts) + +def _workspace_root(text): + """A workspace root as a path: `/C:/x` is `C:/x`; anything else as given.""" + if text[:1] == '/' and text[2:3] == _DRIVE_COLON and text[1:2].isalpha() and (text[3:4] in ('', '/', '\\')): + return text[1:] + return text + +def _holds(root, path): + """Whether `path`, absolute or relative, lies under `root`, compared the way `root` is spelled.""" + flavour = _chock_PureWindowsPath if root[1:2] == _DRIVE_COLON else _chock_PurePosixPath + try: + _folded(flavour(root) / str(path)).relative_to(flavour(root)) + except ValueError: + return False + return True + +def workspace_roots(event): + """The folders the client says the agent works in, normalised; [] when it names none.""" + raw = getattr(event, 'raw', None) + roots = raw.get(_ROOTS_KEY) if isinstance(raw, dict) else None + if not isinstance(roots, list): + return [] + return [_workspace_root(root) for root in roots if isinstance(root, str) and root] + +def workspace_root(event): + """The workspace root holding the event's path, else the first one; None when the client names none.""" + roots = workspace_roots(event) + path = getattr(event, 'path', None) + return next((root for root in roots if path and _holds(root, path)), roots[0] if roots else None) + +_SEVERITY = (VERDICT_DENY, VERDICT_ESCALATE, VERDICT_WARN) + +_GIT_DIR = '.git' + +def _repository(folder): + """The repository `folder` is in: the nearest folder holding `.git`, or None outside one.""" + here = _chock_Path(folder).absolute() + return next((f for f in (here, *here.parents) if (f / _GIT_DIR).exists()), None) + +def stop_roots(event, fallback): + """The event's `cwd` and every workspace root inside a repository, the first per repository; else [fallback].""" + by_repository = {} + for named in filter(None, (getattr(event, 'cwd', None), *workspace_roots(event))): + repository = _repository(named) + if repository is not None: + by_repository.setdefault(repository, _chock_Path(named)) + return list(by_repository.values()) or [fallback] + +def per_root(said): + """One (decision, judged files) for each root's own: the most severe verdict, findings under each root's name.""" + if len(said) == 1: + return said[0][1:] + spoken = [(root, decision) for root, decision, _judged in said if decision] + judged = {path: text for _root, _decision, files in said for path, text in files.items()} + if not spoken: + return (None, judged) + verdict = min((decision[0] for _root, decision in spoken), key=_SEVERITY.index) + return ((verdict, '\n'.join((f'In {root}:\n{decision[1]}' for root, decision in spoken))), judged) + +GATE_FLAG = '--gate' + +STOP_FLAG = '--stop' + +_GATE_DEPTH_TO_CHOCK = 3 + +_RUNNER_PARTS = ('bin', 'gate.py') + +_PACKAGED_RUNNER = 'gate.py' + +_GIT = 'git' + +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + +_DELETED = 'D' + +_RENAMED = 'R' + +VERDICT_DENY = 'deny' + +def gate_path_from_argv(argv): + """The `--gate ` argument a vendored runtime was invoked with, or None.""" + if GATE_FLAG in argv: + index = argv.index(GATE_FLAG) + if index + 1 < len(argv): + return _chock_Path(argv[index + 1]) + return None + +def runner_for(gate): + """The gate runner: beside the gate in a plugin, under .chock/bin in a repository, else None.""" + packaged = gate.resolve().parent / _PACKAGED_RUNNER + if packaged.exists(): + return packaged + parents = gate.resolve().parents + if len(parents) <= _GATE_DEPTH_TO_CHOCK: + return None + runner = parents[_GATE_DEPTH_TO_CHOCK].joinpath(*_RUNNER_PARTS) + return runner if runner.exists() else None + +def writes_from_event(event, root=None): + """The one file this tool call would write, or none when it carries no file text. + + An edit call carries only the text it inserts; judged alone, that fragment has no imports, + no class and no neighbours, so a gate that reads a whole file (a script kind) finds nothing + in it. An edit is therefore judged as the file it would leave: the file on disk with the + call's replacements applied. When that cannot be rebuilt -- the file is unreadable, or the + text to replace is not in it, so the call itself will fail -- the fragment is judged as + before and the turn's end judges what actually landed. A Codex apply_patch call names its + files only inside the patch; each one it adds or updates is judged as the patch leaves it. + """ + patched = patched_files(event, root) + if patched: + return patched + path = getattr(event, 'path', None) + content = created_text(event) + edited = edited_text(event, root) + if path and edited is not None: + return {str(path): edited} + if not path or not isinstance(content, str): + return {} + return {str(path): content} + +def repo_relative(path, root): + """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" + text = str(path) + if root is None: + return text + windows = _chock_os.name == 'nt' or str(root)[1:2] == _DRIVE_COLON + flavour = _chock_PureWindowsPath if windows else _chock_PurePosixPath + try: + return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() + except ValueError: + pass + if windows != (_chock_os.name == 'nt'): + return text + try: + return _chock_Path(root, text).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return text + +def repo_paths(path, root): + """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" + lexical = repo_relative(path, root) + if root is None or (str(root)[1:2] == _DRIVE_COLON) != (_chock_os.name == 'nt'): + return (lexical,) + try: + resolved = _chock_Path(root, str(path)).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return (lexical,) + return tuple(dict.fromkeys((lexical, resolved))) + +def changed_paths(repo_root, deadline=None, why=None): + """Every uncommitted path; None (reason appended to `why`) when git gave no answer, [] only outside a repository.""" + deadline, why = (engine_deadline() if deadline is None else deadline, [] if why is None else why) + argv = [_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'] + try: + timeout = time_left(deadline, argv) + proc = _chock_subprocess.run(argv, capture_output=True, text=True, encoding=_UTF8, errors=_PATH_ERRORS, timeout=timeout, check=False) + except _chock_subprocess.TimeoutExpired as exc: + why.append(f'git status did not finish within {allowed_seconds(exc)}') + except (OSError, _chock_subprocess.SubprocessError) as exc: + why.append(f'git status could not run ({exc})') + else: + first = ((proc.stderr or '').strip().splitlines() or [''])[0] + if proc.returncode and 'not a git repository' in first: + return [] + if proc.returncode: + why.append(f'git status failed (exit {proc.returncode}): {first}') + if why: + sys.stderr.write(f'chock: {why[-1]}, worktree not checked\n') + return None + fields = iter([field for field in (proc.stdout or '').split('\x00') if field]) + paths = [] + for field in fields: + status, path = (field[:2], field[3:]) + if status.startswith(_RENAMED): + next(fields, None) + if _DELETED not in status and path: + paths.append(path) + return paths + +def writes_from_worktree(repo_root, deadline=None, why=None): + """What this turn actually left on disk, however it was written; None when it could not be listed. + + The write path sees only writes it recognises; a shell heredoc carries no file argument. + Reading final state is what makes that stop mattering, so this deliberately does not care + which tool produced the bytes. + """ + paths = changed_paths(repo_root, deadline, why) + if paths is None: + return None + writes = {} + for path in paths: + try: + writes[path] = _chock_Path(repo_root, path).read_text(encoding='utf-8') + except (OSError, UnicodeDecodeError): + continue + return writes + +def run_gate(gate, writes, event, run_in, deadline): + """Ask the vendored runner. Returns (outcome, message) and never decides for itself. + + `run_in` is (root, extra): the working directory, and the stdin keys `extra` adds: `added` (per + edited path, only the text the edit introduces; a runner that predates it judges the whole file, + which only ever refuses more) and the script `session`. The runner gets what is left of `deadline`. + """ + root, extra = run_in + runner = runner_for(gate) + if runner is None: + return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') + argv = [sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event] + try: + proc = _chock_subprocess.run(argv, input=json.dumps({'writes': writes, **(extra or {})}), capture_output=True, text=True, encoding=_UTF8, errors='replace', timeout=time_left(deadline, argv), check=False, cwd=str(root) if root is not None else None) + except _chock_subprocess.TimeoutExpired as exc: + return (GATE_ERRORED, f'the gate runner gave no verdict within {allowed_seconds(exc)}') + except (OSError, _chock_subprocess.SubprocessError) as exc: + return (GATE_ERRORED, str(exc)) + return runner_outcome(proc.returncode, proc.stderr) + +_EVENT_ARG = {'pre_tool': 'pre-tool-use', 'stop': 'stop'} + +PRE_TOOL = 'pre_tool' + +def root_for(gate): + """The repository this compiled gate belongs to, or None when the layout is not that.""" + parents = gate.resolve().parents + if len(parents) <= _GATE_DEPTH_TO_CHOCK: + return None + if parents[_GATE_DEPTH_TO_CHOCK - 1].name != 'compiled' or parents[_GATE_DEPTH_TO_CHOCK].name != '.chock': + return None + return parents[_GATE_DEPTH_TO_CHOCK].parent + +def repo_root_for(event, gate): + """The repository under judgement: the compiled layout's root, else the event's cwd, else its workspace root. + + Of several workspace roots, the one holding the written path; this process's cwd only when there are none. + """ + root = root_for(gate) + if root is not None: + return root + cwd = getattr(event, 'cwd', None) or workspace_root(event) + return _chock_Path(cwd) if cwd else _chock_Path.cwd() + +def writes_for(event, gate, deadline=None, why=None, root=None): + """What this event puts under judgement: the call's own text, or what the turn left behind (None: unlisted).""" + root = repo_root_for(event, gate) if root is None else root + if event.event == PRE_TOOL: + return writes_from_event(event, root) + return writes_from_worktree(root, deadline, why) + +def _missing_gate(gate): + """A gate the hook names but that is not on disk: a broken install, so a refusal that says so.""" + return (VERDICT_DENY, f'chock gate {gate} is missing, so this write cannot be checked. Run `chock sync --repo .` to rebuild the compiled gates.') + +def _gate_says(gate, event, name, deadline, root): + """(decision, judged files): what the compiled gate says about this event in `root`, before a re-entered stop.""" + if not gate.exists(): + return (_missing_gate(gate), {}) + outside = outside_globs(gate) + why = [] + listed = writes_for(event, gate, deadline, why, root) + if listed is None: + return (gate_decision(GATE_ERRORED, why[-1] if why else 'git status gave no answer', gate), {}) + writes = judged_files(listed, root, outside, lambda path: repo_paths(path, root)) + if not writes: + return (None, writes) + added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} + added = judged_files(added, root, outside, lambda path: repo_paths(path, root)) + added = {path: text for path, text in added.items() if path in writes} + extra = {**({'added': added} if added else {}), 'session': session_for(event, root)} + outcome, message = run_gate(gate, writes, name, (root, extra), deadline) + return (gate_decision(outcome, message, gate), writes) + +def evaluate_gate(argv, event): + """The decision this event earns from a compiled gate, or None when it has nothing to say.""" + gate = gate_path_from_argv(argv) + name = _EVENT_ARG.get(getattr(event, 'event', '')) + if gate is None or name is None: + return None + deadline = engine_deadline() + if event.event == PRE_TOOL: + return _gate_says(gate, event, name, deadline, repo_root_for(event, gate))[0] + roots = [repo_root_for(event, gate)] if root_for(gate) else stop_roots(event, repo_root_for(event, gate)) + decision, judged = per_root([(root, *_gate_says(gate, event, name, deadline, root)) for root in roots]) + return settle_stop(event, roots[0], gate, decision, judged) + +TOOL_CALL_FLAG = '--tool-call' + +RECORD_FLAG = '--record' + +TOOL_CALL_EVENT = 'tool_call' + +_TOOL_CALL_PRE = 'pre_tool' + +_TOOL_CALL_POST = ('post_tool', 'tool_failure') + +_TOOL_CALL_SCRIPT_KIND = 'script' + +_TOOL_CALL_REGEX_KIND = 'content_regex' + +_TOOL_CALL_BLOCKED = 'blocked' + +_TOOL_CALL_NEEDS_SESSION = frozenset({_TOOL_CALL_SCRIPT_KIND}) + +_TOOL_CALL_EXIT_VERDICTS = {0: None, 1: 'deny', 3: 'escalate', 4: 'warn'} + +_TOOL_CALL_CEILING = {'block': 'deny', 'ask': 'escalate', 'warn': 'warn'} + +_TOOL_CALL_RANK = {'warn': 0, 'escalate': 1, 'deny': 2} + +_TOOL_CALL_UNDECIDED = ' -- refusing rather than allowing what it never judged' + +def _flag_path(argv, flag): + if flag in argv: + index = argv.index(flag) + if index + 1 < len(argv): + return _chock_Path(argv[index + 1]) + return None + +def tool_call_matches(globs, tool): + """Whether `tool` (a tool name, exact case) matches any glob; no globs match nothing.""" + return any((_chock_fnmatch.fnmatchcase(tool, str(glob)) for glob in globs)) + +def _tool_call_refusal(spec, spoken): + return spoken or str(spec.get('message') or '').strip() or 'blocked by a chock tool_call gate' + +def _tool_call_regex(spec, tool_input): + """`deny` verdict when the input, as JSON text, matches the gate's `content_pattern`.""" + text = json.dumps(tool_input, sort_keys=True, ensure_ascii=False) + if _chock_re.search(str((spec.get('params') or {}).get('content_pattern', '')), text): + return ('deny', _tool_call_refusal(spec, '')) + return None + +def _tool_call_script(spec, root, payload, deadline=None): + """Run the policy's script on `payload`; a missing script, crash or timeout refuses.""" + named = str((spec.get('params') or {}).get('script', '')) + script = _chock_Path(root) / named + if not script.is_file(): + return ('deny', f'script gate: {named!r} is not installed{_TOOL_CALL_UNDECIDED}') + deadline = engine_deadline() if deadline is None else deadline + argv = [sys.executable, str(script)] + try: + proc = _chock_subprocess.run(argv, input=json.dumps(payload), capture_output=True, text=True, encoding='utf-8', errors='replace', cwd=str(root), timeout=time_left(deadline, argv), check=False) + except _chock_subprocess.TimeoutExpired as exc: + budget = f'gave no verdict within {allowed_seconds(exc)}' + return ('deny', f'script gate: {script.name} {budget}{_TOOL_CALL_UNDECIDED}') + except OSError as exc: + return ('deny', f'script gate: {script.name} could not run ({exc}){_TOOL_CALL_UNDECIDED}') + spoken = ((proc.stderr or '') + (proc.stdout or '')).strip() + if proc.returncode in _TOOL_CALL_EXIT_VERDICTS: + verdict = _TOOL_CALL_EXIT_VERDICTS[proc.returncode] + return None if verdict is None else (verdict, spoken or f'blocked by {script.name}') + first = spoken.splitlines()[0] if spoken else '' + detail = f': {first}' if first else '' + return ('deny', f'script gate: {script.name} exited {proc.returncode}{detail}{_TOOL_CALL_UNDECIDED}') + +def _tool_call_verdict(spec, root, event, deadline): + """None to allow, else (verdict, message) for this call; a kind a tool call cannot answer refuses.""" + tool, tool_input = (str(event.tool or ''), tool_call_input(event)) + kind = spec.get('kind') + if kind == _TOOL_CALL_REGEX_KIND: + return _tool_call_regex(spec, tool_input) + if kind == _TOOL_CALL_SCRIPT_KIND: + payload = {'event': TOOL_CALL_EVENT, 'repo_root': str(root), 'tool': tool, 'input': tool_input, 'session': session_for(event, root)} + return _tool_call_script(spec, root, payload, deadline) + return ('deny', f'chock tool_call gate: kind {kind!r} cannot judge a tool call{_TOOL_CALL_UNDECIDED}') + +def _tool_call_capped(spec, verdict): + """`verdict` held to the gate's declared action; an unknown action keeps the verdict as spoken.""" + ceiling = _TOOL_CALL_CEILING.get(spec.get('action', 'block')) + if verdict is None or ceiling is None: + return verdict + return (min(verdict[0], ceiling, key=_TOOL_CALL_RANK.__getitem__), verdict[1]) + +def _tool_call_spec(gate): + """The compiled gate as a dict, or (None, refusal) when the install is broken.""" + if not gate.exists(): + return (None, f'chock gate {gate} is missing, so this tool call cannot be checked. Run `chock sync --repo .` to rebuild the compiled gates.') + try: + return (json.loads(gate.read_text(encoding='utf-8')), None) + except (OSError, ValueError) as exc: + return (None, f'chock could not read {gate}: {exc}. Refusing rather than reporting an allow it never established.') + +def evaluate_tool_call(argv, event): + """The decision a tool_call gate earns this call, or None; also logs the call for the session. + + `--tool-call ` judges a PreToolUse call and, for a gate whose check reads the session, + logs it after the verdict, so the script reads prior calls only. `--record ` logs what + a call came to, after it ran (a vendor's PostToolUse and failure events). + """ + recording = _flag_path(argv, RECORD_FLAG) + if recording is not None: + if event.event in _TOOL_CALL_POST: + session_record(repo_root_for(event, recording), event, 'post', session_outcome(event)) + return None + gate = _flag_path(argv, TOOL_CALL_FLAG) + if gate is None or event.event != _TOOL_CALL_PRE: + return None + spec, broken = _tool_call_spec(gate) + if spec is None: + return ('deny', broken) + root = repo_root_for(event, gate) + verdict = None + if TOOL_CALL_EVENT in spec.get('on', []) and tool_call_matches((spec.get('params') or {}).get('tools', []), str(event.tool or '')): + verdict = _tool_call_capped(spec, _tool_call_verdict(spec, root, event, engine_deadline())) + if spec.get('kind') in _TOOL_CALL_NEEDS_SESSION: + session_record(root, event, 'pre', _TOOL_CALL_BLOCKED if verdict and verdict[0] == 'deny' else None) + return verdict +_CHOCK_UNREADABLE = ( + "chock could not read this hook's payload, so it could not check the call. " + "Refusing rather than reporting an allow it never established." +) + +#: A payload naming only this vendor's own wire event, so `respond` picks that event's grammar. +_CHOCK_WIRE_RAW = {PRE_TOOL: {'event': 'PreToolUse'}, STOP: {'event': 'Stop'}} + +#: The words a client reads as a refusal, in any of the answer fields a vendor grammar uses. +_CHOCK_REFUSING = ("deny", "block", "ask", "continue") +_CHOCK_ANSWER_KEYS = ("decision", "permission", "permissionDecision") + + +def _chock_spec_action(path): + try: + action = json.loads(path.read_text(encoding="utf-8")).get("action") + except Exception: + return "block" + return action if action in ("ask", "warn") else "block" + + +def _chock_declared(): + """(strictest action, event, gate) of the judging flags; (None, None, None) where this run judges nothing.""" + argv = sys.argv[1:] + actions = ["block"] if guard_path_from_argv(argv) is not None else [] + event = PRE_TOOL + gate = _flag_path(argv, GATE_FLAG) + if gate is not None: + actions.append(_chock_spec_action(gate)) + event = STOP if STOP_FLAG in argv or gate.parent.name == _EVENT_ARG[STOP] else PRE_TOOL + called = _flag_path(argv, TOOL_CALL_FLAG) + if called is not None: + actions.append(_chock_spec_action(called)) + for action in ("block", "ask", "warn"): + if action in actions: + return action, event, gate + return None, None, None + + +def _chock_refuses(text, code): + if code == 2: + return True + try: + body = json.loads(text) + except Exception: + return False + if not isinstance(body, dict): + return False + nested = body.get("hookSpecificOutput") + answers = [body] + ([nested] if isinstance(nested, dict) else []) + return any(part.get(key) in _CHOCK_REFUSING for part in answers for key in _CHOCK_ANSWER_KEYS) + + +def _chock_unreadable_stop(gate): + """The bounded verdict of an unreadable Stop: a refusal, or a warning once the cap is spent.""" + root = root_for(gate) or _chock_Path.cwd() + return settle_unreadable_stop(root, gate.parent.parent.name, _CHOCK_UNREADABLE) + + +def _chock_unreadable(): + action, kind, gate = _chock_declared() + if action in (None, "warn"): + return "", 0 + event = Event(AGENT, kind, raw=dict(_CHOCK_WIRE_RAW[kind])) + reason = _CHOCK_UNREADABLE + if kind == STOP and gate is not None: + verdict, reason = _chock_unreadable_stop(gate) + if verdict == VERDICT_WARN: + return respond(degrade(Decision.warn(reason), event), event) + _report(reason + "\n") + decision = Decision.escalate(reason) if action == "ask" else Decision.deny(reason) + text, code = respond(degrade(decision, event), event) + return (text, code) if _chock_refuses(text, code) else ("", 2) +#: Per wire event mapped to pre_tool: whether the client shows a guard's ask as a prompt. +_CHOCK_CAN_ASK = {'PreToolUse': False} +_CHOCK_EVENT_KEYS = ('event', 'hook_event_name', 'hookEventName', 'agent_action_name') + + +def _chock_wire_name(raw): + names = (raw.get(key) for key in _CHOCK_EVENT_KEYS) if isinstance(raw, dict) else () + return next((name for name in names if isinstance(name, str)), None) + + +def _chock_can_ask(raw): + # An event this bundle cannot name, or one the vendor data does not list, cannot prompt. + return _CHOCK_CAN_ASK.get(_chock_wire_name(raw), False) + + +def _spoken(verdict): + outcome, reason = verdict + if outcome == VERDICT_WARN: + return Decision.warn(reason) + return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + + +def _judge(event): + if event.event == "pre_tool" and event.command: + verdict = evaluate(sys.argv[1:], event.command, event.tool or "", can_ask=_chock_can_ask(event.raw)) + if verdict is not None: + outcome, reason = verdict + return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + gated = evaluate_gate(sys.argv[1:], event) + if gated is not None: + return _spoken(gated) + called = evaluate_tool_call(sys.argv[1:], event) + if called is not None: + return _spoken(called) + return None + + +def handle(event): + # A door that cannot decide refuses. An exception escaping here would exit the hook + # with a traceback, which every client reads as a non-blocking error: fail-open, with + # the reason on a stderr nobody watches. The refusal carries the reason instead. + try: + return _judge(event) + except Exception as exc: # noqa: BLE001 -- any failure here must refuse, never fall through + return Decision.deny( + "chock could not check this call (%s: %s). Refusing rather than reporting an " + "allow it never established." % (type(exc).__name__, exc) + ) +# <<< 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 _report(text): + # Best effort, never raising: a diagnostic must not pre-empt the refusal it accompanies. + # A hook's stderr is whatever the host gave it -- closed (then sys.stderr is None, and + # the traceback module's default print would land on STDOUT, inside the verdict), a + # console code page that cannot hold the payload text quoted in the exception, a pipe + # nobody reads. + try: + sys.stderr.write(text) + sys.stderr.flush() + except Exception: + return + + +_STDERR_FD = 2 + + +def _divert_fd1(): + """Point fd 1 at stderr (devnull if there is none); the saved fd 1, or None if it could not.""" + try: + saved = os.dup(1) + except OSError: + return None + # With fd 2 closed, dup() hands back 2 itself, and "stderr" would be stdout again. + if saved > _STDERR_FD: + with contextlib.suppress(OSError): + os.dup2(_STDERR_FD, 1) + return saved + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +def _flush(streams): + """Flush each stream that exists; a broken one is no reason to lose the verdict.""" + for stream in streams: + if stream is not None: + with contextlib.suppress(Exception): + stream.flush() + + +@contextlib.contextmanager +def _stdout_to_stderr(): + # Stdout is the verdict channel. A handler's stray print (or a child process it runs) + # ahead of the JSON makes the host fail to parse it, and Claude Code and Gemini CLI then + # treat the hook as a non-blocking error: a deny became an allow, witnessed live. + sink = sys.stderr if sys.stderr is not None else io.StringIO() + held = (sys.stdout, sys.__stdout__) + _flush(held) + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + # A stream the handler held on to (sys.__stdout__, a reference cached at import) + # buffers past redirect_stdout; flushed now it lands on stderr, not after the verdict. + _flush((sink, *held)) + os.dup2(saved, 1) + os.close(saved) + + +def _decide(raw): + """(stdout_text, exit_code) for one decoded payload: agentseam.dispatch.handle(), inlined.""" + event = parse(raw) + 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. + return "", 0 + try: + with _stdout_to_stderr(): + result = handle(event) + decision = _coerce(result) + except Exception: + # A door that cannot decide refuses. Escaping here would exit 1 with a traceback, + # which every host reads as a non-blocking error and allows past; instead the + # failure is printed for the operator and the gate answers deny in 'goose''s + # own dialect. Only the failure's class is named to the host: its message may quote + # the very payload content the policy was inspecting. + _report(traceback.format_exc()) + decision = Decision.deny( + "policy handler failed (%s); refusing rather than allowing what it could not judge" + % sys.exc_info()[0].__name__ + ) + return respond(degrade(decision, event), event) + + +def main(stdin=None, stdout=None, exit=True): + """Read one payload from stdin, dispatch, emit the 'goose' 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: + raw = None + if not isinstance(raw, dict): + text, code = _chock_unreadable() + if text: + _emit(out, text) + if exit: + sys.exit(code) + return code + try: + text, code = _decide(raw) + except Exception: + # Past the handler, which _decide() already answers for: a fault in this file on a + # payload it did decode. With no Event to answer in dialect, the one refusal left is + # the host's blocking exit code, and nothing on stdout to be misread as a verdict. + _report(traceback.format_exc()) + text, code = "", 2 + 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 index 470de4f2..1e48cb6f 100644 --- a/tests/fixtures/runtime_goldens/grok.py +++ b/tests/fixtures/runtime_goldens/grok.py @@ -29,6 +29,7 @@ from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings +import zlib as _zlib # ------------------------------------------------------------------------------ # contract (agentseam 0.3.5) @@ -224,6 +225,43 @@ def tool_input_of(raw): # ------------------------------------------------------------------------------ # 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", +) + +_MCP_PREFIX = "mcp_" + +_CLAUDE_MCP_PREFIX = "mcp__" + +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) + +def names_a_single_underscore_mcp_tool(raw): + """True when tool_name is an MCP tool ID in a spelling Claude Code never sends.""" + name = raw.get("tool_name") if isinstance(raw, dict) else None + return isinstance(name, str) and name.startswith(_MCP_PREFIX) and not name.startswith(_CLAUDE_MCP_PREFIX) + +def event_keys_agree(raw): + """True when hookEventName names hook_event_name's event: the same text, or Grok Build's snake_case twin.""" + snake, pascal = (raw.get("hookEventName"), raw.get("hook_event_name")) if isinstance(raw, dict) else (None, None) + if not isinstance(pascal, str) or not isinstance(snake, str): + return False + return snake == pascal or (snake.islower() and snake.replace("_", "") == pascal.lower()) + +PROBES = { + "looks_like_claude_code": looks_like_claude_code, + "names_a_single_underscore_mcp_tool": names_a_single_underscore_mcp_tool, + "event_keys_agree": event_keys_agree, +} + UNREADABLE_NAME = "" def wire_name_of(raw, key): @@ -248,12 +286,22 @@ def _rejected_by_markers(c, raw): for marker in c.get("reject_markers", ()): if marker in raw: return True + for key, values in c.get("reject_values", {}).items(): + if key in raw and raw[key] in values: + return True for probe, markers in c.get("reject_markers_unless_probe", {}).items(): if any(marker in raw for marker in markers) and not PROBES[probe](raw): return True return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) +def _accepted_by_values(c, raw): + """True unless the entry names accepted values and the payload carries none of them.""" + values = c.get("accept_values") + return not values or any(key in raw and raw[key] in allowed for key, allowed in values.items()) + def _accepted_by_markers(c, raw, name): + if not _accepted_by_values(c, raw): + return False accept = c.get("accept_markers", ()) if not accept or any(marker in raw for marker in accept): return True @@ -292,12 +340,25 @@ def _segment(node, part): 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 _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + def _walk(node, path): - """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None if "[]." in path: head, sub = path.split("[].", 1) - items = _walk(node, head) - if not isinstance(items, (list, tuple)): + items = _list_at(node, head) + if items is None: return None joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) return joined or None @@ -327,7 +388,7 @@ def _tool_input_raw(cfg, raw): if not isinstance(raw, dict): return None for key in cfg["fields"].get("tool_input", ("tool_input",)): - value = raw.get(key) + value = _walk(raw, key) if value is not None: return value return None @@ -518,6 +579,8 @@ def hj_respond(cfg, decision, event, wire=None): return "", 0 if gate["grammar"] == "G2": return _g2(v, gate, decision, name) + if gate["grammar"] == "G7": + return _g7(v, decision, wire) return _g1(v, gate, decision, wire, name) _WINDOWS_KEYS = ("commandWindows", "windows") @@ -533,16 +596,25 @@ def _hook_dict(cfg, command): entry[key] = value return entry +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): rules = [] for ev in canonical_events: name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: - rule["matcher"] = matcher - rules.append(rule) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) return rules def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): @@ -571,6 +643,10 @@ def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): hooks.setdefault(extra, []).append({"command": command}) return {"hooks": hooks} +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] hooks = {} @@ -601,6 +677,8 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) if wrapper == "flat_entries": return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) return _default_wrapper(cfg, reverse, canonical_events, command, matcher) @@ -609,7 +687,7 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos 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': 'unknown', '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'}} +VENDOR = {'agent': 'grok', 'claims': {'accept_markers': ('hookEventName',), 'event_key': ('hook_event_name', 'hookEventName'), 'mode': 'marker', 'notes': "Grok Build sends hookEventName with grok's snake_case value (pre_tool_use) beside a hook_event_name key carrying the PascalCase name (github.com/xai-org/grok-build user-guide/10-hooks.md, read 2026-10-11 against v1.0.50), so hook_event_name is read first, but only when event_keys_agree(raw) finds hookEventName naming the same event (its snake_case twin, or the same text); a payload whose two keys disagree is not grok's (Kimi Code sends both, with a camelCase hookEventName). The older camelCase-only shape falls back to hookEventName, which is required either way.", 'reject_markers_unless_probe': {'event_keys_agree': ('hook_event_name',)}}, 'config_format': 'json', 'config_path': '.grok/hooks/agentseam.json', 'display': 'Grok CLI', 'events': {'PermissionDenied': 'tool_failure', 'PostCompact': 'unknown', '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): @@ -2096,7 +2174,7 @@ def evaluate_tool_call(argv, event): ) #: A payload naming only this vendor's own wire event, so `respond` picks that event's grammar. -_CHOCK_WIRE_RAW = {PRE_TOOL: {'hookEventName': 'PreToolUse'}, STOP: {'hookEventName': 'Stop'}} +_CHOCK_WIRE_RAW = {PRE_TOOL: {'hook_event_name': 'PreToolUse'}, STOP: {'hook_event_name': 'Stop'}} #: The words a client reads as a refusal, in any of the answer fields a vendor grammar uses. _CHOCK_REFUSING = ("deny", "block", "ask", "continue") @@ -2165,7 +2243,7 @@ def _chock_unreadable(): return (text, code) if _chock_refuses(text, code) else ("", 2) #: Per wire event mapped to pre_tool: whether the client shows a guard's ask as a prompt. _CHOCK_CAN_ASK = {'PreToolUse': False} -_CHOCK_EVENT_KEYS = ('hookEventName', 'hook_event_name', 'agent_action_name') +_CHOCK_EVENT_KEYS = ('hook_event_name', 'hookEventName', 'agent_action_name') def _chock_wire_name(raw): diff --git a/tests/fixtures/runtime_goldens/mistral_vibe.py b/tests/fixtures/runtime_goldens/mistral_vibe.py new file mode 100644 index 00000000..03d846d3 --- /dev/null +++ b/tests/fixtures/runtime_goldens/mistral_vibe.py @@ -0,0 +1,2432 @@ +# Generated by agentseam 0.3.5 -- bundle("mistral_vibe"). 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("mistral_vibe")` (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 contextlib +import io +import json +_json = json +import os +import sys + +import hashlib as _chock_hashlib +import fnmatch as _chock_fnmatch +import re as _chock_re +import os as _chock_os +import shlex as _chock_shlex +import shutil as _chock_shutil +import subprocess as _chock_subprocess +import time as _chock_time +from datetime import datetime as _chock_datetime, timezone as _chock_timezone +from pathlib import Path as _chock_Path +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath +import traceback +import warnings as _warnings +import zlib as _zlib + +# ------------------------------------------------------------------------------ +# contract (agentseam 0.3.5) + +"""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", + "command", + "content", + "cwd", + "event", + "output", + "path", + "prompt", + "raw", + "session_id", + "tool", + "tool_use_id", + ) + + 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__ = ("context", "evidence", "outcome", "reason", "updated_input") + + #: 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.lstrip()[:1] == "{": + try: + parsed = _json.loads(raw) + except (ValueError, RecursionError): + # JSONDecodeError is a ValueError; a string nested past the interpreter's + # limit raises RecursionError instead, and neither is an input to crash on. + return {} + if isinstance(parsed, dict): + return parsed + return {} + + +# ------------------------------------------------------------------------------ +# flat_decision family engine (trimmed to what this entry uses) + +UNREADABLE_NAME = "" + +def wire_name_of(raw, key): + """The event name under `key`: text, None when absent, UNREADABLE_NAME when not text. + + Total over anything `json.loads` can return: a payload that is not an object names no + event, and a name that is not a string is not one any adapter can map. + """ + name = raw.get(key) if isinstance(raw, dict) else None + if name is None or isinstance(name, str): + return name + return UNREADABLE_NAME + +def _wire_name(cfg, raw): + for key in cfg["claims"].get("event_key", ()): + name = wire_name_of(raw, key) + if name is not None: + return name + return None + +def _rejected_by_markers(c, raw): + for marker in c.get("reject_markers", ()): + if marker in raw: + return True + for key, values in c.get("reject_values", {}).items(): + if key in raw and raw[key] in values: + return True + for probe, markers in c.get("reject_markers_unless_probe", {}).items(): + if any(marker in raw for marker in markers) and not PROBES[probe](raw): + return True + return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) + +def _accepted_by_values(c, raw): + """True unless the entry names accepted values and the payload carries none of them.""" + values = c.get("accept_values") + return not values or any(key in raw and raw[key] in allowed for key, allowed in values.items()) + +def _accepted_by_markers(c, raw, name): + if not _accepted_by_values(c, raw): + return False + accept = c.get("accept_markers", ()) + if not accept or any(marker in raw for marker in accept): + return True + return any( + name == event_name and all(key in raw for key in required) + for event_name, required in c.get("accept_when_all", {}).items() + ) + +def _disqualified(cfg, c, raw, name): + """Every reason an entry declines a payload once its wire event name is known.""" + if name not in cfg["events"] and not (c.get("accept_any_name") and isinstance(name, str)): + return True + if "client_types" in c and raw.get("client_type") not in c["client_types"]: + return True + return _rejected_by_markers(c, raw) + +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) + # A positive self-identification beats a shared event name, accept_names included. + if raw.get("client_type") in c.get("reject_client_types", ()): + return False + if name in c.get("accept_names", ()): + return True + return not _disqualified(cfg, c, raw, name) and _accepted_by_markers(c, raw, name) + +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 _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + +def _walk(node, path): + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None + if "[]." in path: + head, sub = path.split("[].", 1) + items = _list_at(node, head) + if items is None: + 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): + if not isinstance(raw, dict): + return None + for key in cfg["fields"].get("tool_input", ("tool_input",)): + value = _walk(raw, 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, + ) + +_ESCALATE_FROM_TRANSFORM = "escalate_from_transform" + +_TRANSFORM_MISSING_INPUT = "transform_missing_input" + +def hj_reverse(cfg): + """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides. + + One wire name per canonical event, which is a real limit and not an oversight (R3, gap 4). + Cursor is where it bites: `pre_tool` pins to `preToolUse`, and Cursor honours `ask` only at + `beforeShellExecution` / `beforeMCPExecution`. So installing at `pre_tool` forecloses `ask` + before dispatch is ever reached -- the runtime degrade to `deny` is honest about it, but the + install already chose. Deny-style policies, which is all that ships today, are unaffected. + + The day an ask-style Cursor policy exists, this map has to select by decision dialect + (deny -> `preToolUse`, ask -> `beforeShellExecution`) and become one-to-many. Recorded here + rather than built, so the resolution is not re-litigated from scratch; the three facts it + rests on are pinned in tests/test_cursor_ask_dialect.py. + """ + 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_allow(v, decision, wire, name, words): + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + if wire in v.get("allow_silent_events", ()): + return "", 0 + if "allow" in words: + out = {"decision": words["allow"]} + if v.get("allow_context_key") and decision.outcome == ALLOW and value: + out[v["allow_context_key"]] = value + return _json.dumps(out), 0 + return "", 0 + +def _g1_transform(v, decision, name, words): + """None when the transform isn't representable here; `_g1` falls through to the block path.""" + if v.get("transform_grammar") == "hook_specific_tool_input": + return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0 + if decision.updated_input is None: + return None + if v.get("transform_grammar") == "top_level_updated_input": + out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input} + if decision.reason: + out["reason"] = decision.reason + return _json.dumps(out), 0 + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}}), 0 + +def _g1(v, gate, decision, wire, name): + """Block dialect: a top-level decision word, or silence/context where nothing is read.""" + words = dict(v.get("words", {})) + words.update(v.get("words_at", {}).get(wire, {})) + at_context_event = wire in v.get("context_events", ()) + if decision.outcome in (ALLOW, VOUCH, WARN): + return _g1_allow(v, decision, wire, name, words) + if decision.outcome == TRANSFORM and gate["honours_transform"]: + transformed = _g1_transform(v, decision, name, words) + if transformed is not None: + return transformed + if ( + decision.outcome == ESCALATE + and gate["honours_escalate"] + and "escalate" in words + # An escalate the dispatcher degraded a transform into is a block where the entry + # names that degradation (antigravity): prompting would offer the unmodified call. + and not (degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in v.get("degrade_notes", {})) + ): + reason = decision.reason or _default_for(v, decision, at_gate=True, wire=wire) + return _json.dumps({"decision": words["escalate"], "reason": reason}), 0 + out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, at_gate=False, wire=wire)} + if at_context_event and v.get("context_source") == "context" and decision.context: + out["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": decision.context} + return _json.dumps(out), 0 + +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) + if gate["grammar"] == "G7": + return _g7(v, decision, wire) + return _g1(v, gate, decision, wire, name) + +_WINDOWS_KEYS = ("commandWindows", "windows") + +_MATCHER_EVENTS = (PRE_TOOL, POST_TOOL, TOOL_FAILURE) + +def _hook_dict(cfg, command): + entry = {"type": "command", "command": command} + for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): + if key in _WINDOWS_KEYS: + entry[key] = powershell_command(command) + else: + entry[key] = value + return entry + +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + +def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): + rules = [] + for ev in canonical_events: + name = reverse.get(ev) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) + return rules + +def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): + gates = cfg["verdicts"]["answer_events"] + allow_silent = cfg["verdicts"].get("allow_silent_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 and name not in allow_silent: + entry["failClosed"] = True + hooks.setdefault(name, []).append(entry) + return {"version": 1, "hooks": hooks} + +def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + hooks.setdefault(name, []).append({"command": command}) + extra = hook_entry.get("also_wires", {}).get(ev) + if extra: + hooks.setdefault(extra, []).append({"command": command}) + return {"hooks": hooks} + +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + +def _default_wrapper(cfg, reverse, canonical_events, command, matcher): + hook_entry = cfg["hook_entry"] + 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"] and ev in _MATCHER_EVENTS: + 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 hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_closed=True): + """The vendor's hooks-config fragment wiring `command` for these canonical events. + + `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the + entry says otherwise; a False installs an observer, not a gate. + """ + hook_entry = cfg["hook_entry"] + reverse = hj_reverse(cfg) + wrapper = hook_entry["wrapper"] + if wrapper == "flat_list": + return _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher) + if wrapper == "cursor": + return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) + if wrapper == "flat_entries": + return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) + return _default_wrapper(cfg, reverse, canonical_events, command, matcher) + +_TOML_ESCAPES = {"\\": "\\\\", '"': '\\"', "\b": "\\b", "\t": "\\t", "\n": "\\n", "\f": "\\f", "\r": "\\r"} + +def _toml_char(ch): + if ch in _TOML_ESCAPES: + return _TOML_ESCAPES[ch] + return "\\u%04X" % ord(ch) if ch < " " or ch == "\x7f" else ch + +def _toml_value(value): + if isinstance(value, int) and not isinstance(value, bool): + return str(value) + return '"%s"' % "".join(_toml_char(ch) for ch in str(value)) + +_TOML_RULE_KEYS = ("name", "event", "type", "matcher", "match", "command", "timeout") + +def render_config(rules): + """Emit `[[hooks]]` tables. Only the documented fields, in a documented order.""" + blocks = [] + for rule in rules: + lines = ["[[hooks]]"] + for key in _TOML_RULE_KEYS: + if rule.get(key) is not None: + lines.append("%s = %s" % (key, _toml_value(rule[key]))) + blocks.append("\n".join(lines)) + return "\n\n".join(blocks) + "\n" + + +# ------------------------------------------------------------------------------ +# mistral_vibe vendor config + engine binding + +AGENT = "mistral_vibe" + +VENDOR = {'agent': 'mistral_vibe', 'claims': {'event_key': ('hook_event_name',), 'mode': 'marker', 'notes': 'Vibe names the event under hook_event_name with its own snake_case hook types (pre_tool, post_tool, post_agent), which no other adapter maps, so the event name alone separates it (docs.mistral.ai/vibe/code/cli/hooks and vibe/core/hooks/models.py HookType at mistral-vibe 2.26.1, read 2026-10-11).'}, 'config_format': 'toml', 'config_path': '.vibe/hooks.toml', 'display': 'Mistral Vibe', 'events': {'post_agent': 'stop', 'post_tool': 'post_tool', 'pre_tool': 'pre_tool'}, 'evidence': {'claims': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'vendor-docs', 'date': '2026-10-11', '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_text',), 'path': ('tool_input.file_path', 'tool_input.path'), 'session_id': ('session_id',), 'tool': ('tool_name',), 'tool_use_id': ('tool_call_id',)}, 'hook_entry': {'default_match': '*', 'matcher': True, 'rule_keys': {'event': 'type', 'matcher': 'match'}, 'rule_name_prefix': 'agentseam-', 'wrapper': 'flat_list'}, 'needs_trust': True, 'tools': {'shell': ('bash', 'powershell', 'git_bash'), 'write': ('write_file', 'edit')}, 'verdicts': {'answer_events': ('post_agent', 'pre_tool'), 'bare_allow': 'silent', 'degrade_notes': {'escalate': 'Vibe cannot prompt for confirmation from a hook', 'escalate_from_transform': "agentseam does not write Vibe's tool_input rewrite", 'transform': "agentseam does not write Vibe's tool_input rewrite"}, 'echo': 'payload', 'gates': {'post_agent': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'pre_tool': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'because', 'vocabulary': ('allow', 'deny'), 'vocabulary_basis': 'verified', 'words': {'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 mistral_vibe) + +_VOUCH_SPEAKS = False +_WARN_SPEAKS = False +_TRANSFORM_EVENTS = frozenset(()) + + +def degrade(decision, event): + """Reduce a decision to what 'mistral_vibe' 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 >>> +ENGINE_BUDGET_SECONDS = 30 + +def engine_deadline(): + """The monotonic instant this invocation's budget runs out.""" + return _chock_time.monotonic() + ENGINE_BUDGET_SECONDS + +def engine_remaining(deadline): + """Seconds left before `deadline`, never negative.""" + return max(deadline - _chock_time.monotonic(), 0.0) + +def time_left(deadline, cmd): + """Seconds left for a subprocess; raises TimeoutExpired when none are, so the caller's timeout path runs.""" + left = deadline - _chock_time.monotonic() + if left <= 0: + raise _chock_subprocess.TimeoutExpired(cmd, 0) + return left + +def allowed_seconds(expired): + """The time a timed-out call was actually given, as text (what the deadline left it, not the full budget).""" + return f'{max(float(expired.timeout or 0), 0.0):.1f}s' + +GUARD_VIOLATION = 1 + +GUARD_ASK_EXIT = 3 + +PYTHON_SUFFIX = '.py' + +_CRASH_MARKERS = ('traceback (most recent call last)', 'syntax error', 'syntaxerror', 'unexpected eof') + +TOOL_UNKNOWN = 'unknown' + +ARGV_FALLBACK_ENV = 'CHOCK_ARGV_FALLBACK' + +CAN_ASK_ENV = 'CHOCK_CAN_ASK' + +_POSIX_BASH = ('bash', '/bin/bash', '/usr/bin/bash') + +_GIT_FOR_WINDOWS = ('C:\\Program Files\\Git', 'C:\\Program Files (x86)\\Git') + +_GIT_BASH_DIRS = (('bin',), ('usr', 'bin')) + +_BASH_EXE = 'bash.exe' + +_WINDOWS_STUB_DIRS = ('/system32/', '/windowsapps/') + +_COREUTILS_MARKER = 'sed.exe' + +_WINDOWS = 'nt' + +_FOUND_BASH = {} + +GATE_LOG_ENV = 'CHOCK_GATE_LOG' + +_LOG_MAX_BYTES = 1048576 + +_BASH_PROBE_SECONDS = 10 + +GUARD_BLOCKED = 'blocked' + +GUARD_CLEAN = 'clean' + +GUARD_ASKED = 'asked' + +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 _git_roots() -> list[_chock_Path]: + """Git for Windows install roots: from `git` on PATH (cmd/ or mingw64/bin/), then the defaults.""" + git = _chock_shutil.which('git') + near = [_chock_Path(git).parent.parent, _chock_Path(git).parent.parent.parent] if git else [] + return [*near, *(_chock_Path(root) for root in _GIT_FOR_WINDOWS)] + +def bash_candidates() -> list[str]: + """Bash interpreters to try, best first; on Windows Git's own, never the WSL launcher.""" + if _chock_os.name != _WINDOWS: + return list(_POSIX_BASH) + found = [str(root.joinpath(*sub, _BASH_EXE)) for root in _git_roots() for sub in _GIT_BASH_DIRS] + on_path = _chock_shutil.which('bash') + if on_path and (not any((stub in on_path.lower().replace('\\', '/') for stub in _WINDOWS_STUB_DIRS))): + found.append(on_path) + return [c for i, c in enumerate(found) if c not in found[:i] and _chock_Path(c).is_file()] + +def interpreter_env(interpreter: str) -> dict[str, str]: + """The environment a guard runs in: on Windows, Git's usr/bin ahead on PATH for sed and grep.""" + env = dict(_chock_os.environ) + env.pop(CAN_ASK_ENV, None) + if _chock_os.name != _WINDOWS: + return env + home = _chock_Path(interpreter).parent + usr_bin = next((d for d in (home.parent / 'usr' / 'bin', home) if (d / _COREUTILS_MARKER).is_file()), None) + if usr_bin is not None: + env['PATH'] = str(usr_bin) + _chock_os.pathsep + env.get('PATH', '') + return env + +def find_bash(guard: _chock_Path, deadline: float | None=None) -> str | None: + """First bash that can actually see `guard`, probed once per process; the probes draw on `deadline`.""" + if 'bash' in _FOUND_BASH: + return _FOUND_BASH['bash'] + for candidate in bash_candidates(): + argv = [candidate, '-c', f'test -f "{guard.as_posix()}"'] + try: + limit = _BASH_PROBE_SECONDS if deadline is None else min(_BASH_PROBE_SECONDS, time_left(deadline, argv)) + proc = _chock_subprocess.run(argv, capture_output=True, timeout=limit, check=False) + except (OSError, _chock_subprocess.SubprocessError): + if deadline is not None and engine_remaining(deadline) <= 0: + return None + continue + if proc.returncode == 0: + _FOUND_BASH['bash'] = candidate + return candidate + return None + +def find_interpreter(guard: _chock_Path, deadline: float | None=None) -> str | None: + """The interpreter that can run `guard`: this Python for `.py`, otherwise a usable bash.""" + if guard.suffix == PYTHON_SUFFIX: + return sys.executable or None + return find_bash(guard, deadline) + +def normalize_tool(tool: str | None) -> str: + """The hook payload's tool name as `bash` | `powershell` | `shell` | `unknown`.""" + name = (tool or '').lower() + if name in ('powershell', 'pwsh'): + return 'powershell' + if name == 'bash': + return 'bash' + if name == 'sh' or 'shell' in name: + return 'shell' + return TOOL_UNKNOWN + +def is_guard_crash(output: str) -> bool: + """True when a guard's exit-1 output is empty or shows an interpreter crash, not a refusal.""" + lowered = output.lower() + return not lowered or any((marker in lowered for marker in _CRASH_MARKERS)) + +def split_command(command: str) -> tuple[list[str], bool]: + """POSIX `shlex.split` argv, else a whitespace split; the flag is True on the fallback.""" + try: + return (_chock_shlex.split(command), False) + except ValueError: + return (command.split(), True) + +def run_guard(guard: _chock_Path, command: str, tool: str='') -> str: + """`GUARD_BLOCKED` / `GUARD_ASKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + return run_guard_detailed(guard, command, tool)[0] + +def _no_interpreter(guard: _chock_Path, deadline: float) -> tuple[str, str]: + """The ask a guard earns when no shell could run it: the budget ran out probing, or none can see it.""" + if engine_remaining(deadline) <= 0: + print(f'chock: guard timed out after {ENGINE_BUDGET_SECONDS}s finding a shell, not checked', file=sys.stderr) + return (GUARD_ERRORED, '') + reason = f'no usable bash was found to run {guard.name}; on Windows install Git for Windows (it ships bash), elsewhere put bash on PATH' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) + +def run_guard_detailed(guard: _chock_Path, command: str, tool: str='', *, can_ask: bool | None=None) -> tuple[str, str]: + """`run_guard`'s verdict plus the guard's own first line, which an ask carries to the user.""" + args, fallback = split_command(command) + if not args: + return (GUARD_UNCHECKED, '') + deadline = engine_deadline() + interpreter = find_interpreter(guard, deadline) + if interpreter is None: + return _no_interpreter(guard, deadline) + try: + env = {**interpreter_env(interpreter), 'CHOCK_RAW_COMMAND': command, 'CHOCK_TOOL': normalize_tool(tool)} + if fallback: + env[ARGV_FALLBACK_ENV] = '1' + env.update({} if can_ask is None else {CAN_ASK_ENV: '1' if can_ask else '0'}) + proc = _chock_subprocess.run([interpreter, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=time_left(deadline, [interpreter, str(guard)]), check=False) + except _chock_subprocess.TimeoutExpired: + print(f'chock: guard timed out after {ENGINE_BUDGET_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, '') + output = ((proc.stderr or '') + (proc.stdout or '')).strip() + first_line = output.splitlines()[0].strip() if output else '' + if proc.returncode == GUARD_VIOLATION: + if is_guard_crash(output): + print(f'chock: guard {_chock_Path(guard).name} exited 1 without a reason (crash?), not checked', file=sys.stderr) + return (GUARD_ERRORED, '') + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + return (GUARD_BLOCKED, first_line) + if proc.returncode == GUARD_ASK_EXIT: + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + return (GUARD_ASKED, first_line) + if proc.returncode != 0: + print(f'chock: guard exited {proc.returncode}, not checked' + (f': {first_line[:120]}' if first_line else ''), file=sys.stderr) + return (GUARD_ERRORED, '') + return (GUARD_CLEAN, '') + +def append_gate_log(chock_root: _chock_Path, record: dict) -> None: + """Append one record, stamped, to `/log/gate-events.jsonl`, rotating past the size cap.""" + log_dir = chock_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') + stamped = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), **record} + with log_path.open('a', encoding='utf-8') as fh: + fh.write(json.dumps(stamped, ensure_ascii=False) + '\n') + +def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> 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 + record = {'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': verdict} + append_gate_log(artifact_root, record) + except Exception: + return + +def evaluate(argv: list[str], command: str, tool: str='', *, can_ask: bool | None=None) -> tuple[str, str] | None: + """Run the guard named on `argv` (`--guard `) against `command`.""" + guard = guard_path_from_argv(argv) + if guard is None: + return None + if not guard.exists(): + return (VERDICT_DENY, f"chock guard {guard} is missing, so this command cannot be checked. Run `chock sync --repo .` to reinstall the policy's guards.") + verdict, message = run_guard_detailed(guard, command, tool, can_ask=can_ask) + logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} + if verdict in logged: + log_outcome(guard, tool, verdict=logged[verdict]) + if verdict == GUARD_BLOCKED: + return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + if verdict == GUARD_ASKED: + return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') + if verdict == GUARD_ERRORED: + why = message or f"the {guard.stem} guard did not complete (see this hook's stderr)" + return (VERDICT_ESCALATE, f'chock could not check this command against {guard.stem}: {why}. Approving runs it unchecked.') + return None + +_EDIT_KEYS = (('old_string', 'new_string'), ('oldString', 'newString'), ('old_str', 'new_str')) + +_EDIT_LIST = 'edits' + +_CRLF = '\r\n' + +_CREATE_TEXT = 'file_text' + +def _edit_call_input(event): + raw = getattr(event, 'raw', None) + tool_input = raw.get('tool_input') if isinstance(raw, dict) else None + if isinstance(tool_input, str) and tool_input[:1] == '{': + try: + tool_input = json.loads(tool_input) + except ValueError: + return {} + return tool_input if isinstance(tool_input, dict) else {} + +def _pair(item): + if not isinstance(item, dict): + return None + for old, new in _EDIT_KEYS: + if isinstance(item.get(old), str) and isinstance(item.get(new), str): + return (item[old], item[new], item.get('replace_all') is True) + return None + +def edit_replacements(event): + """The (old, new, replace_all) replacements an edit call applies, in order; None if not an edit.""" + tool_input = _edit_call_input(event) + listed = tool_input.get(_EDIT_LIST) + found = [_pair(item) for item in (listed if isinstance(listed, list) else [tool_input])] + if not found or None in found: + return None + return found + +def _replace(text, old, new, every): + """`text` with the replacement applied, or None when `old` is not there to replace. + + The file is read with universal newlines, so a CRLF file (a Windows checkout) arrives as + LF; the call's strings are brought to LF too, whichever ending the client sent them with, + rather than calling a real edit a failure over line endings. + """ + old, new = (old.replace(_CRLF, '\n'), new.replace(_CRLF, '\n')) + if old == '': + return new if text == '' else None + if old not in text: + return None + return text.replace(old, new) if every else text.replace(old, new, 1) + +def edited_text(event, root=None): + """The file an edit call would leave behind, or None when it is not an edit or cannot be rebuilt.""" + replacements = edit_replacements(event) + path = getattr(event, 'path', None) + if replacements is None or not path: + return None + target = _chock_Path(path) + if not target.is_absolute(): + target = _chock_Path(root if root is not None else _chock_Path.cwd()) / target + try: + text = target.read_text(encoding='utf-8') if target.exists() else '' + except (OSError, UnicodeDecodeError): + return None + for old, new, every in replacements: + text = _replace(text, old, new, every) + if text is None: + return None + return text + +def created_text(event): + """The whole file a write call carries: the adapter's content, else `tool_input.file_text`; None otherwise.""" + content = getattr(event, 'content', None) + if isinstance(content, str): + return content + text = _edit_call_input(event).get(_CREATE_TEXT) + return text if isinstance(text, str) else None + +def added_from_event(event): + """What an edit call introduces -- what `added_lines` means for it -- or {} for any other call.""" + replacements = edit_replacements(event) + path = getattr(event, 'path', None) + if replacements is None or not path: + return {} + return {str(path): '\n'.join((new for _, new, _ in replacements))} + +_BEGIN = '*** Begin Patch' + +_END = '*** End Patch' + +_ADD = '*** Add File: ' + +_DELETE = '*** Delete File: ' + +_UPDATE = '*** Update File: ' + +_MOVE = '*** Move to: ' + +_EOF = '*** End of File' + +_HUNK = '@@' + +_OPS = (('add', _ADD), ('delete', _DELETE), ('update', _UPDATE)) + +_PATCH_KEYS = ('command', 'input', 'patch') + +def patch_text(event): + """The patch an apply_patch call carries, from its first to its last marker; None otherwise.""" + raw = getattr(event, 'raw', None) + tool_input = raw.get('tool_input') if isinstance(raw, dict) else None + command = tool_input if isinstance(tool_input, str) else None + for key in _PATCH_KEYS if isinstance(tool_input, dict) else (): + if tool_input.get(key) is not None: + command = tool_input[key] + break + if isinstance(command, list): + command = '\n'.join((part for part in command if isinstance(part, str))) + start = command.find(_BEGIN) if isinstance(command, str) else -1 + end = command.find(_END, start) if start >= 0 else -1 + return command[start:end + len(_END)] if end >= 0 else None + +def _body_line(current, line): + """Add one line under the file operation `current` is reading.""" + if line.startswith(_MOVE): + current[2] = line[len(_MOVE):].strip() + elif line.startswith(_HUNK): + current[3].append([line[len(_HUNK):].strip(), [], False]) + elif line.startswith(_EOF): + if current[3]: + current[3][-1][2] = True + elif current[0] == 'add': + current[3].append(line[1:] if line.startswith('+') else line) + else: + if not current[3]: + current[3].append(['', [], False]) + current[3][-1][1].append(line if line[:1] in (' ', '-', '+') else ' ' + line) + +def parse_patch(patch): + """[(op, path, moved_to, hunks)]: op is add/delete/update; a hunk is (anchor, lines, at_eof).""" + operations = [] + for line in patch.replace('\r\n', '\n').split('\n')[1:]: + if line.startswith(_END): + break + opened = next(((op, marker) for op, marker in _OPS if line.startswith(marker)), None) + if opened is not None: + operations.append([opened[0], line[len(opened[1]):].strip(), None, []]) + elif operations: + _body_line(operations[-1], line) + return [tuple(operation) for operation in operations] + +def _find(lines, wanted, start): + """Where `wanted` starts in `lines` at or after `start`, exactly or ignoring trailing space.""" + for loose in (False, True): + for index in range(start, len(lines) - len(wanted) + 1): + window = lines[index:index + len(wanted)] + if window == wanted or (loose and [w.rstrip() for w in window] == [w.rstrip() for w in wanted]): + return index + return None + +def apply_hunks(text, hunks): + """`text` with the hunks applied in order, or None when one cannot be placed.""" + lines = text.split('\n') + cursor = 0 + for anchor, body, at_eof in hunks: + if anchor: + found = _find(lines, [anchor], cursor) + if found is None: + found = next((i for i in range(cursor, len(lines)) if anchor.strip() in lines[i]), None) + if found is None: + return None + cursor = found + 1 + old = [line[1:] for line in body if line[:1] in (' ', '-')] + new = [line[1:] for line in body if line[:1] in (' ', '+')] + if not old: + at = len(lines) - (1 if lines and lines[-1] == '' else 0) if at_eof else cursor + else: + at = _find(lines, old, cursor) + if at is None: + return None + lines[at:at + len(old)] = new + cursor = at + len(new) + return '\n'.join(lines) + +def _read(root, path): + target = _chock_Path(path) + if not target.is_absolute(): + target = _chock_Path(root if root is not None else _chock_Path.cwd()) / target + try: + return target.read_text(encoding='utf-8') + except (OSError, UnicodeDecodeError): + return None + +def patched_files(event, root=None): + """{path: text} for every file an apply_patch call adds or updates and can be rebuilt.""" + patch = patch_text(event) + if patch is None: + return {} + files = {} + for op, path, moved_to, body in parse_patch(patch): + if op == 'add': + files[path] = '\n'.join(body) + '\n' + elif op == 'update': + before = _read(root, path) + after = apply_hunks(before, body) if before is not None else None + if after is not None: + files[moved_to or path] = after + return files + +def patch_added(event): + """{path: the lines the patch adds}, which is what `added_lines` means for it.""" + patch = patch_text(event) + if patch is None: + return {} + added = {} + for op, path, moved_to, body in parse_patch(patch): + if op == 'add': + added[path] = '\n'.join(body) + elif op == 'update': + added[moved_to or path] = '\n'.join((line[1:] for _, hunk, _ in body for line in hunk if line[:1] == '+')) + return added + +GATE_BLOCKED = 'blocked' + +GATE_CLEAN = 'clean' + +GATE_ERRORED = 'errored' + +GATE_ASKED = 'asked' + +GATE_WARNED = 'warned' + +VERDICT_WARN = 'warn' + +_EXIT_OUTCOME = {0: GATE_CLEAN, 1: GATE_BLOCKED, 3: GATE_ASKED, 4: GATE_WARNED} + +def runner_outcome(returncode, stderr): + """(outcome, message) for the runner's exit code and stderr: its words, only when it refused or spoke.""" + outcome = _EXIT_OUTCOME.get(returncode, GATE_ERRORED) + return (outcome, '' if outcome == GATE_CLEAN else (stderr or '').strip()) + +def gate_decision(outcome, message, gate): + """The decision an outcome earns, or None when the gate allowed: deny, escalate (ask) or warn.""" + policy = gate.parent.parent.name + spoken = {GATE_BLOCKED: (VERDICT_DENY, message or f'Blocked by chock policy: {policy}'), GATE_ASKED: (VERDICT_ESCALATE, message or f'Chock policy {policy} asks before this write.'), GATE_WARNED: (VERDICT_WARN, message or f'Chock policy {policy} warns about this write.'), GATE_ERRORED: (VERDICT_DENY, f'chock could not check this write: {message}. Refusing rather than reporting an allow it never established.')} + return spoken.get(outcome) + +OUTSIDE_KEY = 'outside_repo' + +_UP = '..' + +_DRIVE_AT = 1 + +_SEPARATORS = ('/', '\\') + +def _slashed(path): + return str(path).replace('\\', '/') + +def is_outside(path): + """Whether a path as the runtime names it lies outside the repository: absolute, or climbing out.""" + text = _slashed(path) + return text.startswith(('/', _UP + '/')) or text[_DRIVE_AT:_DRIVE_AT + 1] == ':' or text == _UP + +def expand_home(pattern): + """`~` (alone or before a separator) as this machine's home directory; anything else as given.""" + text = str(pattern) + if text[:1] == '~' and (len(text) == 1 or text[1] in _SEPARATORS): + return _chock_os.path.expanduser('~') + text[1:] + return text + +def _outside_fold(path): + """`path` with `.` and `..` folded lexically and doubled slashes dropped; a root's leading slash kept.""" + parts = [] + for part in _slashed(path).split('/'): + if part == _UP and parts and (parts[-1] not in ('', _UP)): + parts.pop() + elif part != '.' and (part or not parts): + parts.append(part) + return '/'.join(parts) + +def declared_outside(path, root, patterns, *, windows=None): + """The absolute, slash-separated path when it is outside `root` and matches a declared glob; else None.""" + windows = _chock_os.name == 'nt' if windows is None else windows + text = _slashed(path) + if not is_outside(text) or not patterns: + return None + absolute = text if not text.startswith(_UP) else _slashed(root) + '/' + text + absolute = _outside_fold(absolute) + subject = absolute.lower() if windows else absolute + for pattern in patterns: + wanted = _outside_fold(expand_home(pattern)) + if _chock_fnmatch.fnmatchcase(subject, wanted.lower() if windows else wanted): + return absolute + return None + +def outside_globs(gate): + """The `outside_repo` globs the compiled gate declares; none when it declares none or cannot be read.""" + try: + declared = json.loads(gate.read_text(encoding='utf-8')).get(OUTSIDE_KEY) + except (OSError, ValueError, AttributeError): + return [] + return [g for g in declared if isinstance(g, str)] if isinstance(declared, list) else [] + +def judged_files(files, root, patterns, repo_names): + """`files` ({path: text}) keyed the way a gate judges them. + + A path inside the repository is named by `repo_names(path)`; one outside is kept, under its + absolute path, only when a declared glob matches it. + """ + kept = {} + for path, text in files.items(): + for name in repo_names(path): + if not is_outside(name): + kept[name] = text + continue + found = declared_outside(name, root, patterns) + if found is not None: + kept[found] = text + return kept + +SESSION_MAX_ENTRIES = 500 + +SESSION_PRUNE_SECONDS = 7 * 24 * 3600 + +SESSION_FIELD_CAP = 300 + +SESSION_TRIM_BYTES = 131072 + +SESSION_DEDUPE_TAIL = 8 + +SESSION_STATE_PARTS = ('.chock', 'state') + +SESSION_INPUT_KEYS = ('tool_input', 'toolInput', 'toolArgs', 'input') + +SESSION_PATH_KEYS = ('file_path', 'filePath', 'path', 'notebook_path', 'target_file', 'TargetFile') + +SESSION_URL_KEYS = ('url', 'uri') + +SESSION_RESPONSE_KEYS = ('tool_response', 'toolResponse', 'tool_result', 'toolResult') + +SESSION_UNKNOWN_ID = 'unknown' + +SESSION_ID_MAX = 128 + +SESSION_MASK = '***' + +def tool_call_input(event): + """The tool's arguments as a dict, decoding the JSON-string form some vendors send; {} when absent.""" + raw = getattr(event, 'raw', None) + if not isinstance(raw, dict): + return {} + for key in SESSION_INPUT_KEYS: + value = raw.get(key) + if isinstance(value, str) and value[:1] == '{': + try: + value = json.loads(value) + except ValueError: + continue + if isinstance(value, dict): + return value + return {} + +def session_id_of(event): + """The session id as a safe file stem: separators and leading dots removed, never empty.""" + raw = getattr(event, 'raw', None) or {} + given = getattr(event, 'session_id', None) or (raw.get('session_id') if isinstance(raw, dict) else None) + stem = ''.join((c for c in str(given or '') if c.isalnum() or c in '._-')).lstrip('.') + return stem[:SESSION_ID_MAX] or SESSION_UNKNOWN_ID + +def session_log_path(root, session_id): + return _chock_Path(root).joinpath(*SESSION_STATE_PARTS, session_id + '.jsonl') + +def session_for(event, root): + """What a script gate receives as `session`: the id, the log's absolute path, this call's id.""" + session_id = session_id_of(event) + return {'id': session_id, 'log_path': session_log_path(root, session_id).as_posix(), 'tool_use_id': getattr(event, 'tool_use_id', None)} + +def _mask_assignments(command): + """`command` with the value of every `NAME=value` word masked: an env prefix is a secret's usual home.""" + words = [] + for word in command.split(' '): + name, eq, _ = word.partition('=') + words.append(name + eq + SESSION_MASK if eq and name.isidentifier() else word) + return ' '.join(words) + +def _bare_url(url): + """`url` without userinfo, query and fragment: where a token or a secret is usually carried.""" + url = url.split('#', 1)[0].split('?', 1)[0] + scheme, sep, rest = url.partition('://') + if not sep: + return url + authority, slash, tail = rest.partition('/') + return scheme + sep + authority.rsplit('@', 1)[-1] + slash + tail + +def _first_str(mapping, keys): + for key in keys: + value = mapping.get(key) + if isinstance(value, str) and value: + return value + return None + +def session_summary(event): + """The input of a tool call as {path?, command?, url?}: what a policy asks about, nothing else.""" + tool_input = tool_call_input(event) + path = getattr(event, 'path', None) or _first_str(tool_input, SESSION_PATH_KEYS) + command = getattr(event, 'command', None) or _first_str(tool_input, ('command',)) + url = _first_str(tool_input, SESSION_URL_KEYS) + summary = {} + if isinstance(path, str) and path: + summary['path'] = path[:SESSION_FIELD_CAP] + if isinstance(command, str) and command: + summary['command'] = _mask_assignments(command.splitlines()[0] if command.strip() else '')[:SESSION_FIELD_CAP] + if url: + summary['url'] = _bare_url(url)[:SESSION_FIELD_CAP] + return summary + +def _response_failed(response): + if not isinstance(response, dict): + return False + if response.get('is_error') is True or response.get('isError') is True or response.get('success') is False: + return True + code = response.get('exit_code', response.get('exitCode')) + return bool(response.get('error')) or (isinstance(code, int) and code != 0) + +def session_outcome(event): + """`ok` or `error` for a finished call, from what the payload says; None before it ran.""" + if event.event == 'tool_failure': + return 'error' + if event.event != 'post_tool': + return None + raw = getattr(event, 'raw', None) or {} + failed = any((_response_failed(raw.get(key)) for key in SESSION_RESPONSE_KEYS)) if isinstance(raw, dict) else False + return 'error' if failed else 'ok' + +def _same_call(line, record): + """Whether `line` is `record` already logged: same call id, or, with none, the same second and input.""" + try: + seen = json.loads(line) + except ValueError: + return False + if seen.get('phase') != record['phase']: + return False + if record['tool_use_id']: + return seen.get('tool_use_id') == record['tool_use_id'] + return not seen.get('tool_use_id') and all((seen.get(key) == record[key] for key in ('ts', 'tool', 'input', 'outcome'))) + +def _prune_old(directory): + cutoff = _chock_datetime.now(_chock_timezone.utc).timestamp() - SESSION_PRUNE_SECONDS + for old in directory.glob('*.jsonl'): + try: + if old.stat().st_mtime < cutoff: + old.unlink() + except OSError: + continue + +def _append_bounded(path, line, record): + lines = path.read_text(encoding='utf-8', errors='replace').splitlines() if path.exists() else [] + if any((_same_call(seen, record) for seen in lines[-SESSION_DEDUPE_TAIL:])): + return + if len(lines) < SESSION_MAX_ENTRIES and path.exists() and (path.stat().st_size < SESSION_TRIM_BYTES): + with path.open('a', encoding='utf-8') as fh: + fh.write(line + '\n') + return + kept = [*lines, line][-SESSION_MAX_ENTRIES:] + scratch = path.with_suffix('.tmp') + scratch.write_text('\n'.join(kept) + '\n', encoding='utf-8') + scratch.replace(path) + +def session_record(root, event, phase, outcome=None): + """Append one record for this call to its session's log. Best effort: never raises.""" + try: + path = session_log_path(root, session_id_of(event)) + path.parent.mkdir(parents=True, exist_ok=True) + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'session_id': path.stem, 'phase': phase, 'tool': str(getattr(event, 'tool', None) or ''), 'input': session_summary(event), 'outcome': outcome, 'tool_use_id': getattr(event, 'tool_use_id', None)} + _append_bounded(path, json.dumps(record, ensure_ascii=False, sort_keys=True), record) + _prune_old(path.parent) + except Exception: + return + +REENTRY_CAP = 3 + +STOP_LEDGER_SUFFIX = '.stop.jsonl' + +GATE_LOG_PARTS = ('.chock', 'log', 'gate-events.jsonl') + +STOP_MODE_MASK = 61440 + +STOP_MODE_FILE = 32768 + +STOP_MODE_DIR = 16384 + +STOP_PHASE = 'stop' + +STOP_TURN_KEYS = ('turn_id', 'generation_id') + +STOP_COUNT_KEY = 'loop_count' + +STOP_DIGEST_CHARS = 16 + +REENTRY_CLEAN = 'clean' + +REENTRY_REFUSED = 'refused' + +REENTRY_CAPPED = 'cap-reached' + +REENTRY_UNTRACKED = 'untracked' + +STOP_LOG_BLOCK = 'block' + +STOP_LOG_ALLOW = 'allow' + +STOP_LOG_WARN = 'warn' + +_STOP_LAST = "\nThis is refusal {cap} of {cap} for this turn's end: the next stop ends the turn with these findings still on disk, and a commit will refuse them. A person must review them." + +_STOP_WARNING = 'chock {policy}: this turn ended with findings still on disk{why}. The turn was allowed to end so the client does not loop; a commit will refuse these findings, and a person must review them.\n' + +_STOP_WHY = {REENTRY_CAPPED: ' after {cap} refused stops', REENTRY_UNTRACKED: ' (the stop record under .chock/state could not be written, so re-entries cannot be counted)'} + +_STOP_SINCE = {True: ' (changed since the last refusal)', False: ' (unchanged since the last refusal)'} + +def reentries(event): + """How many times the client says this stop re-entered its hook: `loop_count`, else 1 for `stop_hook_active`.""" + raw = event.raw if isinstance(event.raw, dict) else {} + try: + counted = int(raw.get(STOP_COUNT_KEY) or 0) + except (TypeError, ValueError): + counted = 1 + return max(counted, 1 if raw.get('stop_hook_active') else 0) + +def _stop_turn(event): + """The vendor's turn id, when the payload carries one; None otherwise.""" + raw = event.raw if isinstance(event.raw, dict) else {} + for key in STOP_TURN_KEYS: + value = raw.get(key) + if isinstance(value, str) and value: + return value[:SESSION_ID_MAX] + return None + +def _stop_digest(*parts): + digest = _chock_hashlib.sha256() + for part in parts: + digest.update(str(part).encode('utf-8', 'replace') + b'\x00') + return digest.hexdigest()[:STOP_DIGEST_CHARS] + +def finding_digests(message, writes): + """One digest per flagged file, of its path and content: never the text itself, never the message's wording.""" + if not message: + return [] + lines = [line.strip().lstrip('-').strip() for line in message.splitlines()] + flagged = [p for p in writes if any((line.startswith(p + ':') for line in lines))] or list(writes) + if not flagged: + return [_stop_digest(message)] + return sorted({_stop_digest(p, writes[p]) for p in flagged}) + +def stop_ledger_path(root, session_id): + return _chock_Path(root).joinpath(*SESSION_STATE_PARTS, session_id + STOP_LEDGER_SUFFIX) + +def stop_ledger_safe(path): + """Whether `path`, its directory and `.chock` are what chock made: real directories and a regular file or none. + + lstat, so a symlink, FIFO, socket, device or directory in any place is refused before anything opens it: + opening a FIFO blocks the hook until its timeout, which a client may read as an allow. + """ + for target, kind in ((path, STOP_MODE_FILE), (path.parent, STOP_MODE_DIR), (path.parent.parent, STOP_MODE_DIR)): + try: + mode = _chock_os.lstat(target).st_mode + except FileNotFoundError: + continue + except OSError: + return False + if mode & STOP_MODE_MASK != kind: + return False + return True + +def _stop_lines(path): + """The ledger's lines; none when it is missing, unsafe or cannot be read.""" + if not stop_ledger_safe(path): + return [] + try: + fd = _chock_os.open(path, _chock_os.O_RDONLY | getattr(_chock_os, 'O_NOFOLLOW', 0) | getattr(_chock_os, 'O_NONBLOCK', 0)) + with _chock_os.fdopen(fd, encoding='utf-8', errors='replace') as fh: + return fh.read().splitlines() + except OSError: + return [] + +def _stop_parse(line): + try: + return json.loads(line) + except (ValueError, RecursionError): + return None + +def _stop_well_formed(seen, session_id): + stamp, index = (seen.get('ts'), seen.get('reentry')) + return isinstance(stamp, str) and bool(stamp) and (seen.get('session_id') == session_id) and (type(index) is int) and (index >= 0) + +def _stop_chain(lines, session_id, policy, turn): + """This turn's records, newest first, counting down without a gap to its first stop; None when broken.""" + chain = [] + for line in reversed(lines): + seen = _stop_parse(line) + if not isinstance(seen, dict) or seen.get('phase') != STOP_PHASE or seen.get('policy') != policy: + continue + if turn is not None and seen.get('turn') != turn: + continue + if not _stop_well_formed(seen, session_id): + return None + if chain and seen['reentry'] != chain[-1]['reentry'] - 1: + return None + chain.append(seen) + if seen['reentry'] == 0 or seen.get('anchor') is True: + return chain + return None + +def _stop_append(path, lines, record): + """Append one record, keeping the last `SESSION_MAX_ENTRIES`; whether it landed.""" + line = json.dumps(record, sort_keys=True) + if not stop_ledger_safe(path): + return False + try: + path.parent.mkdir(parents=True, exist_ok=True) + if len(lines) < SESSION_MAX_ENTRIES and (not path.exists() or path.stat().st_size < SESSION_TRIM_BYTES): + flags = _chock_os.O_WRONLY | _chock_os.O_APPEND | _chock_os.O_CREAT | getattr(_chock_os, 'O_NOFOLLOW', 0) | getattr(_chock_os, 'O_NONBLOCK', 0) + with _chock_os.fdopen(_chock_os.open(path, flags, 384), 'a', encoding='utf-8') as fh: + fh.write(line + '\n') + else: + scratch = path.with_name('%s.%d.tmp' % (path.name, _chock_os.getpid())) + scratch.write_text('\n'.join([*lines[-(SESSION_MAX_ENTRIES - 1):], line]) + '\n', encoding='utf-8') + scratch.replace(path) + _prune_old(path.parent) + except OSError: + return False + return True + +def _stop_gate_log(root, policy, verdict, reentry, findings): + """One gate-log record per re-entry; a warn is held (`would_block`), so it is kept even with the log off. + + `reentry` is (index, reentry_verdict, findings_changed or None). + """ + try: + if not stop_ledger_safe(_chock_Path(root).joinpath(*GATE_LOG_PARTS)): + return + held = verdict == STOP_LOG_WARN + if _chock_os.environ.get(GATE_LOG_ENV) == '0' and (not held): + return + index, reentry_verdict, changed = reentry + record = {'policy_id': policy, 'surface': 'stop-reentry', 'event': 'stop', 'kind': 'reentry', 'reentry': index, 'verdict': verdict, 'reentry_verdict': reentry_verdict, 'match_count': len(findings), 'matches': []} + if held: + record.update({'would_action': STOP_LOG_BLOCK, 'would_block': True}) + if changed is not None: + record['findings_changed'] = changed + append_gate_log(_chock_Path(root).joinpath(SESSION_STATE_PARTS[0]), record) + except Exception: + return + +def _stop_warn(policy, why, changed, message): + """The warning a turn ends with: said on stderr for every client, and returned for a `systemMessage`.""" + text = _STOP_WARNING.format(policy=policy, why=why.format(cap=REENTRY_CAP) + _STOP_SINCE.get(changed, '')) + text += message + with contextlib.suppress(Exception): + sys.stderr.write(text + '\n') + sys.stderr.flush() + return (VERDICT_WARN, text) + +def _stop_changed(chain, findings): + """Whether the findings differ from the last refusal's; None when there is no refusal to compare with.""" + refused = next((r for r in chain or () if r.get('verdict') == STOP_LOG_BLOCK), None) + if refused is None or not isinstance(refused.get('findings'), list): + return None + return sorted(refused['findings']) != findings + +def settle_stop(event, root, gate, decision, writes=None): + """The decision a Stop earns once this turn's earlier Stops are weighed against `decision`.""" + if decision is not None and decision[0] != VERDICT_DENY: + return decision + policy = gate.parent.parent.name + session_id = session_id_of(event) + path = stop_ledger_path(root, session_id) + lines = _stop_lines(path) + message = decision[1] if decision else '' + findings = finding_digests(message, writes or {}) + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'session_id': session_id, 'phase': STOP_PHASE, 'policy': policy, 'turn': _stop_turn(event), 'findings': findings} + signalled = reentries(event) + if not signalled: + _stop_append(path, lines, {**record, 'reentry': 0, 'verdict': STOP_LOG_BLOCK if decision else STOP_LOG_ALLOW}) + return decision + chain = _stop_chain(lines, session_id, policy, record['turn']) + counted = isinstance(event.raw, dict) and STOP_COUNT_KEY in event.raw + index = signalled if counted else chain[0]['reentry'] + 1 if chain else 1 + capped = index > REENTRY_CAP + verdict = STOP_LOG_ALLOW if not decision else STOP_LOG_WARN if capped else STOP_LOG_BLOCK + landed = _stop_append(path, lines, {**record, 'reentry': index, 'verdict': verdict, 'anchor': chain is None}) + changed = _stop_changed(chain, findings) + if decision is None: + _stop_gate_log(root, policy, verdict, (index, REENTRY_CLEAN, None), findings) + return None + if capped: + _stop_gate_log(root, policy, verdict, (index, REENTRY_CAPPED, changed), findings) + return _stop_warn(policy, _STOP_WHY[REENTRY_CAPPED], changed, message) + if not landed and (not counted): + _stop_gate_log(root, policy, STOP_LOG_WARN, (index, REENTRY_UNTRACKED, None), findings) + return _stop_warn(policy, _STOP_WHY[REENTRY_UNTRACKED], None, message) + tracked = REENTRY_REFUSED if chain and landed else REENTRY_UNTRACKED + _stop_gate_log(root, policy, verdict, (index, tracked, changed), findings) + return (VERDICT_DENY, message + (_STOP_LAST.format(cap=REENTRY_CAP) if index == REENTRY_CAP else '')) + +UNREADABLE_STOP_LEDGER = 'unreadable-stop.jsonl' + +UNREADABLE_STOP_PHASE = 'unreadable-stop' + +UNREADABLE_STOP_WINDOW_SECONDS = 600 + +REENTRY_UNREADABLE = 'unreadable' + +_UNREADABLE_STOP_WARNING = 'chock {policy}: {cap} stops in a row had a payload chock could not read, so none of them was judged. The turn was allowed to end so the client does not loop; a commit will judge what is on disk, and a person must look at why the payload is unreadable.\n' + +_UNREADABLE_STOP_LAST = '\nThis is refusal {cap} of {cap} for stops chock cannot read: the next one ends the turn unchecked, and a commit will judge what is on disk. A person must look at why the payload is unreadable.' + +def _unreadable_stop_recent(lines, now): + """The refusals recorded inside the window; a record that does not parse, or has no time, counts as none.""" + kept = [] + for line in lines: + seen = _stop_parse(line) + stamp = seen.get('at') if isinstance(seen, dict) else None + refused = isinstance(seen, dict) and seen.get('phase') == UNREADABLE_STOP_PHASE + if refused and seen.get('verdict') == STOP_LOG_BLOCK and (type(stamp) in (int, float)) and (0 <= now - stamp < UNREADABLE_STOP_WINDOW_SECONDS): + kept.append(stamp) + return kept + +def settle_unreadable_stop(root, policy, refusal): + """(verdict, text) this unreadable Stop earns: a refusal with `refusal`, or a warning once the cap is spent. + + A ledger that is not a regular file in real directories, or cannot be written, keeps refusing: with nothing to count, only a refusal is safe. + """ + path = _chock_Path(root).joinpath(*SESSION_STATE_PARTS, UNREADABLE_STOP_LEDGER) + if not stop_ledger_safe(path): + return (VERDICT_DENY, refusal) + lines = _stop_lines(path) + now = _chock_datetime.now(_chock_timezone.utc).timestamp() + index = len(_unreadable_stop_recent(lines, now)) + 1 + capped = index > REENTRY_CAP + verdict = STOP_LOG_WARN if capped else STOP_LOG_BLOCK + _stop_append(path, lines, {'phase': UNREADABLE_STOP_PHASE, 'verdict': verdict, 'at': now, 'reentry': index}) + _stop_gate_log(root, policy, verdict, (index, REENTRY_UNREADABLE, None), []) + if capped: + text = _UNREADABLE_STOP_WARNING.format(policy=policy, cap=REENTRY_CAP) + with contextlib.suppress(Exception): + sys.stderr.write(text) + sys.stderr.flush() + return (VERDICT_WARN, text) + return (VERDICT_DENY, refusal + (_UNREADABLE_STOP_LAST.format(cap=REENTRY_CAP) if index == REENTRY_CAP else '')) + +_PARENT = '..' + +_DRIVE_COLON = ':' + +_ROOTS_KEY = 'workspace_roots' + +def _folded(pure): + """`pure` with `..` folded lexically; an absolute path never climbs above its anchor.""" + parts = [] + for part in pure.parts: + if part != _PARENT: + parts.append(part) + elif len(parts) > 1: + parts.pop() + return type(pure)(*parts) + +def _workspace_root(text): + """A workspace root as a path: `/C:/x` is `C:/x`; anything else as given.""" + if text[:1] == '/' and text[2:3] == _DRIVE_COLON and text[1:2].isalpha() and (text[3:4] in ('', '/', '\\')): + return text[1:] + return text + +def _holds(root, path): + """Whether `path`, absolute or relative, lies under `root`, compared the way `root` is spelled.""" + flavour = _chock_PureWindowsPath if root[1:2] == _DRIVE_COLON else _chock_PurePosixPath + try: + _folded(flavour(root) / str(path)).relative_to(flavour(root)) + except ValueError: + return False + return True + +def workspace_roots(event): + """The folders the client says the agent works in, normalised; [] when it names none.""" + raw = getattr(event, 'raw', None) + roots = raw.get(_ROOTS_KEY) if isinstance(raw, dict) else None + if not isinstance(roots, list): + return [] + return [_workspace_root(root) for root in roots if isinstance(root, str) and root] + +def workspace_root(event): + """The workspace root holding the event's path, else the first one; None when the client names none.""" + roots = workspace_roots(event) + path = getattr(event, 'path', None) + return next((root for root in roots if path and _holds(root, path)), roots[0] if roots else None) + +_SEVERITY = (VERDICT_DENY, VERDICT_ESCALATE, VERDICT_WARN) + +_GIT_DIR = '.git' + +def _repository(folder): + """The repository `folder` is in: the nearest folder holding `.git`, or None outside one.""" + here = _chock_Path(folder).absolute() + return next((f for f in (here, *here.parents) if (f / _GIT_DIR).exists()), None) + +def stop_roots(event, fallback): + """The event's `cwd` and every workspace root inside a repository, the first per repository; else [fallback].""" + by_repository = {} + for named in filter(None, (getattr(event, 'cwd', None), *workspace_roots(event))): + repository = _repository(named) + if repository is not None: + by_repository.setdefault(repository, _chock_Path(named)) + return list(by_repository.values()) or [fallback] + +def per_root(said): + """One (decision, judged files) for each root's own: the most severe verdict, findings under each root's name.""" + if len(said) == 1: + return said[0][1:] + spoken = [(root, decision) for root, decision, _judged in said if decision] + judged = {path: text for _root, _decision, files in said for path, text in files.items()} + if not spoken: + return (None, judged) + verdict = min((decision[0] for _root, decision in spoken), key=_SEVERITY.index) + return ((verdict, '\n'.join((f'In {root}:\n{decision[1]}' for root, decision in spoken))), judged) + +GATE_FLAG = '--gate' + +STOP_FLAG = '--stop' + +_GATE_DEPTH_TO_CHOCK = 3 + +_RUNNER_PARTS = ('bin', 'gate.py') + +_PACKAGED_RUNNER = 'gate.py' + +_GIT = 'git' + +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + +_DELETED = 'D' + +_RENAMED = 'R' + +VERDICT_DENY = 'deny' + +def gate_path_from_argv(argv): + """The `--gate ` argument a vendored runtime was invoked with, or None.""" + if GATE_FLAG in argv: + index = argv.index(GATE_FLAG) + if index + 1 < len(argv): + return _chock_Path(argv[index + 1]) + return None + +def runner_for(gate): + """The gate runner: beside the gate in a plugin, under .chock/bin in a repository, else None.""" + packaged = gate.resolve().parent / _PACKAGED_RUNNER + if packaged.exists(): + return packaged + parents = gate.resolve().parents + if len(parents) <= _GATE_DEPTH_TO_CHOCK: + return None + runner = parents[_GATE_DEPTH_TO_CHOCK].joinpath(*_RUNNER_PARTS) + return runner if runner.exists() else None + +def writes_from_event(event, root=None): + """The one file this tool call would write, or none when it carries no file text. + + An edit call carries only the text it inserts; judged alone, that fragment has no imports, + no class and no neighbours, so a gate that reads a whole file (a script kind) finds nothing + in it. An edit is therefore judged as the file it would leave: the file on disk with the + call's replacements applied. When that cannot be rebuilt -- the file is unreadable, or the + text to replace is not in it, so the call itself will fail -- the fragment is judged as + before and the turn's end judges what actually landed. A Codex apply_patch call names its + files only inside the patch; each one it adds or updates is judged as the patch leaves it. + """ + patched = patched_files(event, root) + if patched: + return patched + path = getattr(event, 'path', None) + content = created_text(event) + edited = edited_text(event, root) + if path and edited is not None: + return {str(path): edited} + if not path or not isinstance(content, str): + return {} + return {str(path): content} + +def repo_relative(path, root): + """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" + text = str(path) + if root is None: + return text + windows = _chock_os.name == 'nt' or str(root)[1:2] == _DRIVE_COLON + flavour = _chock_PureWindowsPath if windows else _chock_PurePosixPath + try: + return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() + except ValueError: + pass + if windows != (_chock_os.name == 'nt'): + return text + try: + return _chock_Path(root, text).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return text + +def repo_paths(path, root): + """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" + lexical = repo_relative(path, root) + if root is None or (str(root)[1:2] == _DRIVE_COLON) != (_chock_os.name == 'nt'): + return (lexical,) + try: + resolved = _chock_Path(root, str(path)).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return (lexical,) + return tuple(dict.fromkeys((lexical, resolved))) + +def changed_paths(repo_root, deadline=None, why=None): + """Every uncommitted path; None (reason appended to `why`) when git gave no answer, [] only outside a repository.""" + deadline, why = (engine_deadline() if deadline is None else deadline, [] if why is None else why) + argv = [_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'] + try: + timeout = time_left(deadline, argv) + proc = _chock_subprocess.run(argv, capture_output=True, text=True, encoding=_UTF8, errors=_PATH_ERRORS, timeout=timeout, check=False) + except _chock_subprocess.TimeoutExpired as exc: + why.append(f'git status did not finish within {allowed_seconds(exc)}') + except (OSError, _chock_subprocess.SubprocessError) as exc: + why.append(f'git status could not run ({exc})') + else: + first = ((proc.stderr or '').strip().splitlines() or [''])[0] + if proc.returncode and 'not a git repository' in first: + return [] + if proc.returncode: + why.append(f'git status failed (exit {proc.returncode}): {first}') + if why: + sys.stderr.write(f'chock: {why[-1]}, worktree not checked\n') + return None + fields = iter([field for field in (proc.stdout or '').split('\x00') if field]) + paths = [] + for field in fields: + status, path = (field[:2], field[3:]) + if status.startswith(_RENAMED): + next(fields, None) + if _DELETED not in status and path: + paths.append(path) + return paths + +def writes_from_worktree(repo_root, deadline=None, why=None): + """What this turn actually left on disk, however it was written; None when it could not be listed. + + The write path sees only writes it recognises; a shell heredoc carries no file argument. + Reading final state is what makes that stop mattering, so this deliberately does not care + which tool produced the bytes. + """ + paths = changed_paths(repo_root, deadline, why) + if paths is None: + return None + writes = {} + for path in paths: + try: + writes[path] = _chock_Path(repo_root, path).read_text(encoding='utf-8') + except (OSError, UnicodeDecodeError): + continue + return writes + +def run_gate(gate, writes, event, run_in, deadline): + """Ask the vendored runner. Returns (outcome, message) and never decides for itself. + + `run_in` is (root, extra): the working directory, and the stdin keys `extra` adds: `added` (per + edited path, only the text the edit introduces; a runner that predates it judges the whole file, + which only ever refuses more) and the script `session`. The runner gets what is left of `deadline`. + """ + root, extra = run_in + runner = runner_for(gate) + if runner is None: + return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') + argv = [sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event] + try: + proc = _chock_subprocess.run(argv, input=json.dumps({'writes': writes, **(extra or {})}), capture_output=True, text=True, encoding=_UTF8, errors='replace', timeout=time_left(deadline, argv), check=False, cwd=str(root) if root is not None else None) + except _chock_subprocess.TimeoutExpired as exc: + return (GATE_ERRORED, f'the gate runner gave no verdict within {allowed_seconds(exc)}') + except (OSError, _chock_subprocess.SubprocessError) as exc: + return (GATE_ERRORED, str(exc)) + return runner_outcome(proc.returncode, proc.stderr) + +_EVENT_ARG = {'pre_tool': 'pre-tool-use', 'stop': 'stop'} + +PRE_TOOL = 'pre_tool' + +def root_for(gate): + """The repository this compiled gate belongs to, or None when the layout is not that.""" + parents = gate.resolve().parents + if len(parents) <= _GATE_DEPTH_TO_CHOCK: + return None + if parents[_GATE_DEPTH_TO_CHOCK - 1].name != 'compiled' or parents[_GATE_DEPTH_TO_CHOCK].name != '.chock': + return None + return parents[_GATE_DEPTH_TO_CHOCK].parent + +def repo_root_for(event, gate): + """The repository under judgement: the compiled layout's root, else the event's cwd, else its workspace root. + + Of several workspace roots, the one holding the written path; this process's cwd only when there are none. + """ + root = root_for(gate) + if root is not None: + return root + cwd = getattr(event, 'cwd', None) or workspace_root(event) + return _chock_Path(cwd) if cwd else _chock_Path.cwd() + +def writes_for(event, gate, deadline=None, why=None, root=None): + """What this event puts under judgement: the call's own text, or what the turn left behind (None: unlisted).""" + root = repo_root_for(event, gate) if root is None else root + if event.event == PRE_TOOL: + return writes_from_event(event, root) + return writes_from_worktree(root, deadline, why) + +def _missing_gate(gate): + """A gate the hook names but that is not on disk: a broken install, so a refusal that says so.""" + return (VERDICT_DENY, f'chock gate {gate} is missing, so this write cannot be checked. Run `chock sync --repo .` to rebuild the compiled gates.') + +def _gate_says(gate, event, name, deadline, root): + """(decision, judged files): what the compiled gate says about this event in `root`, before a re-entered stop.""" + if not gate.exists(): + return (_missing_gate(gate), {}) + outside = outside_globs(gate) + why = [] + listed = writes_for(event, gate, deadline, why, root) + if listed is None: + return (gate_decision(GATE_ERRORED, why[-1] if why else 'git status gave no answer', gate), {}) + writes = judged_files(listed, root, outside, lambda path: repo_paths(path, root)) + if not writes: + return (None, writes) + added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} + added = judged_files(added, root, outside, lambda path: repo_paths(path, root)) + added = {path: text for path, text in added.items() if path in writes} + extra = {**({'added': added} if added else {}), 'session': session_for(event, root)} + outcome, message = run_gate(gate, writes, name, (root, extra), deadline) + return (gate_decision(outcome, message, gate), writes) + +def evaluate_gate(argv, event): + """The decision this event earns from a compiled gate, or None when it has nothing to say.""" + gate = gate_path_from_argv(argv) + name = _EVENT_ARG.get(getattr(event, 'event', '')) + if gate is None or name is None: + return None + deadline = engine_deadline() + if event.event == PRE_TOOL: + return _gate_says(gate, event, name, deadline, repo_root_for(event, gate))[0] + roots = [repo_root_for(event, gate)] if root_for(gate) else stop_roots(event, repo_root_for(event, gate)) + decision, judged = per_root([(root, *_gate_says(gate, event, name, deadline, root)) for root in roots]) + return settle_stop(event, roots[0], gate, decision, judged) + +TOOL_CALL_FLAG = '--tool-call' + +RECORD_FLAG = '--record' + +TOOL_CALL_EVENT = 'tool_call' + +_TOOL_CALL_PRE = 'pre_tool' + +_TOOL_CALL_POST = ('post_tool', 'tool_failure') + +_TOOL_CALL_SCRIPT_KIND = 'script' + +_TOOL_CALL_REGEX_KIND = 'content_regex' + +_TOOL_CALL_BLOCKED = 'blocked' + +_TOOL_CALL_NEEDS_SESSION = frozenset({_TOOL_CALL_SCRIPT_KIND}) + +_TOOL_CALL_EXIT_VERDICTS = {0: None, 1: 'deny', 3: 'escalate', 4: 'warn'} + +_TOOL_CALL_CEILING = {'block': 'deny', 'ask': 'escalate', 'warn': 'warn'} + +_TOOL_CALL_RANK = {'warn': 0, 'escalate': 1, 'deny': 2} + +_TOOL_CALL_UNDECIDED = ' -- refusing rather than allowing what it never judged' + +def _flag_path(argv, flag): + if flag in argv: + index = argv.index(flag) + if index + 1 < len(argv): + return _chock_Path(argv[index + 1]) + return None + +def tool_call_matches(globs, tool): + """Whether `tool` (a tool name, exact case) matches any glob; no globs match nothing.""" + return any((_chock_fnmatch.fnmatchcase(tool, str(glob)) for glob in globs)) + +def _tool_call_refusal(spec, spoken): + return spoken or str(spec.get('message') or '').strip() or 'blocked by a chock tool_call gate' + +def _tool_call_regex(spec, tool_input): + """`deny` verdict when the input, as JSON text, matches the gate's `content_pattern`.""" + text = json.dumps(tool_input, sort_keys=True, ensure_ascii=False) + if _chock_re.search(str((spec.get('params') or {}).get('content_pattern', '')), text): + return ('deny', _tool_call_refusal(spec, '')) + return None + +def _tool_call_script(spec, root, payload, deadline=None): + """Run the policy's script on `payload`; a missing script, crash or timeout refuses.""" + named = str((spec.get('params') or {}).get('script', '')) + script = _chock_Path(root) / named + if not script.is_file(): + return ('deny', f'script gate: {named!r} is not installed{_TOOL_CALL_UNDECIDED}') + deadline = engine_deadline() if deadline is None else deadline + argv = [sys.executable, str(script)] + try: + proc = _chock_subprocess.run(argv, input=json.dumps(payload), capture_output=True, text=True, encoding='utf-8', errors='replace', cwd=str(root), timeout=time_left(deadline, argv), check=False) + except _chock_subprocess.TimeoutExpired as exc: + budget = f'gave no verdict within {allowed_seconds(exc)}' + return ('deny', f'script gate: {script.name} {budget}{_TOOL_CALL_UNDECIDED}') + except OSError as exc: + return ('deny', f'script gate: {script.name} could not run ({exc}){_TOOL_CALL_UNDECIDED}') + spoken = ((proc.stderr or '') + (proc.stdout or '')).strip() + if proc.returncode in _TOOL_CALL_EXIT_VERDICTS: + verdict = _TOOL_CALL_EXIT_VERDICTS[proc.returncode] + return None if verdict is None else (verdict, spoken or f'blocked by {script.name}') + first = spoken.splitlines()[0] if spoken else '' + detail = f': {first}' if first else '' + return ('deny', f'script gate: {script.name} exited {proc.returncode}{detail}{_TOOL_CALL_UNDECIDED}') + +def _tool_call_verdict(spec, root, event, deadline): + """None to allow, else (verdict, message) for this call; a kind a tool call cannot answer refuses.""" + tool, tool_input = (str(event.tool or ''), tool_call_input(event)) + kind = spec.get('kind') + if kind == _TOOL_CALL_REGEX_KIND: + return _tool_call_regex(spec, tool_input) + if kind == _TOOL_CALL_SCRIPT_KIND: + payload = {'event': TOOL_CALL_EVENT, 'repo_root': str(root), 'tool': tool, 'input': tool_input, 'session': session_for(event, root)} + return _tool_call_script(spec, root, payload, deadline) + return ('deny', f'chock tool_call gate: kind {kind!r} cannot judge a tool call{_TOOL_CALL_UNDECIDED}') + +def _tool_call_capped(spec, verdict): + """`verdict` held to the gate's declared action; an unknown action keeps the verdict as spoken.""" + ceiling = _TOOL_CALL_CEILING.get(spec.get('action', 'block')) + if verdict is None or ceiling is None: + return verdict + return (min(verdict[0], ceiling, key=_TOOL_CALL_RANK.__getitem__), verdict[1]) + +def _tool_call_spec(gate): + """The compiled gate as a dict, or (None, refusal) when the install is broken.""" + if not gate.exists(): + return (None, f'chock gate {gate} is missing, so this tool call cannot be checked. Run `chock sync --repo .` to rebuild the compiled gates.') + try: + return (json.loads(gate.read_text(encoding='utf-8')), None) + except (OSError, ValueError) as exc: + return (None, f'chock could not read {gate}: {exc}. Refusing rather than reporting an allow it never established.') + +def evaluate_tool_call(argv, event): + """The decision a tool_call gate earns this call, or None; also logs the call for the session. + + `--tool-call ` judges a PreToolUse call and, for a gate whose check reads the session, + logs it after the verdict, so the script reads prior calls only. `--record ` logs what + a call came to, after it ran (a vendor's PostToolUse and failure events). + """ + recording = _flag_path(argv, RECORD_FLAG) + if recording is not None: + if event.event in _TOOL_CALL_POST: + session_record(repo_root_for(event, recording), event, 'post', session_outcome(event)) + return None + gate = _flag_path(argv, TOOL_CALL_FLAG) + if gate is None or event.event != _TOOL_CALL_PRE: + return None + spec, broken = _tool_call_spec(gate) + if spec is None: + return ('deny', broken) + root = repo_root_for(event, gate) + verdict = None + if TOOL_CALL_EVENT in spec.get('on', []) and tool_call_matches((spec.get('params') or {}).get('tools', []), str(event.tool or '')): + verdict = _tool_call_capped(spec, _tool_call_verdict(spec, root, event, engine_deadline())) + if spec.get('kind') in _TOOL_CALL_NEEDS_SESSION: + session_record(root, event, 'pre', _TOOL_CALL_BLOCKED if verdict and verdict[0] == 'deny' else None) + return verdict +_CHOCK_UNREADABLE = ( + "chock could not read this hook's payload, so it could not check the call. " + "Refusing rather than reporting an allow it never established." +) + +#: A payload naming only this vendor's own wire event, so `respond` picks that event's grammar. +_CHOCK_WIRE_RAW = {PRE_TOOL: {'hook_event_name': 'pre_tool'}, STOP: {'hook_event_name': 'post_agent'}} + +#: The words a client reads as a refusal, in any of the answer fields a vendor grammar uses. +_CHOCK_REFUSING = ("deny", "block", "ask", "continue") +_CHOCK_ANSWER_KEYS = ("decision", "permission", "permissionDecision") + + +def _chock_spec_action(path): + try: + action = json.loads(path.read_text(encoding="utf-8")).get("action") + except Exception: + return "block" + return action if action in ("ask", "warn") else "block" + + +def _chock_declared(): + """(strictest action, event, gate) of the judging flags; (None, None, None) where this run judges nothing.""" + argv = sys.argv[1:] + actions = ["block"] if guard_path_from_argv(argv) is not None else [] + event = PRE_TOOL + gate = _flag_path(argv, GATE_FLAG) + if gate is not None: + actions.append(_chock_spec_action(gate)) + event = STOP if STOP_FLAG in argv or gate.parent.name == _EVENT_ARG[STOP] else PRE_TOOL + called = _flag_path(argv, TOOL_CALL_FLAG) + if called is not None: + actions.append(_chock_spec_action(called)) + for action in ("block", "ask", "warn"): + if action in actions: + return action, event, gate + return None, None, None + + +def _chock_refuses(text, code): + if code == 2: + return True + try: + body = json.loads(text) + except Exception: + return False + if not isinstance(body, dict): + return False + nested = body.get("hookSpecificOutput") + answers = [body] + ([nested] if isinstance(nested, dict) else []) + return any(part.get(key) in _CHOCK_REFUSING for part in answers for key in _CHOCK_ANSWER_KEYS) + + +def _chock_unreadable_stop(gate): + """The bounded verdict of an unreadable Stop: a refusal, or a warning once the cap is spent.""" + root = root_for(gate) or _chock_Path.cwd() + return settle_unreadable_stop(root, gate.parent.parent.name, _CHOCK_UNREADABLE) + + +def _chock_unreadable(): + action, kind, gate = _chock_declared() + if action in (None, "warn"): + return "", 0 + event = Event(AGENT, kind, raw=dict(_CHOCK_WIRE_RAW[kind])) + reason = _CHOCK_UNREADABLE + if kind == STOP and gate is not None: + verdict, reason = _chock_unreadable_stop(gate) + if verdict == VERDICT_WARN: + return respond(degrade(Decision.warn(reason), event), event) + _report(reason + "\n") + decision = Decision.escalate(reason) if action == "ask" else Decision.deny(reason) + text, code = respond(degrade(decision, event), event) + return (text, code) if _chock_refuses(text, code) else ("", 2) +#: Per wire event mapped to pre_tool: whether the client shows a guard's ask as a prompt. +_CHOCK_CAN_ASK = {'pre_tool': False} +_CHOCK_EVENT_KEYS = ('hook_event_name', 'hookEventName', 'agent_action_name') + + +def _chock_wire_name(raw): + names = (raw.get(key) for key in _CHOCK_EVENT_KEYS) if isinstance(raw, dict) else () + return next((name for name in names if isinstance(name, str)), None) + + +def _chock_can_ask(raw): + # An event this bundle cannot name, or one the vendor data does not list, cannot prompt. + return _CHOCK_CAN_ASK.get(_chock_wire_name(raw), False) + + +def _spoken(verdict): + outcome, reason = verdict + if outcome == VERDICT_WARN: + return Decision.warn(reason) + return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + + +def _judge(event): + if event.event == "pre_tool" and event.command: + verdict = evaluate(sys.argv[1:], event.command, event.tool or "", can_ask=_chock_can_ask(event.raw)) + if verdict is not None: + outcome, reason = verdict + return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + gated = evaluate_gate(sys.argv[1:], event) + if gated is not None: + return _spoken(gated) + called = evaluate_tool_call(sys.argv[1:], event) + if called is not None: + return _spoken(called) + return None + + +def handle(event): + # A door that cannot decide refuses. An exception escaping here would exit the hook + # with a traceback, which every client reads as a non-blocking error: fail-open, with + # the reason on a stderr nobody watches. The refusal carries the reason instead. + try: + return _judge(event) + except Exception as exc: # noqa: BLE001 -- any failure here must refuse, never fall through + return Decision.deny( + "chock could not check this call (%s: %s). Refusing rather than reporting an " + "allow it never established." % (type(exc).__name__, exc) + ) +# <<< 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 _report(text): + # Best effort, never raising: a diagnostic must not pre-empt the refusal it accompanies. + # A hook's stderr is whatever the host gave it -- closed (then sys.stderr is None, and + # the traceback module's default print would land on STDOUT, inside the verdict), a + # console code page that cannot hold the payload text quoted in the exception, a pipe + # nobody reads. + try: + sys.stderr.write(text) + sys.stderr.flush() + except Exception: + return + + +_STDERR_FD = 2 + + +def _divert_fd1(): + """Point fd 1 at stderr (devnull if there is none); the saved fd 1, or None if it could not.""" + try: + saved = os.dup(1) + except OSError: + return None + # With fd 2 closed, dup() hands back 2 itself, and "stderr" would be stdout again. + if saved > _STDERR_FD: + with contextlib.suppress(OSError): + os.dup2(_STDERR_FD, 1) + return saved + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +def _flush(streams): + """Flush each stream that exists; a broken one is no reason to lose the verdict.""" + for stream in streams: + if stream is not None: + with contextlib.suppress(Exception): + stream.flush() + + +@contextlib.contextmanager +def _stdout_to_stderr(): + # Stdout is the verdict channel. A handler's stray print (or a child process it runs) + # ahead of the JSON makes the host fail to parse it, and Claude Code and Gemini CLI then + # treat the hook as a non-blocking error: a deny became an allow, witnessed live. + sink = sys.stderr if sys.stderr is not None else io.StringIO() + held = (sys.stdout, sys.__stdout__) + _flush(held) + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + # A stream the handler held on to (sys.__stdout__, a reference cached at import) + # buffers past redirect_stdout; flushed now it lands on stderr, not after the verdict. + _flush((sink, *held)) + os.dup2(saved, 1) + os.close(saved) + + +def _decide(raw): + """(stdout_text, exit_code) for one decoded payload: agentseam.dispatch.handle(), inlined.""" + event = parse(raw) + 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. + return "", 0 + try: + with _stdout_to_stderr(): + result = handle(event) + decision = _coerce(result) + except Exception: + # A door that cannot decide refuses. Escaping here would exit 1 with a traceback, + # which every host reads as a non-blocking error and allows past; instead the + # failure is printed for the operator and the gate answers deny in 'mistral_vibe''s + # own dialect. Only the failure's class is named to the host: its message may quote + # the very payload content the policy was inspecting. + _report(traceback.format_exc()) + decision = Decision.deny( + "policy handler failed (%s); refusing rather than allowing what it could not judge" + % sys.exc_info()[0].__name__ + ) + return respond(degrade(decision, event), event) + + +def main(stdin=None, stdout=None, exit=True): + """Read one payload from stdin, dispatch, emit the 'mistral_vibe' 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: + raw = None + if not isinstance(raw, dict): + text, code = _chock_unreadable() + if text: + _emit(out, text) + if exit: + sys.exit(code) + return code + try: + text, code = _decide(raw) + except Exception: + # Past the handler, which _decide() already answers for: a fault in this file on a + # payload it did decode. With no Event to answer in dialect, the one refusal left is + # the host's blocking exit code, and nothing on stdout to be misread as a verdict. + _report(traceback.format_exc()) + text, code = "", 2 + 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/openhands.py b/tests/fixtures/runtime_goldens/openhands.py new file mode 100644 index 00000000..8aa0c93c --- /dev/null +++ b/tests/fixtures/runtime_goldens/openhands.py @@ -0,0 +1,2407 @@ +# Generated by agentseam 0.3.5 -- bundle("openhands"). 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("openhands")` (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 contextlib +import io +import json +_json = json +import os +import sys + +import hashlib as _chock_hashlib +import fnmatch as _chock_fnmatch +import re as _chock_re +import os as _chock_os +import shlex as _chock_shlex +import shutil as _chock_shutil +import subprocess as _chock_subprocess +import time as _chock_time +from datetime import datetime as _chock_datetime, timezone as _chock_timezone +from pathlib import Path as _chock_Path +from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath +import traceback +import warnings as _warnings +import zlib as _zlib + +# ------------------------------------------------------------------------------ +# contract (agentseam 0.3.5) + +"""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", + "command", + "content", + "cwd", + "event", + "output", + "path", + "prompt", + "raw", + "session_id", + "tool", + "tool_use_id", + ) + + 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__ = ("context", "evidence", "outcome", "reason", "updated_input") + + #: 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.lstrip()[:1] == "{": + try: + parsed = _json.loads(raw) + except (ValueError, RecursionError): + # JSONDecodeError is a ValueError; a string nested past the interpreter's + # limit raises RecursionError instead, and neither is an input to crash on. + return {} + if isinstance(parsed, dict): + return parsed + return {} + + +# ------------------------------------------------------------------------------ +# flat_decision family engine (trimmed to what this entry uses) + +UNREADABLE_NAME = "" + +def wire_name_of(raw, key): + """The event name under `key`: text, None when absent, UNREADABLE_NAME when not text. + + Total over anything `json.loads` can return: a payload that is not an object names no + event, and a name that is not a string is not one any adapter can map. + """ + name = raw.get(key) if isinstance(raw, dict) else None + if name is None or isinstance(name, str): + return name + return UNREADABLE_NAME + +def _wire_name(cfg, raw): + for key in cfg["claims"].get("event_key", ()): + name = wire_name_of(raw, key) + if name is not None: + return name + return None + +def _rejected_by_markers(c, raw): + for marker in c.get("reject_markers", ()): + if marker in raw: + return True + for key, values in c.get("reject_values", {}).items(): + if key in raw and raw[key] in values: + return True + for probe, markers in c.get("reject_markers_unless_probe", {}).items(): + if any(marker in raw for marker in markers) and not PROBES[probe](raw): + return True + return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) + +def _accepted_by_values(c, raw): + """True unless the entry names accepted values and the payload carries none of them.""" + values = c.get("accept_values") + return not values or any(key in raw and raw[key] in allowed for key, allowed in values.items()) + +def _accepted_by_markers(c, raw, name): + if not _accepted_by_values(c, raw): + return False + accept = c.get("accept_markers", ()) + if not accept or any(marker in raw for marker in accept): + return True + return any( + name == event_name and all(key in raw for key in required) + for event_name, required in c.get("accept_when_all", {}).items() + ) + +def _disqualified(cfg, c, raw, name): + """Every reason an entry declines a payload once its wire event name is known.""" + if name not in cfg["events"] and not (c.get("accept_any_name") and isinstance(name, str)): + return True + if "client_types" in c and raw.get("client_type") not in c["client_types"]: + return True + return _rejected_by_markers(c, raw) + +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) + # A positive self-identification beats a shared event name, accept_names included. + if raw.get("client_type") in c.get("reject_client_types", ()): + return False + if name in c.get("accept_names", ()): + return True + return not _disqualified(cfg, c, raw, name) and _accepted_by_markers(c, raw, name) + +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 _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + +def _walk(node, path): + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None + if "[]." in path: + head, sub = path.split("[].", 1) + items = _list_at(node, head) + if items is None: + 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): + if not isinstance(raw, dict): + return None + for key in cfg["fields"].get("tool_input", ("tool_input",)): + value = _walk(raw, 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, + ) + +_ESCALATE_FROM_TRANSFORM = "escalate_from_transform" + +_TRANSFORM_MISSING_INPUT = "transform_missing_input" + +def hj_reverse(cfg): + """Canonical event -> wire name: the naive inverse, then the entry's pinned overrides. + + One wire name per canonical event, which is a real limit and not an oversight (R3, gap 4). + Cursor is where it bites: `pre_tool` pins to `preToolUse`, and Cursor honours `ask` only at + `beforeShellExecution` / `beforeMCPExecution`. So installing at `pre_tool` forecloses `ask` + before dispatch is ever reached -- the runtime degrade to `deny` is honest about it, but the + install already chose. Deny-style policies, which is all that ships today, are unaffected. + + The day an ask-style Cursor policy exists, this map has to select by decision dialect + (deny -> `preToolUse`, ask -> `beforeShellExecution`) and become one-to-many. Recorded here + rather than built, so the resolution is not re-litigated from scratch; the three facts it + rests on are pinned in tests/test_cursor_ask_dialect.py. + """ + 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_allow(v, decision, wire, name, words): + value = _context_value(v, decision) + if wire in v.get("context_events", ()) and value: + return _context_body(name, value) + if wire in v.get("allow_silent_events", ()): + return "", 0 + if "allow" in words: + out = {"decision": words["allow"]} + if v.get("allow_context_key") and decision.outcome == ALLOW and value: + out[v["allow_context_key"]] = value + return _json.dumps(out), 0 + return "", 0 + +def _g1_transform(v, decision, name, words): + """None when the transform isn't representable here; `_g1` falls through to the block path.""" + if v.get("transform_grammar") == "hook_specific_tool_input": + return _json.dumps({"hookSpecificOutput": {"tool_input": decision.updated_input}}), 0 + if decision.updated_input is None: + return None + if v.get("transform_grammar") == "top_level_updated_input": + out = {"decision": words.get("transform", "allow"), "updatedInput": decision.updated_input} + if decision.reason: + out["reason"] = decision.reason + return _json.dumps(out), 0 + return _json.dumps({"hookSpecificOutput": {"hookEventName": name, "updatedInput": decision.updated_input}}), 0 + +def _g1(v, gate, decision, wire, name): + """Block dialect: a top-level decision word, or silence/context where nothing is read.""" + words = dict(v.get("words", {})) + words.update(v.get("words_at", {}).get(wire, {})) + at_context_event = wire in v.get("context_events", ()) + if decision.outcome in (ALLOW, VOUCH, WARN): + return _g1_allow(v, decision, wire, name, words) + if decision.outcome == TRANSFORM and gate["honours_transform"]: + transformed = _g1_transform(v, decision, name, words) + if transformed is not None: + return transformed + if ( + decision.outcome == ESCALATE + and gate["honours_escalate"] + and "escalate" in words + # An escalate the dispatcher degraded a transform into is a block where the entry + # names that degradation (antigravity): prompting would offer the unmodified call. + and not (degraded_from(decision) == TRANSFORM and _ESCALATE_FROM_TRANSFORM in v.get("degrade_notes", {})) + ): + reason = decision.reason or _default_for(v, decision, at_gate=True, wire=wire) + return _json.dumps({"decision": words["escalate"], "reason": reason}), 0 + out = {"decision": words.get("block", "block"), "reason": _refusal_text(v, decision, at_gate=False, wire=wire)} + if at_context_event and v.get("context_source") == "context" and decision.context: + out["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": decision.context} + return _json.dumps(out), 0 + +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) + if gate["grammar"] == "G7": + return _g7(v, decision, wire) + return _g1(v, gate, decision, wire, name) + +_WINDOWS_KEYS = ("commandWindows", "windows") + +_MATCHER_EVENTS = (PRE_TOOL, POST_TOOL, TOOL_FAILURE) + +def _hook_dict(cfg, command): + entry = {"type": "command", "command": command} + for key, value in cfg["hook_entry"].get("entry_extra", {}).items(): + if key in _WINDOWS_KEYS: + entry[key] = powershell_command(command) + else: + entry[key] = value + return entry + +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + +def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): + rules = [] + for ev in canonical_events: + name = reverse.get(ev) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) + return rules + +def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): + gates = cfg["verdicts"]["answer_events"] + allow_silent = cfg["verdicts"].get("allow_silent_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 and name not in allow_silent: + entry["failClosed"] = True + hooks.setdefault(name, []).append(entry) + return {"version": 1, "hooks": hooks} + +def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): + hooks = {} + for ev in canonical_events: + name = reverse.get(ev) + if not name: + continue + hooks.setdefault(name, []).append({"command": command}) + extra = hook_entry.get("also_wires", {}).get(ev) + if extra: + hooks.setdefault(extra, []).append({"command": command}) + return {"hooks": hooks} + +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + +def _default_wrapper(cfg, reverse, canonical_events, command, matcher): + hook_entry = cfg["hook_entry"] + 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"] and ev in _MATCHER_EVENTS: + 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 hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_closed=True): + """The vendor's hooks-config fragment wiring `command` for these canonical events. + + `fail_closed` is read only by the `cursor` wrapper, whose gates fail open unless the + entry says otherwise; a False installs an observer, not a gate. + """ + hook_entry = cfg["hook_entry"] + reverse = hj_reverse(cfg) + wrapper = hook_entry["wrapper"] + if wrapper == "flat_list": + return _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher) + if wrapper == "cursor": + return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) + if wrapper == "flat_entries": + return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) + return _default_wrapper(cfg, reverse, canonical_events, command, matcher) + + +# ------------------------------------------------------------------------------ +# openhands vendor config + engine binding + +AGENT = "openhands" + +VENDOR = {'agent': 'openhands', 'claims': {'event_key': ('event_type',), 'mode': 'marker', 'notes': "OpenHands names the event under `event_type`, a key no other adapter's payload uses, so the event key alone separates it (docs.openhands.dev hooks page, read 2026-10-11)."}, 'config_format': 'json', 'config_path': '.openhands/hooks.json', 'display': 'OpenHands', 'events': {'PostToolUse': 'post_tool', 'PreToolUse': 'pre_tool', 'SessionEnd': 'session_end', 'SessionStart': 'session_start', 'Stop': 'stop', 'UserPromptSubmit': 'prompt_submit'}, 'evidence': {'claims': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'vendor-docs', 'date': '2026-10-11', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'flat_decision', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.file_text', 'tool_input.new_str'), 'cwd': ('working_dir',), 'output': ('tool_response',), 'path': ('tool_input.path',), 'prompt': ('message',), 'session_id': ('session_id',), 'tool': ('tool_name',)}, 'hook_entry': {'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'tools': {'shell': ('terminal',), 'write': ('file_editor',)}, 'verdicts': {'answer_events': ('PreToolUse', 'Stop', 'UserPromptSubmit'), 'bare_allow': 'silent', 'degrade_notes': {'escalate': 'OpenHands cannot prompt for confirmation', 'escalate_from_transform': 'OpenHands cannot modify a tool call', 'transform': 'OpenHands cannot modify a tool call'}, 'gates': {'PreToolUse': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'Stop': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'UserPromptSubmit': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'because', 'vocabulary': ('deny',), 'vocabulary_basis': 'verified', 'words': {'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 openhands) + +_VOUCH_SPEAKS = False +_WARN_SPEAKS = False +_TRANSFORM_EVENTS = frozenset(()) + + +def degrade(decision, event): + """Reduce a decision to what 'openhands' 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 >>> +ENGINE_BUDGET_SECONDS = 30 + +def engine_deadline(): + """The monotonic instant this invocation's budget runs out.""" + return _chock_time.monotonic() + ENGINE_BUDGET_SECONDS + +def engine_remaining(deadline): + """Seconds left before `deadline`, never negative.""" + return max(deadline - _chock_time.monotonic(), 0.0) + +def time_left(deadline, cmd): + """Seconds left for a subprocess; raises TimeoutExpired when none are, so the caller's timeout path runs.""" + left = deadline - _chock_time.monotonic() + if left <= 0: + raise _chock_subprocess.TimeoutExpired(cmd, 0) + return left + +def allowed_seconds(expired): + """The time a timed-out call was actually given, as text (what the deadline left it, not the full budget).""" + return f'{max(float(expired.timeout or 0), 0.0):.1f}s' + +GUARD_VIOLATION = 1 + +GUARD_ASK_EXIT = 3 + +PYTHON_SUFFIX = '.py' + +_CRASH_MARKERS = ('traceback (most recent call last)', 'syntax error', 'syntaxerror', 'unexpected eof') + +TOOL_UNKNOWN = 'unknown' + +ARGV_FALLBACK_ENV = 'CHOCK_ARGV_FALLBACK' + +CAN_ASK_ENV = 'CHOCK_CAN_ASK' + +_POSIX_BASH = ('bash', '/bin/bash', '/usr/bin/bash') + +_GIT_FOR_WINDOWS = ('C:\\Program Files\\Git', 'C:\\Program Files (x86)\\Git') + +_GIT_BASH_DIRS = (('bin',), ('usr', 'bin')) + +_BASH_EXE = 'bash.exe' + +_WINDOWS_STUB_DIRS = ('/system32/', '/windowsapps/') + +_COREUTILS_MARKER = 'sed.exe' + +_WINDOWS = 'nt' + +_FOUND_BASH = {} + +GATE_LOG_ENV = 'CHOCK_GATE_LOG' + +_LOG_MAX_BYTES = 1048576 + +_BASH_PROBE_SECONDS = 10 + +GUARD_BLOCKED = 'blocked' + +GUARD_CLEAN = 'clean' + +GUARD_ASKED = 'asked' + +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 _git_roots() -> list[_chock_Path]: + """Git for Windows install roots: from `git` on PATH (cmd/ or mingw64/bin/), then the defaults.""" + git = _chock_shutil.which('git') + near = [_chock_Path(git).parent.parent, _chock_Path(git).parent.parent.parent] if git else [] + return [*near, *(_chock_Path(root) for root in _GIT_FOR_WINDOWS)] + +def bash_candidates() -> list[str]: + """Bash interpreters to try, best first; on Windows Git's own, never the WSL launcher.""" + if _chock_os.name != _WINDOWS: + return list(_POSIX_BASH) + found = [str(root.joinpath(*sub, _BASH_EXE)) for root in _git_roots() for sub in _GIT_BASH_DIRS] + on_path = _chock_shutil.which('bash') + if on_path and (not any((stub in on_path.lower().replace('\\', '/') for stub in _WINDOWS_STUB_DIRS))): + found.append(on_path) + return [c for i, c in enumerate(found) if c not in found[:i] and _chock_Path(c).is_file()] + +def interpreter_env(interpreter: str) -> dict[str, str]: + """The environment a guard runs in: on Windows, Git's usr/bin ahead on PATH for sed and grep.""" + env = dict(_chock_os.environ) + env.pop(CAN_ASK_ENV, None) + if _chock_os.name != _WINDOWS: + return env + home = _chock_Path(interpreter).parent + usr_bin = next((d for d in (home.parent / 'usr' / 'bin', home) if (d / _COREUTILS_MARKER).is_file()), None) + if usr_bin is not None: + env['PATH'] = str(usr_bin) + _chock_os.pathsep + env.get('PATH', '') + return env + +def find_bash(guard: _chock_Path, deadline: float | None=None) -> str | None: + """First bash that can actually see `guard`, probed once per process; the probes draw on `deadline`.""" + if 'bash' in _FOUND_BASH: + return _FOUND_BASH['bash'] + for candidate in bash_candidates(): + argv = [candidate, '-c', f'test -f "{guard.as_posix()}"'] + try: + limit = _BASH_PROBE_SECONDS if deadline is None else min(_BASH_PROBE_SECONDS, time_left(deadline, argv)) + proc = _chock_subprocess.run(argv, capture_output=True, timeout=limit, check=False) + except (OSError, _chock_subprocess.SubprocessError): + if deadline is not None and engine_remaining(deadline) <= 0: + return None + continue + if proc.returncode == 0: + _FOUND_BASH['bash'] = candidate + return candidate + return None + +def find_interpreter(guard: _chock_Path, deadline: float | None=None) -> str | None: + """The interpreter that can run `guard`: this Python for `.py`, otherwise a usable bash.""" + if guard.suffix == PYTHON_SUFFIX: + return sys.executable or None + return find_bash(guard, deadline) + +def normalize_tool(tool: str | None) -> str: + """The hook payload's tool name as `bash` | `powershell` | `shell` | `unknown`.""" + name = (tool or '').lower() + if name in ('powershell', 'pwsh'): + return 'powershell' + if name == 'bash': + return 'bash' + if name == 'sh' or 'shell' in name: + return 'shell' + return TOOL_UNKNOWN + +def is_guard_crash(output: str) -> bool: + """True when a guard's exit-1 output is empty or shows an interpreter crash, not a refusal.""" + lowered = output.lower() + return not lowered or any((marker in lowered for marker in _CRASH_MARKERS)) + +def split_command(command: str) -> tuple[list[str], bool]: + """POSIX `shlex.split` argv, else a whitespace split; the flag is True on the fallback.""" + try: + return (_chock_shlex.split(command), False) + except ValueError: + return (command.split(), True) + +def run_guard(guard: _chock_Path, command: str, tool: str='') -> str: + """`GUARD_BLOCKED` / `GUARD_ASKED` / `GUARD_CLEAN` when the guard ran, otherwise why it did not.""" + return run_guard_detailed(guard, command, tool)[0] + +def _no_interpreter(guard: _chock_Path, deadline: float) -> tuple[str, str]: + """The ask a guard earns when no shell could run it: the budget ran out probing, or none can see it.""" + if engine_remaining(deadline) <= 0: + print(f'chock: guard timed out after {ENGINE_BUDGET_SECONDS}s finding a shell, not checked', file=sys.stderr) + return (GUARD_ERRORED, '') + reason = f'no usable bash was found to run {guard.name}; on Windows install Git for Windows (it ships bash), elsewhere put bash on PATH' + print(f'chock: {reason}', file=sys.stderr) + return (GUARD_ERRORED, reason) + +def run_guard_detailed(guard: _chock_Path, command: str, tool: str='', *, can_ask: bool | None=None) -> tuple[str, str]: + """`run_guard`'s verdict plus the guard's own first line, which an ask carries to the user.""" + args, fallback = split_command(command) + if not args: + return (GUARD_UNCHECKED, '') + deadline = engine_deadline() + interpreter = find_interpreter(guard, deadline) + if interpreter is None: + return _no_interpreter(guard, deadline) + try: + env = {**interpreter_env(interpreter), 'CHOCK_RAW_COMMAND': command, 'CHOCK_TOOL': normalize_tool(tool)} + if fallback: + env[ARGV_FALLBACK_ENV] = '1' + env.update({} if can_ask is None else {CAN_ASK_ENV: '1' if can_ask else '0'}) + proc = _chock_subprocess.run([interpreter, str(guard), *args], capture_output=True, text=True, encoding='utf-8', errors='replace', env=env, timeout=time_left(deadline, [interpreter, str(guard)]), check=False) + except _chock_subprocess.TimeoutExpired: + print(f'chock: guard timed out after {ENGINE_BUDGET_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, '') + output = ((proc.stderr or '') + (proc.stdout or '')).strip() + first_line = output.splitlines()[0].strip() if output else '' + if proc.returncode == GUARD_VIOLATION: + if is_guard_crash(output): + print(f'chock: guard {_chock_Path(guard).name} exited 1 without a reason (crash?), not checked', file=sys.stderr) + return (GUARD_ERRORED, '') + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + return (GUARD_BLOCKED, first_line) + if proc.returncode == GUARD_ASK_EXIT: + sys.stderr.write(proc.stdout or '') + sys.stderr.write(proc.stderr or '') + return (GUARD_ASKED, first_line) + if proc.returncode != 0: + print(f'chock: guard exited {proc.returncode}, not checked' + (f': {first_line[:120]}' if first_line else ''), file=sys.stderr) + return (GUARD_ERRORED, '') + return (GUARD_CLEAN, '') + +def append_gate_log(chock_root: _chock_Path, record: dict) -> None: + """Append one record, stamped, to `/log/gate-events.jsonl`, rotating past the size cap.""" + log_dir = chock_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') + stamped = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), **record} + with log_path.open('a', encoding='utf-8') as fh: + fh.write(json.dumps(stamped, ensure_ascii=False) + '\n') + +def log_outcome(guard: _chock_Path, tool: str, *, verdict: str) -> 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 + record = {'policy_id': guard.parent.parent.name, 'surface': 'pre-tool-use', 'event': 'tool_use', 'kind': guard.stem, 'tool': tool, 'verdict': verdict} + append_gate_log(artifact_root, record) + except Exception: + return + +def evaluate(argv: list[str], command: str, tool: str='', *, can_ask: bool | None=None) -> tuple[str, str] | None: + """Run the guard named on `argv` (`--guard `) against `command`.""" + guard = guard_path_from_argv(argv) + if guard is None: + return None + if not guard.exists(): + return (VERDICT_DENY, f"chock guard {guard} is missing, so this command cannot be checked. Run `chock sync --repo .` to reinstall the policy's guards.") + verdict, message = run_guard_detailed(guard, command, tool, can_ask=can_ask) + logged = {GUARD_BLOCKED: 'block', GUARD_ASKED: 'ask', GUARD_CLEAN: 'allow'} + if verdict in logged: + log_outcome(guard, tool, verdict=logged[verdict]) + if verdict == GUARD_BLOCKED: + return (VERDICT_DENY, f'Blocked by chock policy: {guard.stem}') + if verdict == GUARD_ASKED: + return (VERDICT_ESCALATE, f'chock policy {guard.stem} asks before this runs: {message}' if message else f'chock policy {guard.stem} asks for confirmation before this runs (guard gave no reason).') + if verdict == GUARD_ERRORED: + why = message or f"the {guard.stem} guard did not complete (see this hook's stderr)" + return (VERDICT_ESCALATE, f'chock could not check this command against {guard.stem}: {why}. Approving runs it unchecked.') + return None + +_EDIT_KEYS = (('old_string', 'new_string'), ('oldString', 'newString'), ('old_str', 'new_str')) + +_EDIT_LIST = 'edits' + +_CRLF = '\r\n' + +_CREATE_TEXT = 'file_text' + +def _edit_call_input(event): + raw = getattr(event, 'raw', None) + tool_input = raw.get('tool_input') if isinstance(raw, dict) else None + if isinstance(tool_input, str) and tool_input[:1] == '{': + try: + tool_input = json.loads(tool_input) + except ValueError: + return {} + return tool_input if isinstance(tool_input, dict) else {} + +def _pair(item): + if not isinstance(item, dict): + return None + for old, new in _EDIT_KEYS: + if isinstance(item.get(old), str) and isinstance(item.get(new), str): + return (item[old], item[new], item.get('replace_all') is True) + return None + +def edit_replacements(event): + """The (old, new, replace_all) replacements an edit call applies, in order; None if not an edit.""" + tool_input = _edit_call_input(event) + listed = tool_input.get(_EDIT_LIST) + found = [_pair(item) for item in (listed if isinstance(listed, list) else [tool_input])] + if not found or None in found: + return None + return found + +def _replace(text, old, new, every): + """`text` with the replacement applied, or None when `old` is not there to replace. + + The file is read with universal newlines, so a CRLF file (a Windows checkout) arrives as + LF; the call's strings are brought to LF too, whichever ending the client sent them with, + rather than calling a real edit a failure over line endings. + """ + old, new = (old.replace(_CRLF, '\n'), new.replace(_CRLF, '\n')) + if old == '': + return new if text == '' else None + if old not in text: + return None + return text.replace(old, new) if every else text.replace(old, new, 1) + +def edited_text(event, root=None): + """The file an edit call would leave behind, or None when it is not an edit or cannot be rebuilt.""" + replacements = edit_replacements(event) + path = getattr(event, 'path', None) + if replacements is None or not path: + return None + target = _chock_Path(path) + if not target.is_absolute(): + target = _chock_Path(root if root is not None else _chock_Path.cwd()) / target + try: + text = target.read_text(encoding='utf-8') if target.exists() else '' + except (OSError, UnicodeDecodeError): + return None + for old, new, every in replacements: + text = _replace(text, old, new, every) + if text is None: + return None + return text + +def created_text(event): + """The whole file a write call carries: the adapter's content, else `tool_input.file_text`; None otherwise.""" + content = getattr(event, 'content', None) + if isinstance(content, str): + return content + text = _edit_call_input(event).get(_CREATE_TEXT) + return text if isinstance(text, str) else None + +def added_from_event(event): + """What an edit call introduces -- what `added_lines` means for it -- or {} for any other call.""" + replacements = edit_replacements(event) + path = getattr(event, 'path', None) + if replacements is None or not path: + return {} + return {str(path): '\n'.join((new for _, new, _ in replacements))} + +_BEGIN = '*** Begin Patch' + +_END = '*** End Patch' + +_ADD = '*** Add File: ' + +_DELETE = '*** Delete File: ' + +_UPDATE = '*** Update File: ' + +_MOVE = '*** Move to: ' + +_EOF = '*** End of File' + +_HUNK = '@@' + +_OPS = (('add', _ADD), ('delete', _DELETE), ('update', _UPDATE)) + +_PATCH_KEYS = ('command', 'input', 'patch') + +def patch_text(event): + """The patch an apply_patch call carries, from its first to its last marker; None otherwise.""" + raw = getattr(event, 'raw', None) + tool_input = raw.get('tool_input') if isinstance(raw, dict) else None + command = tool_input if isinstance(tool_input, str) else None + for key in _PATCH_KEYS if isinstance(tool_input, dict) else (): + if tool_input.get(key) is not None: + command = tool_input[key] + break + if isinstance(command, list): + command = '\n'.join((part for part in command if isinstance(part, str))) + start = command.find(_BEGIN) if isinstance(command, str) else -1 + end = command.find(_END, start) if start >= 0 else -1 + return command[start:end + len(_END)] if end >= 0 else None + +def _body_line(current, line): + """Add one line under the file operation `current` is reading.""" + if line.startswith(_MOVE): + current[2] = line[len(_MOVE):].strip() + elif line.startswith(_HUNK): + current[3].append([line[len(_HUNK):].strip(), [], False]) + elif line.startswith(_EOF): + if current[3]: + current[3][-1][2] = True + elif current[0] == 'add': + current[3].append(line[1:] if line.startswith('+') else line) + else: + if not current[3]: + current[3].append(['', [], False]) + current[3][-1][1].append(line if line[:1] in (' ', '-', '+') else ' ' + line) + +def parse_patch(patch): + """[(op, path, moved_to, hunks)]: op is add/delete/update; a hunk is (anchor, lines, at_eof).""" + operations = [] + for line in patch.replace('\r\n', '\n').split('\n')[1:]: + if line.startswith(_END): + break + opened = next(((op, marker) for op, marker in _OPS if line.startswith(marker)), None) + if opened is not None: + operations.append([opened[0], line[len(opened[1]):].strip(), None, []]) + elif operations: + _body_line(operations[-1], line) + return [tuple(operation) for operation in operations] + +def _find(lines, wanted, start): + """Where `wanted` starts in `lines` at or after `start`, exactly or ignoring trailing space.""" + for loose in (False, True): + for index in range(start, len(lines) - len(wanted) + 1): + window = lines[index:index + len(wanted)] + if window == wanted or (loose and [w.rstrip() for w in window] == [w.rstrip() for w in wanted]): + return index + return None + +def apply_hunks(text, hunks): + """`text` with the hunks applied in order, or None when one cannot be placed.""" + lines = text.split('\n') + cursor = 0 + for anchor, body, at_eof in hunks: + if anchor: + found = _find(lines, [anchor], cursor) + if found is None: + found = next((i for i in range(cursor, len(lines)) if anchor.strip() in lines[i]), None) + if found is None: + return None + cursor = found + 1 + old = [line[1:] for line in body if line[:1] in (' ', '-')] + new = [line[1:] for line in body if line[:1] in (' ', '+')] + if not old: + at = len(lines) - (1 if lines and lines[-1] == '' else 0) if at_eof else cursor + else: + at = _find(lines, old, cursor) + if at is None: + return None + lines[at:at + len(old)] = new + cursor = at + len(new) + return '\n'.join(lines) + +def _read(root, path): + target = _chock_Path(path) + if not target.is_absolute(): + target = _chock_Path(root if root is not None else _chock_Path.cwd()) / target + try: + return target.read_text(encoding='utf-8') + except (OSError, UnicodeDecodeError): + return None + +def patched_files(event, root=None): + """{path: text} for every file an apply_patch call adds or updates and can be rebuilt.""" + patch = patch_text(event) + if patch is None: + return {} + files = {} + for op, path, moved_to, body in parse_patch(patch): + if op == 'add': + files[path] = '\n'.join(body) + '\n' + elif op == 'update': + before = _read(root, path) + after = apply_hunks(before, body) if before is not None else None + if after is not None: + files[moved_to or path] = after + return files + +def patch_added(event): + """{path: the lines the patch adds}, which is what `added_lines` means for it.""" + patch = patch_text(event) + if patch is None: + return {} + added = {} + for op, path, moved_to, body in parse_patch(patch): + if op == 'add': + added[path] = '\n'.join(body) + elif op == 'update': + added[moved_to or path] = '\n'.join((line[1:] for _, hunk, _ in body for line in hunk if line[:1] == '+')) + return added + +GATE_BLOCKED = 'blocked' + +GATE_CLEAN = 'clean' + +GATE_ERRORED = 'errored' + +GATE_ASKED = 'asked' + +GATE_WARNED = 'warned' + +VERDICT_WARN = 'warn' + +_EXIT_OUTCOME = {0: GATE_CLEAN, 1: GATE_BLOCKED, 3: GATE_ASKED, 4: GATE_WARNED} + +def runner_outcome(returncode, stderr): + """(outcome, message) for the runner's exit code and stderr: its words, only when it refused or spoke.""" + outcome = _EXIT_OUTCOME.get(returncode, GATE_ERRORED) + return (outcome, '' if outcome == GATE_CLEAN else (stderr or '').strip()) + +def gate_decision(outcome, message, gate): + """The decision an outcome earns, or None when the gate allowed: deny, escalate (ask) or warn.""" + policy = gate.parent.parent.name + spoken = {GATE_BLOCKED: (VERDICT_DENY, message or f'Blocked by chock policy: {policy}'), GATE_ASKED: (VERDICT_ESCALATE, message or f'Chock policy {policy} asks before this write.'), GATE_WARNED: (VERDICT_WARN, message or f'Chock policy {policy} warns about this write.'), GATE_ERRORED: (VERDICT_DENY, f'chock could not check this write: {message}. Refusing rather than reporting an allow it never established.')} + return spoken.get(outcome) + +OUTSIDE_KEY = 'outside_repo' + +_UP = '..' + +_DRIVE_AT = 1 + +_SEPARATORS = ('/', '\\') + +def _slashed(path): + return str(path).replace('\\', '/') + +def is_outside(path): + """Whether a path as the runtime names it lies outside the repository: absolute, or climbing out.""" + text = _slashed(path) + return text.startswith(('/', _UP + '/')) or text[_DRIVE_AT:_DRIVE_AT + 1] == ':' or text == _UP + +def expand_home(pattern): + """`~` (alone or before a separator) as this machine's home directory; anything else as given.""" + text = str(pattern) + if text[:1] == '~' and (len(text) == 1 or text[1] in _SEPARATORS): + return _chock_os.path.expanduser('~') + text[1:] + return text + +def _outside_fold(path): + """`path` with `.` and `..` folded lexically and doubled slashes dropped; a root's leading slash kept.""" + parts = [] + for part in _slashed(path).split('/'): + if part == _UP and parts and (parts[-1] not in ('', _UP)): + parts.pop() + elif part != '.' and (part or not parts): + parts.append(part) + return '/'.join(parts) + +def declared_outside(path, root, patterns, *, windows=None): + """The absolute, slash-separated path when it is outside `root` and matches a declared glob; else None.""" + windows = _chock_os.name == 'nt' if windows is None else windows + text = _slashed(path) + if not is_outside(text) or not patterns: + return None + absolute = text if not text.startswith(_UP) else _slashed(root) + '/' + text + absolute = _outside_fold(absolute) + subject = absolute.lower() if windows else absolute + for pattern in patterns: + wanted = _outside_fold(expand_home(pattern)) + if _chock_fnmatch.fnmatchcase(subject, wanted.lower() if windows else wanted): + return absolute + return None + +def outside_globs(gate): + """The `outside_repo` globs the compiled gate declares; none when it declares none or cannot be read.""" + try: + declared = json.loads(gate.read_text(encoding='utf-8')).get(OUTSIDE_KEY) + except (OSError, ValueError, AttributeError): + return [] + return [g for g in declared if isinstance(g, str)] if isinstance(declared, list) else [] + +def judged_files(files, root, patterns, repo_names): + """`files` ({path: text}) keyed the way a gate judges them. + + A path inside the repository is named by `repo_names(path)`; one outside is kept, under its + absolute path, only when a declared glob matches it. + """ + kept = {} + for path, text in files.items(): + for name in repo_names(path): + if not is_outside(name): + kept[name] = text + continue + found = declared_outside(name, root, patterns) + if found is not None: + kept[found] = text + return kept + +SESSION_MAX_ENTRIES = 500 + +SESSION_PRUNE_SECONDS = 7 * 24 * 3600 + +SESSION_FIELD_CAP = 300 + +SESSION_TRIM_BYTES = 131072 + +SESSION_DEDUPE_TAIL = 8 + +SESSION_STATE_PARTS = ('.chock', 'state') + +SESSION_INPUT_KEYS = ('tool_input', 'toolInput', 'toolArgs', 'input') + +SESSION_PATH_KEYS = ('file_path', 'filePath', 'path', 'notebook_path', 'target_file', 'TargetFile') + +SESSION_URL_KEYS = ('url', 'uri') + +SESSION_RESPONSE_KEYS = ('tool_response', 'toolResponse', 'tool_result', 'toolResult') + +SESSION_UNKNOWN_ID = 'unknown' + +SESSION_ID_MAX = 128 + +SESSION_MASK = '***' + +def tool_call_input(event): + """The tool's arguments as a dict, decoding the JSON-string form some vendors send; {} when absent.""" + raw = getattr(event, 'raw', None) + if not isinstance(raw, dict): + return {} + for key in SESSION_INPUT_KEYS: + value = raw.get(key) + if isinstance(value, str) and value[:1] == '{': + try: + value = json.loads(value) + except ValueError: + continue + if isinstance(value, dict): + return value + return {} + +def session_id_of(event): + """The session id as a safe file stem: separators and leading dots removed, never empty.""" + raw = getattr(event, 'raw', None) or {} + given = getattr(event, 'session_id', None) or (raw.get('session_id') if isinstance(raw, dict) else None) + stem = ''.join((c for c in str(given or '') if c.isalnum() or c in '._-')).lstrip('.') + return stem[:SESSION_ID_MAX] or SESSION_UNKNOWN_ID + +def session_log_path(root, session_id): + return _chock_Path(root).joinpath(*SESSION_STATE_PARTS, session_id + '.jsonl') + +def session_for(event, root): + """What a script gate receives as `session`: the id, the log's absolute path, this call's id.""" + session_id = session_id_of(event) + return {'id': session_id, 'log_path': session_log_path(root, session_id).as_posix(), 'tool_use_id': getattr(event, 'tool_use_id', None)} + +def _mask_assignments(command): + """`command` with the value of every `NAME=value` word masked: an env prefix is a secret's usual home.""" + words = [] + for word in command.split(' '): + name, eq, _ = word.partition('=') + words.append(name + eq + SESSION_MASK if eq and name.isidentifier() else word) + return ' '.join(words) + +def _bare_url(url): + """`url` without userinfo, query and fragment: where a token or a secret is usually carried.""" + url = url.split('#', 1)[0].split('?', 1)[0] + scheme, sep, rest = url.partition('://') + if not sep: + return url + authority, slash, tail = rest.partition('/') + return scheme + sep + authority.rsplit('@', 1)[-1] + slash + tail + +def _first_str(mapping, keys): + for key in keys: + value = mapping.get(key) + if isinstance(value, str) and value: + return value + return None + +def session_summary(event): + """The input of a tool call as {path?, command?, url?}: what a policy asks about, nothing else.""" + tool_input = tool_call_input(event) + path = getattr(event, 'path', None) or _first_str(tool_input, SESSION_PATH_KEYS) + command = getattr(event, 'command', None) or _first_str(tool_input, ('command',)) + url = _first_str(tool_input, SESSION_URL_KEYS) + summary = {} + if isinstance(path, str) and path: + summary['path'] = path[:SESSION_FIELD_CAP] + if isinstance(command, str) and command: + summary['command'] = _mask_assignments(command.splitlines()[0] if command.strip() else '')[:SESSION_FIELD_CAP] + if url: + summary['url'] = _bare_url(url)[:SESSION_FIELD_CAP] + return summary + +def _response_failed(response): + if not isinstance(response, dict): + return False + if response.get('is_error') is True or response.get('isError') is True or response.get('success') is False: + return True + code = response.get('exit_code', response.get('exitCode')) + return bool(response.get('error')) or (isinstance(code, int) and code != 0) + +def session_outcome(event): + """`ok` or `error` for a finished call, from what the payload says; None before it ran.""" + if event.event == 'tool_failure': + return 'error' + if event.event != 'post_tool': + return None + raw = getattr(event, 'raw', None) or {} + failed = any((_response_failed(raw.get(key)) for key in SESSION_RESPONSE_KEYS)) if isinstance(raw, dict) else False + return 'error' if failed else 'ok' + +def _same_call(line, record): + """Whether `line` is `record` already logged: same call id, or, with none, the same second and input.""" + try: + seen = json.loads(line) + except ValueError: + return False + if seen.get('phase') != record['phase']: + return False + if record['tool_use_id']: + return seen.get('tool_use_id') == record['tool_use_id'] + return not seen.get('tool_use_id') and all((seen.get(key) == record[key] for key in ('ts', 'tool', 'input', 'outcome'))) + +def _prune_old(directory): + cutoff = _chock_datetime.now(_chock_timezone.utc).timestamp() - SESSION_PRUNE_SECONDS + for old in directory.glob('*.jsonl'): + try: + if old.stat().st_mtime < cutoff: + old.unlink() + except OSError: + continue + +def _append_bounded(path, line, record): + lines = path.read_text(encoding='utf-8', errors='replace').splitlines() if path.exists() else [] + if any((_same_call(seen, record) for seen in lines[-SESSION_DEDUPE_TAIL:])): + return + if len(lines) < SESSION_MAX_ENTRIES and path.exists() and (path.stat().st_size < SESSION_TRIM_BYTES): + with path.open('a', encoding='utf-8') as fh: + fh.write(line + '\n') + return + kept = [*lines, line][-SESSION_MAX_ENTRIES:] + scratch = path.with_suffix('.tmp') + scratch.write_text('\n'.join(kept) + '\n', encoding='utf-8') + scratch.replace(path) + +def session_record(root, event, phase, outcome=None): + """Append one record for this call to its session's log. Best effort: never raises.""" + try: + path = session_log_path(root, session_id_of(event)) + path.parent.mkdir(parents=True, exist_ok=True) + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'session_id': path.stem, 'phase': phase, 'tool': str(getattr(event, 'tool', None) or ''), 'input': session_summary(event), 'outcome': outcome, 'tool_use_id': getattr(event, 'tool_use_id', None)} + _append_bounded(path, json.dumps(record, ensure_ascii=False, sort_keys=True), record) + _prune_old(path.parent) + except Exception: + return + +REENTRY_CAP = 3 + +STOP_LEDGER_SUFFIX = '.stop.jsonl' + +GATE_LOG_PARTS = ('.chock', 'log', 'gate-events.jsonl') + +STOP_MODE_MASK = 61440 + +STOP_MODE_FILE = 32768 + +STOP_MODE_DIR = 16384 + +STOP_PHASE = 'stop' + +STOP_TURN_KEYS = ('turn_id', 'generation_id') + +STOP_COUNT_KEY = 'loop_count' + +STOP_DIGEST_CHARS = 16 + +REENTRY_CLEAN = 'clean' + +REENTRY_REFUSED = 'refused' + +REENTRY_CAPPED = 'cap-reached' + +REENTRY_UNTRACKED = 'untracked' + +STOP_LOG_BLOCK = 'block' + +STOP_LOG_ALLOW = 'allow' + +STOP_LOG_WARN = 'warn' + +_STOP_LAST = "\nThis is refusal {cap} of {cap} for this turn's end: the next stop ends the turn with these findings still on disk, and a commit will refuse them. A person must review them." + +_STOP_WARNING = 'chock {policy}: this turn ended with findings still on disk{why}. The turn was allowed to end so the client does not loop; a commit will refuse these findings, and a person must review them.\n' + +_STOP_WHY = {REENTRY_CAPPED: ' after {cap} refused stops', REENTRY_UNTRACKED: ' (the stop record under .chock/state could not be written, so re-entries cannot be counted)'} + +_STOP_SINCE = {True: ' (changed since the last refusal)', False: ' (unchanged since the last refusal)'} + +def reentries(event): + """How many times the client says this stop re-entered its hook: `loop_count`, else 1 for `stop_hook_active`.""" + raw = event.raw if isinstance(event.raw, dict) else {} + try: + counted = int(raw.get(STOP_COUNT_KEY) or 0) + except (TypeError, ValueError): + counted = 1 + return max(counted, 1 if raw.get('stop_hook_active') else 0) + +def _stop_turn(event): + """The vendor's turn id, when the payload carries one; None otherwise.""" + raw = event.raw if isinstance(event.raw, dict) else {} + for key in STOP_TURN_KEYS: + value = raw.get(key) + if isinstance(value, str) and value: + return value[:SESSION_ID_MAX] + return None + +def _stop_digest(*parts): + digest = _chock_hashlib.sha256() + for part in parts: + digest.update(str(part).encode('utf-8', 'replace') + b'\x00') + return digest.hexdigest()[:STOP_DIGEST_CHARS] + +def finding_digests(message, writes): + """One digest per flagged file, of its path and content: never the text itself, never the message's wording.""" + if not message: + return [] + lines = [line.strip().lstrip('-').strip() for line in message.splitlines()] + flagged = [p for p in writes if any((line.startswith(p + ':') for line in lines))] or list(writes) + if not flagged: + return [_stop_digest(message)] + return sorted({_stop_digest(p, writes[p]) for p in flagged}) + +def stop_ledger_path(root, session_id): + return _chock_Path(root).joinpath(*SESSION_STATE_PARTS, session_id + STOP_LEDGER_SUFFIX) + +def stop_ledger_safe(path): + """Whether `path`, its directory and `.chock` are what chock made: real directories and a regular file or none. + + lstat, so a symlink, FIFO, socket, device or directory in any place is refused before anything opens it: + opening a FIFO blocks the hook until its timeout, which a client may read as an allow. + """ + for target, kind in ((path, STOP_MODE_FILE), (path.parent, STOP_MODE_DIR), (path.parent.parent, STOP_MODE_DIR)): + try: + mode = _chock_os.lstat(target).st_mode + except FileNotFoundError: + continue + except OSError: + return False + if mode & STOP_MODE_MASK != kind: + return False + return True + +def _stop_lines(path): + """The ledger's lines; none when it is missing, unsafe or cannot be read.""" + if not stop_ledger_safe(path): + return [] + try: + fd = _chock_os.open(path, _chock_os.O_RDONLY | getattr(_chock_os, 'O_NOFOLLOW', 0) | getattr(_chock_os, 'O_NONBLOCK', 0)) + with _chock_os.fdopen(fd, encoding='utf-8', errors='replace') as fh: + return fh.read().splitlines() + except OSError: + return [] + +def _stop_parse(line): + try: + return json.loads(line) + except (ValueError, RecursionError): + return None + +def _stop_well_formed(seen, session_id): + stamp, index = (seen.get('ts'), seen.get('reentry')) + return isinstance(stamp, str) and bool(stamp) and (seen.get('session_id') == session_id) and (type(index) is int) and (index >= 0) + +def _stop_chain(lines, session_id, policy, turn): + """This turn's records, newest first, counting down without a gap to its first stop; None when broken.""" + chain = [] + for line in reversed(lines): + seen = _stop_parse(line) + if not isinstance(seen, dict) or seen.get('phase') != STOP_PHASE or seen.get('policy') != policy: + continue + if turn is not None and seen.get('turn') != turn: + continue + if not _stop_well_formed(seen, session_id): + return None + if chain and seen['reentry'] != chain[-1]['reentry'] - 1: + return None + chain.append(seen) + if seen['reentry'] == 0 or seen.get('anchor') is True: + return chain + return None + +def _stop_append(path, lines, record): + """Append one record, keeping the last `SESSION_MAX_ENTRIES`; whether it landed.""" + line = json.dumps(record, sort_keys=True) + if not stop_ledger_safe(path): + return False + try: + path.parent.mkdir(parents=True, exist_ok=True) + if len(lines) < SESSION_MAX_ENTRIES and (not path.exists() or path.stat().st_size < SESSION_TRIM_BYTES): + flags = _chock_os.O_WRONLY | _chock_os.O_APPEND | _chock_os.O_CREAT | getattr(_chock_os, 'O_NOFOLLOW', 0) | getattr(_chock_os, 'O_NONBLOCK', 0) + with _chock_os.fdopen(_chock_os.open(path, flags, 384), 'a', encoding='utf-8') as fh: + fh.write(line + '\n') + else: + scratch = path.with_name('%s.%d.tmp' % (path.name, _chock_os.getpid())) + scratch.write_text('\n'.join([*lines[-(SESSION_MAX_ENTRIES - 1):], line]) + '\n', encoding='utf-8') + scratch.replace(path) + _prune_old(path.parent) + except OSError: + return False + return True + +def _stop_gate_log(root, policy, verdict, reentry, findings): + """One gate-log record per re-entry; a warn is held (`would_block`), so it is kept even with the log off. + + `reentry` is (index, reentry_verdict, findings_changed or None). + """ + try: + if not stop_ledger_safe(_chock_Path(root).joinpath(*GATE_LOG_PARTS)): + return + held = verdict == STOP_LOG_WARN + if _chock_os.environ.get(GATE_LOG_ENV) == '0' and (not held): + return + index, reentry_verdict, changed = reentry + record = {'policy_id': policy, 'surface': 'stop-reentry', 'event': 'stop', 'kind': 'reentry', 'reentry': index, 'verdict': verdict, 'reentry_verdict': reentry_verdict, 'match_count': len(findings), 'matches': []} + if held: + record.update({'would_action': STOP_LOG_BLOCK, 'would_block': True}) + if changed is not None: + record['findings_changed'] = changed + append_gate_log(_chock_Path(root).joinpath(SESSION_STATE_PARTS[0]), record) + except Exception: + return + +def _stop_warn(policy, why, changed, message): + """The warning a turn ends with: said on stderr for every client, and returned for a `systemMessage`.""" + text = _STOP_WARNING.format(policy=policy, why=why.format(cap=REENTRY_CAP) + _STOP_SINCE.get(changed, '')) + text += message + with contextlib.suppress(Exception): + sys.stderr.write(text + '\n') + sys.stderr.flush() + return (VERDICT_WARN, text) + +def _stop_changed(chain, findings): + """Whether the findings differ from the last refusal's; None when there is no refusal to compare with.""" + refused = next((r for r in chain or () if r.get('verdict') == STOP_LOG_BLOCK), None) + if refused is None or not isinstance(refused.get('findings'), list): + return None + return sorted(refused['findings']) != findings + +def settle_stop(event, root, gate, decision, writes=None): + """The decision a Stop earns once this turn's earlier Stops are weighed against `decision`.""" + if decision is not None and decision[0] != VERDICT_DENY: + return decision + policy = gate.parent.parent.name + session_id = session_id_of(event) + path = stop_ledger_path(root, session_id) + lines = _stop_lines(path) + message = decision[1] if decision else '' + findings = finding_digests(message, writes or {}) + record = {'ts': _chock_datetime.now(_chock_timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'session_id': session_id, 'phase': STOP_PHASE, 'policy': policy, 'turn': _stop_turn(event), 'findings': findings} + signalled = reentries(event) + if not signalled: + _stop_append(path, lines, {**record, 'reentry': 0, 'verdict': STOP_LOG_BLOCK if decision else STOP_LOG_ALLOW}) + return decision + chain = _stop_chain(lines, session_id, policy, record['turn']) + counted = isinstance(event.raw, dict) and STOP_COUNT_KEY in event.raw + index = signalled if counted else chain[0]['reentry'] + 1 if chain else 1 + capped = index > REENTRY_CAP + verdict = STOP_LOG_ALLOW if not decision else STOP_LOG_WARN if capped else STOP_LOG_BLOCK + landed = _stop_append(path, lines, {**record, 'reentry': index, 'verdict': verdict, 'anchor': chain is None}) + changed = _stop_changed(chain, findings) + if decision is None: + _stop_gate_log(root, policy, verdict, (index, REENTRY_CLEAN, None), findings) + return None + if capped: + _stop_gate_log(root, policy, verdict, (index, REENTRY_CAPPED, changed), findings) + return _stop_warn(policy, _STOP_WHY[REENTRY_CAPPED], changed, message) + if not landed and (not counted): + _stop_gate_log(root, policy, STOP_LOG_WARN, (index, REENTRY_UNTRACKED, None), findings) + return _stop_warn(policy, _STOP_WHY[REENTRY_UNTRACKED], None, message) + tracked = REENTRY_REFUSED if chain and landed else REENTRY_UNTRACKED + _stop_gate_log(root, policy, verdict, (index, tracked, changed), findings) + return (VERDICT_DENY, message + (_STOP_LAST.format(cap=REENTRY_CAP) if index == REENTRY_CAP else '')) + +UNREADABLE_STOP_LEDGER = 'unreadable-stop.jsonl' + +UNREADABLE_STOP_PHASE = 'unreadable-stop' + +UNREADABLE_STOP_WINDOW_SECONDS = 600 + +REENTRY_UNREADABLE = 'unreadable' + +_UNREADABLE_STOP_WARNING = 'chock {policy}: {cap} stops in a row had a payload chock could not read, so none of them was judged. The turn was allowed to end so the client does not loop; a commit will judge what is on disk, and a person must look at why the payload is unreadable.\n' + +_UNREADABLE_STOP_LAST = '\nThis is refusal {cap} of {cap} for stops chock cannot read: the next one ends the turn unchecked, and a commit will judge what is on disk. A person must look at why the payload is unreadable.' + +def _unreadable_stop_recent(lines, now): + """The refusals recorded inside the window; a record that does not parse, or has no time, counts as none.""" + kept = [] + for line in lines: + seen = _stop_parse(line) + stamp = seen.get('at') if isinstance(seen, dict) else None + refused = isinstance(seen, dict) and seen.get('phase') == UNREADABLE_STOP_PHASE + if refused and seen.get('verdict') == STOP_LOG_BLOCK and (type(stamp) in (int, float)) and (0 <= now - stamp < UNREADABLE_STOP_WINDOW_SECONDS): + kept.append(stamp) + return kept + +def settle_unreadable_stop(root, policy, refusal): + """(verdict, text) this unreadable Stop earns: a refusal with `refusal`, or a warning once the cap is spent. + + A ledger that is not a regular file in real directories, or cannot be written, keeps refusing: with nothing to count, only a refusal is safe. + """ + path = _chock_Path(root).joinpath(*SESSION_STATE_PARTS, UNREADABLE_STOP_LEDGER) + if not stop_ledger_safe(path): + return (VERDICT_DENY, refusal) + lines = _stop_lines(path) + now = _chock_datetime.now(_chock_timezone.utc).timestamp() + index = len(_unreadable_stop_recent(lines, now)) + 1 + capped = index > REENTRY_CAP + verdict = STOP_LOG_WARN if capped else STOP_LOG_BLOCK + _stop_append(path, lines, {'phase': UNREADABLE_STOP_PHASE, 'verdict': verdict, 'at': now, 'reentry': index}) + _stop_gate_log(root, policy, verdict, (index, REENTRY_UNREADABLE, None), []) + if capped: + text = _UNREADABLE_STOP_WARNING.format(policy=policy, cap=REENTRY_CAP) + with contextlib.suppress(Exception): + sys.stderr.write(text) + sys.stderr.flush() + return (VERDICT_WARN, text) + return (VERDICT_DENY, refusal + (_UNREADABLE_STOP_LAST.format(cap=REENTRY_CAP) if index == REENTRY_CAP else '')) + +_PARENT = '..' + +_DRIVE_COLON = ':' + +_ROOTS_KEY = 'workspace_roots' + +def _folded(pure): + """`pure` with `..` folded lexically; an absolute path never climbs above its anchor.""" + parts = [] + for part in pure.parts: + if part != _PARENT: + parts.append(part) + elif len(parts) > 1: + parts.pop() + return type(pure)(*parts) + +def _workspace_root(text): + """A workspace root as a path: `/C:/x` is `C:/x`; anything else as given.""" + if text[:1] == '/' and text[2:3] == _DRIVE_COLON and text[1:2].isalpha() and (text[3:4] in ('', '/', '\\')): + return text[1:] + return text + +def _holds(root, path): + """Whether `path`, absolute or relative, lies under `root`, compared the way `root` is spelled.""" + flavour = _chock_PureWindowsPath if root[1:2] == _DRIVE_COLON else _chock_PurePosixPath + try: + _folded(flavour(root) / str(path)).relative_to(flavour(root)) + except ValueError: + return False + return True + +def workspace_roots(event): + """The folders the client says the agent works in, normalised; [] when it names none.""" + raw = getattr(event, 'raw', None) + roots = raw.get(_ROOTS_KEY) if isinstance(raw, dict) else None + if not isinstance(roots, list): + return [] + return [_workspace_root(root) for root in roots if isinstance(root, str) and root] + +def workspace_root(event): + """The workspace root holding the event's path, else the first one; None when the client names none.""" + roots = workspace_roots(event) + path = getattr(event, 'path', None) + return next((root for root in roots if path and _holds(root, path)), roots[0] if roots else None) + +_SEVERITY = (VERDICT_DENY, VERDICT_ESCALATE, VERDICT_WARN) + +_GIT_DIR = '.git' + +def _repository(folder): + """The repository `folder` is in: the nearest folder holding `.git`, or None outside one.""" + here = _chock_Path(folder).absolute() + return next((f for f in (here, *here.parents) if (f / _GIT_DIR).exists()), None) + +def stop_roots(event, fallback): + """The event's `cwd` and every workspace root inside a repository, the first per repository; else [fallback].""" + by_repository = {} + for named in filter(None, (getattr(event, 'cwd', None), *workspace_roots(event))): + repository = _repository(named) + if repository is not None: + by_repository.setdefault(repository, _chock_Path(named)) + return list(by_repository.values()) or [fallback] + +def per_root(said): + """One (decision, judged files) for each root's own: the most severe verdict, findings under each root's name.""" + if len(said) == 1: + return said[0][1:] + spoken = [(root, decision) for root, decision, _judged in said if decision] + judged = {path: text for _root, _decision, files in said for path, text in files.items()} + if not spoken: + return (None, judged) + verdict = min((decision[0] for _root, decision in spoken), key=_SEVERITY.index) + return ((verdict, '\n'.join((f'In {root}:\n{decision[1]}' for root, decision in spoken))), judged) + +GATE_FLAG = '--gate' + +STOP_FLAG = '--stop' + +_GATE_DEPTH_TO_CHOCK = 3 + +_RUNNER_PARTS = ('bin', 'gate.py') + +_PACKAGED_RUNNER = 'gate.py' + +_GIT = 'git' + +_UTF8 = 'utf-8' + +_PATH_ERRORS = 'surrogateescape' + +_DELETED = 'D' + +_RENAMED = 'R' + +VERDICT_DENY = 'deny' + +def gate_path_from_argv(argv): + """The `--gate ` argument a vendored runtime was invoked with, or None.""" + if GATE_FLAG in argv: + index = argv.index(GATE_FLAG) + if index + 1 < len(argv): + return _chock_Path(argv[index + 1]) + return None + +def runner_for(gate): + """The gate runner: beside the gate in a plugin, under .chock/bin in a repository, else None.""" + packaged = gate.resolve().parent / _PACKAGED_RUNNER + if packaged.exists(): + return packaged + parents = gate.resolve().parents + if len(parents) <= _GATE_DEPTH_TO_CHOCK: + return None + runner = parents[_GATE_DEPTH_TO_CHOCK].joinpath(*_RUNNER_PARTS) + return runner if runner.exists() else None + +def writes_from_event(event, root=None): + """The one file this tool call would write, or none when it carries no file text. + + An edit call carries only the text it inserts; judged alone, that fragment has no imports, + no class and no neighbours, so a gate that reads a whole file (a script kind) finds nothing + in it. An edit is therefore judged as the file it would leave: the file on disk with the + call's replacements applied. When that cannot be rebuilt -- the file is unreadable, or the + text to replace is not in it, so the call itself will fail -- the fragment is judged as + before and the turn's end judges what actually landed. A Codex apply_patch call names its + files only inside the patch; each one it adds or updates is judged as the patch leaves it. + """ + patched = patched_files(event, root) + if patched: + return patched + path = getattr(event, 'path', None) + content = created_text(event) + edited = edited_text(event, root) + if path and edited is not None: + return {str(path): edited} + if not path or not isinstance(content, str): + return {} + return {str(path): content} + +def repo_relative(path, root): + """`path` relative to `root` in POSIX form, as scope globs are written; as given when outside `root`.""" + text = str(path) + if root is None: + return text + windows = _chock_os.name == 'nt' or str(root)[1:2] == _DRIVE_COLON + flavour = _chock_PureWindowsPath if windows else _chock_PurePosixPath + try: + return _folded(flavour(str(root)) / text).relative_to(flavour(str(root))).as_posix() + except ValueError: + pass + if windows != (_chock_os.name == 'nt'): + return text + try: + return _chock_Path(root, text).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return text + +def repo_paths(path, root): + """Every repo-relative name a write is judged under: as written, and through symlinks and case.""" + lexical = repo_relative(path, root) + if root is None or (str(root)[1:2] == _DRIVE_COLON) != (_chock_os.name == 'nt'): + return (lexical,) + try: + resolved = _chock_Path(root, str(path)).resolve().relative_to(_chock_Path(root).resolve()).as_posix() + except (OSError, ValueError, RuntimeError): + return (lexical,) + return tuple(dict.fromkeys((lexical, resolved))) + +def changed_paths(repo_root, deadline=None, why=None): + """Every uncommitted path; None (reason appended to `why`) when git gave no answer, [] only outside a repository.""" + deadline, why = (engine_deadline() if deadline is None else deadline, [] if why is None else why) + argv = [_GIT, '-C', str(repo_root), 'status', '--porcelain=v1', '--untracked-files=all', '-z'] + try: + timeout = time_left(deadline, argv) + proc = _chock_subprocess.run(argv, capture_output=True, text=True, encoding=_UTF8, errors=_PATH_ERRORS, timeout=timeout, check=False) + except _chock_subprocess.TimeoutExpired as exc: + why.append(f'git status did not finish within {allowed_seconds(exc)}') + except (OSError, _chock_subprocess.SubprocessError) as exc: + why.append(f'git status could not run ({exc})') + else: + first = ((proc.stderr or '').strip().splitlines() or [''])[0] + if proc.returncode and 'not a git repository' in first: + return [] + if proc.returncode: + why.append(f'git status failed (exit {proc.returncode}): {first}') + if why: + sys.stderr.write(f'chock: {why[-1]}, worktree not checked\n') + return None + fields = iter([field for field in (proc.stdout or '').split('\x00') if field]) + paths = [] + for field in fields: + status, path = (field[:2], field[3:]) + if status.startswith(_RENAMED): + next(fields, None) + if _DELETED not in status and path: + paths.append(path) + return paths + +def writes_from_worktree(repo_root, deadline=None, why=None): + """What this turn actually left on disk, however it was written; None when it could not be listed. + + The write path sees only writes it recognises; a shell heredoc carries no file argument. + Reading final state is what makes that stop mattering, so this deliberately does not care + which tool produced the bytes. + """ + paths = changed_paths(repo_root, deadline, why) + if paths is None: + return None + writes = {} + for path in paths: + try: + writes[path] = _chock_Path(repo_root, path).read_text(encoding='utf-8') + except (OSError, UnicodeDecodeError): + continue + return writes + +def run_gate(gate, writes, event, run_in, deadline): + """Ask the vendored runner. Returns (outcome, message) and never decides for itself. + + `run_in` is (root, extra): the working directory, and the stdin keys `extra` adds: `added` (per + edited path, only the text the edit introduces; a runner that predates it judges the whole file, + which only ever refuses more) and the script `session`. The runner gets what is left of `deadline`. + """ + root, extra = run_in + runner = runner_for(gate) + if runner is None: + return (GATE_ERRORED, 'the vendored gate runner is not installed beside this gate') + argv = [sys.executable, str(runner), 'run', '--gate', str(gate), '--event', event] + try: + proc = _chock_subprocess.run(argv, input=json.dumps({'writes': writes, **(extra or {})}), capture_output=True, text=True, encoding=_UTF8, errors='replace', timeout=time_left(deadline, argv), check=False, cwd=str(root) if root is not None else None) + except _chock_subprocess.TimeoutExpired as exc: + return (GATE_ERRORED, f'the gate runner gave no verdict within {allowed_seconds(exc)}') + except (OSError, _chock_subprocess.SubprocessError) as exc: + return (GATE_ERRORED, str(exc)) + return runner_outcome(proc.returncode, proc.stderr) + +_EVENT_ARG = {'pre_tool': 'pre-tool-use', 'stop': 'stop'} + +PRE_TOOL = 'pre_tool' + +def root_for(gate): + """The repository this compiled gate belongs to, or None when the layout is not that.""" + parents = gate.resolve().parents + if len(parents) <= _GATE_DEPTH_TO_CHOCK: + return None + if parents[_GATE_DEPTH_TO_CHOCK - 1].name != 'compiled' or parents[_GATE_DEPTH_TO_CHOCK].name != '.chock': + return None + return parents[_GATE_DEPTH_TO_CHOCK].parent + +def repo_root_for(event, gate): + """The repository under judgement: the compiled layout's root, else the event's cwd, else its workspace root. + + Of several workspace roots, the one holding the written path; this process's cwd only when there are none. + """ + root = root_for(gate) + if root is not None: + return root + cwd = getattr(event, 'cwd', None) or workspace_root(event) + return _chock_Path(cwd) if cwd else _chock_Path.cwd() + +def writes_for(event, gate, deadline=None, why=None, root=None): + """What this event puts under judgement: the call's own text, or what the turn left behind (None: unlisted).""" + root = repo_root_for(event, gate) if root is None else root + if event.event == PRE_TOOL: + return writes_from_event(event, root) + return writes_from_worktree(root, deadline, why) + +def _missing_gate(gate): + """A gate the hook names but that is not on disk: a broken install, so a refusal that says so.""" + return (VERDICT_DENY, f'chock gate {gate} is missing, so this write cannot be checked. Run `chock sync --repo .` to rebuild the compiled gates.') + +def _gate_says(gate, event, name, deadline, root): + """(decision, judged files): what the compiled gate says about this event in `root`, before a re-entered stop.""" + if not gate.exists(): + return (_missing_gate(gate), {}) + outside = outside_globs(gate) + why = [] + listed = writes_for(event, gate, deadline, why, root) + if listed is None: + return (gate_decision(GATE_ERRORED, why[-1] if why else 'git status gave no answer', gate), {}) + writes = judged_files(listed, root, outside, lambda path: repo_paths(path, root)) + if not writes: + return (None, writes) + added = {**patch_added(event), **added_from_event(event)} if event.event == PRE_TOOL else {} + added = judged_files(added, root, outside, lambda path: repo_paths(path, root)) + added = {path: text for path, text in added.items() if path in writes} + extra = {**({'added': added} if added else {}), 'session': session_for(event, root)} + outcome, message = run_gate(gate, writes, name, (root, extra), deadline) + return (gate_decision(outcome, message, gate), writes) + +def evaluate_gate(argv, event): + """The decision this event earns from a compiled gate, or None when it has nothing to say.""" + gate = gate_path_from_argv(argv) + name = _EVENT_ARG.get(getattr(event, 'event', '')) + if gate is None or name is None: + return None + deadline = engine_deadline() + if event.event == PRE_TOOL: + return _gate_says(gate, event, name, deadline, repo_root_for(event, gate))[0] + roots = [repo_root_for(event, gate)] if root_for(gate) else stop_roots(event, repo_root_for(event, gate)) + decision, judged = per_root([(root, *_gate_says(gate, event, name, deadline, root)) for root in roots]) + return settle_stop(event, roots[0], gate, decision, judged) + +TOOL_CALL_FLAG = '--tool-call' + +RECORD_FLAG = '--record' + +TOOL_CALL_EVENT = 'tool_call' + +_TOOL_CALL_PRE = 'pre_tool' + +_TOOL_CALL_POST = ('post_tool', 'tool_failure') + +_TOOL_CALL_SCRIPT_KIND = 'script' + +_TOOL_CALL_REGEX_KIND = 'content_regex' + +_TOOL_CALL_BLOCKED = 'blocked' + +_TOOL_CALL_NEEDS_SESSION = frozenset({_TOOL_CALL_SCRIPT_KIND}) + +_TOOL_CALL_EXIT_VERDICTS = {0: None, 1: 'deny', 3: 'escalate', 4: 'warn'} + +_TOOL_CALL_CEILING = {'block': 'deny', 'ask': 'escalate', 'warn': 'warn'} + +_TOOL_CALL_RANK = {'warn': 0, 'escalate': 1, 'deny': 2} + +_TOOL_CALL_UNDECIDED = ' -- refusing rather than allowing what it never judged' + +def _flag_path(argv, flag): + if flag in argv: + index = argv.index(flag) + if index + 1 < len(argv): + return _chock_Path(argv[index + 1]) + return None + +def tool_call_matches(globs, tool): + """Whether `tool` (a tool name, exact case) matches any glob; no globs match nothing.""" + return any((_chock_fnmatch.fnmatchcase(tool, str(glob)) for glob in globs)) + +def _tool_call_refusal(spec, spoken): + return spoken or str(spec.get('message') or '').strip() or 'blocked by a chock tool_call gate' + +def _tool_call_regex(spec, tool_input): + """`deny` verdict when the input, as JSON text, matches the gate's `content_pattern`.""" + text = json.dumps(tool_input, sort_keys=True, ensure_ascii=False) + if _chock_re.search(str((spec.get('params') or {}).get('content_pattern', '')), text): + return ('deny', _tool_call_refusal(spec, '')) + return None + +def _tool_call_script(spec, root, payload, deadline=None): + """Run the policy's script on `payload`; a missing script, crash or timeout refuses.""" + named = str((spec.get('params') or {}).get('script', '')) + script = _chock_Path(root) / named + if not script.is_file(): + return ('deny', f'script gate: {named!r} is not installed{_TOOL_CALL_UNDECIDED}') + deadline = engine_deadline() if deadline is None else deadline + argv = [sys.executable, str(script)] + try: + proc = _chock_subprocess.run(argv, input=json.dumps(payload), capture_output=True, text=True, encoding='utf-8', errors='replace', cwd=str(root), timeout=time_left(deadline, argv), check=False) + except _chock_subprocess.TimeoutExpired as exc: + budget = f'gave no verdict within {allowed_seconds(exc)}' + return ('deny', f'script gate: {script.name} {budget}{_TOOL_CALL_UNDECIDED}') + except OSError as exc: + return ('deny', f'script gate: {script.name} could not run ({exc}){_TOOL_CALL_UNDECIDED}') + spoken = ((proc.stderr or '') + (proc.stdout or '')).strip() + if proc.returncode in _TOOL_CALL_EXIT_VERDICTS: + verdict = _TOOL_CALL_EXIT_VERDICTS[proc.returncode] + return None if verdict is None else (verdict, spoken or f'blocked by {script.name}') + first = spoken.splitlines()[0] if spoken else '' + detail = f': {first}' if first else '' + return ('deny', f'script gate: {script.name} exited {proc.returncode}{detail}{_TOOL_CALL_UNDECIDED}') + +def _tool_call_verdict(spec, root, event, deadline): + """None to allow, else (verdict, message) for this call; a kind a tool call cannot answer refuses.""" + tool, tool_input = (str(event.tool or ''), tool_call_input(event)) + kind = spec.get('kind') + if kind == _TOOL_CALL_REGEX_KIND: + return _tool_call_regex(spec, tool_input) + if kind == _TOOL_CALL_SCRIPT_KIND: + payload = {'event': TOOL_CALL_EVENT, 'repo_root': str(root), 'tool': tool, 'input': tool_input, 'session': session_for(event, root)} + return _tool_call_script(spec, root, payload, deadline) + return ('deny', f'chock tool_call gate: kind {kind!r} cannot judge a tool call{_TOOL_CALL_UNDECIDED}') + +def _tool_call_capped(spec, verdict): + """`verdict` held to the gate's declared action; an unknown action keeps the verdict as spoken.""" + ceiling = _TOOL_CALL_CEILING.get(spec.get('action', 'block')) + if verdict is None or ceiling is None: + return verdict + return (min(verdict[0], ceiling, key=_TOOL_CALL_RANK.__getitem__), verdict[1]) + +def _tool_call_spec(gate): + """The compiled gate as a dict, or (None, refusal) when the install is broken.""" + if not gate.exists(): + return (None, f'chock gate {gate} is missing, so this tool call cannot be checked. Run `chock sync --repo .` to rebuild the compiled gates.') + try: + return (json.loads(gate.read_text(encoding='utf-8')), None) + except (OSError, ValueError) as exc: + return (None, f'chock could not read {gate}: {exc}. Refusing rather than reporting an allow it never established.') + +def evaluate_tool_call(argv, event): + """The decision a tool_call gate earns this call, or None; also logs the call for the session. + + `--tool-call ` judges a PreToolUse call and, for a gate whose check reads the session, + logs it after the verdict, so the script reads prior calls only. `--record ` logs what + a call came to, after it ran (a vendor's PostToolUse and failure events). + """ + recording = _flag_path(argv, RECORD_FLAG) + if recording is not None: + if event.event in _TOOL_CALL_POST: + session_record(repo_root_for(event, recording), event, 'post', session_outcome(event)) + return None + gate = _flag_path(argv, TOOL_CALL_FLAG) + if gate is None or event.event != _TOOL_CALL_PRE: + return None + spec, broken = _tool_call_spec(gate) + if spec is None: + return ('deny', broken) + root = repo_root_for(event, gate) + verdict = None + if TOOL_CALL_EVENT in spec.get('on', []) and tool_call_matches((spec.get('params') or {}).get('tools', []), str(event.tool or '')): + verdict = _tool_call_capped(spec, _tool_call_verdict(spec, root, event, engine_deadline())) + if spec.get('kind') in _TOOL_CALL_NEEDS_SESSION: + session_record(root, event, 'pre', _TOOL_CALL_BLOCKED if verdict and verdict[0] == 'deny' else None) + return verdict +_CHOCK_UNREADABLE = ( + "chock could not read this hook's payload, so it could not check the call. " + "Refusing rather than reporting an allow it never established." +) + +#: A payload naming only this vendor's own wire event, so `respond` picks that event's grammar. +_CHOCK_WIRE_RAW = {PRE_TOOL: {'event_type': 'PreToolUse'}, STOP: {'event_type': 'Stop'}} + +#: The words a client reads as a refusal, in any of the answer fields a vendor grammar uses. +_CHOCK_REFUSING = ("deny", "block", "ask", "continue") +_CHOCK_ANSWER_KEYS = ("decision", "permission", "permissionDecision") + + +def _chock_spec_action(path): + try: + action = json.loads(path.read_text(encoding="utf-8")).get("action") + except Exception: + return "block" + return action if action in ("ask", "warn") else "block" + + +def _chock_declared(): + """(strictest action, event, gate) of the judging flags; (None, None, None) where this run judges nothing.""" + argv = sys.argv[1:] + actions = ["block"] if guard_path_from_argv(argv) is not None else [] + event = PRE_TOOL + gate = _flag_path(argv, GATE_FLAG) + if gate is not None: + actions.append(_chock_spec_action(gate)) + event = STOP if STOP_FLAG in argv or gate.parent.name == _EVENT_ARG[STOP] else PRE_TOOL + called = _flag_path(argv, TOOL_CALL_FLAG) + if called is not None: + actions.append(_chock_spec_action(called)) + for action in ("block", "ask", "warn"): + if action in actions: + return action, event, gate + return None, None, None + + +def _chock_refuses(text, code): + if code == 2: + return True + try: + body = json.loads(text) + except Exception: + return False + if not isinstance(body, dict): + return False + nested = body.get("hookSpecificOutput") + answers = [body] + ([nested] if isinstance(nested, dict) else []) + return any(part.get(key) in _CHOCK_REFUSING for part in answers for key in _CHOCK_ANSWER_KEYS) + + +def _chock_unreadable_stop(gate): + """The bounded verdict of an unreadable Stop: a refusal, or a warning once the cap is spent.""" + root = root_for(gate) or _chock_Path.cwd() + return settle_unreadable_stop(root, gate.parent.parent.name, _CHOCK_UNREADABLE) + + +def _chock_unreadable(): + action, kind, gate = _chock_declared() + if action in (None, "warn"): + return "", 0 + event = Event(AGENT, kind, raw=dict(_CHOCK_WIRE_RAW[kind])) + reason = _CHOCK_UNREADABLE + if kind == STOP and gate is not None: + verdict, reason = _chock_unreadable_stop(gate) + if verdict == VERDICT_WARN: + return respond(degrade(Decision.warn(reason), event), event) + _report(reason + "\n") + decision = Decision.escalate(reason) if action == "ask" else Decision.deny(reason) + text, code = respond(degrade(decision, event), event) + return (text, code) if _chock_refuses(text, code) else ("", 2) +#: Per wire event mapped to pre_tool: whether the client shows a guard's ask as a prompt. +_CHOCK_CAN_ASK = {'PreToolUse': False} +_CHOCK_EVENT_KEYS = ('event_type', 'hook_event_name', 'hookEventName', 'agent_action_name') + + +def _chock_wire_name(raw): + names = (raw.get(key) for key in _CHOCK_EVENT_KEYS) if isinstance(raw, dict) else () + return next((name for name in names if isinstance(name, str)), None) + + +def _chock_can_ask(raw): + # An event this bundle cannot name, or one the vendor data does not list, cannot prompt. + return _CHOCK_CAN_ASK.get(_chock_wire_name(raw), False) + + +def _spoken(verdict): + outcome, reason = verdict + if outcome == VERDICT_WARN: + return Decision.warn(reason) + return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + + +def _judge(event): + if event.event == "pre_tool" and event.command: + verdict = evaluate(sys.argv[1:], event.command, event.tool or "", can_ask=_chock_can_ask(event.raw)) + if verdict is not None: + outcome, reason = verdict + return Decision.escalate(reason) if outcome == ESCALATE else Decision.deny(reason) + gated = evaluate_gate(sys.argv[1:], event) + if gated is not None: + return _spoken(gated) + called = evaluate_tool_call(sys.argv[1:], event) + if called is not None: + return _spoken(called) + return None + + +def handle(event): + # A door that cannot decide refuses. An exception escaping here would exit the hook + # with a traceback, which every client reads as a non-blocking error: fail-open, with + # the reason on a stderr nobody watches. The refusal carries the reason instead. + try: + return _judge(event) + except Exception as exc: # noqa: BLE001 -- any failure here must refuse, never fall through + return Decision.deny( + "chock could not check this call (%s: %s). Refusing rather than reporting an " + "allow it never established." % (type(exc).__name__, exc) + ) +# <<< 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 _report(text): + # Best effort, never raising: a diagnostic must not pre-empt the refusal it accompanies. + # A hook's stderr is whatever the host gave it -- closed (then sys.stderr is None, and + # the traceback module's default print would land on STDOUT, inside the verdict), a + # console code page that cannot hold the payload text quoted in the exception, a pipe + # nobody reads. + try: + sys.stderr.write(text) + sys.stderr.flush() + except Exception: + return + + +_STDERR_FD = 2 + + +def _divert_fd1(): + """Point fd 1 at stderr (devnull if there is none); the saved fd 1, or None if it could not.""" + try: + saved = os.dup(1) + except OSError: + return None + # With fd 2 closed, dup() hands back 2 itself, and "stderr" would be stdout again. + if saved > _STDERR_FD: + with contextlib.suppress(OSError): + os.dup2(_STDERR_FD, 1) + return saved + sink = os.open(os.devnull, os.O_WRONLY) + os.dup2(sink, 1) + os.close(sink) + return saved + + +def _flush(streams): + """Flush each stream that exists; a broken one is no reason to lose the verdict.""" + for stream in streams: + if stream is not None: + with contextlib.suppress(Exception): + stream.flush() + + +@contextlib.contextmanager +def _stdout_to_stderr(): + # Stdout is the verdict channel. A handler's stray print (or a child process it runs) + # ahead of the JSON makes the host fail to parse it, and Claude Code and Gemini CLI then + # treat the hook as a non-blocking error: a deny became an allow, witnessed live. + sink = sys.stderr if sys.stderr is not None else io.StringIO() + held = (sys.stdout, sys.__stdout__) + _flush(held) + saved = _divert_fd1() + try: + with contextlib.redirect_stdout(sink): + yield + finally: + if saved is not None: + # A stream the handler held on to (sys.__stdout__, a reference cached at import) + # buffers past redirect_stdout; flushed now it lands on stderr, not after the verdict. + _flush((sink, *held)) + os.dup2(saved, 1) + os.close(saved) + + +def _decide(raw): + """(stdout_text, exit_code) for one decoded payload: agentseam.dispatch.handle(), inlined.""" + event = parse(raw) + 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. + return "", 0 + try: + with _stdout_to_stderr(): + result = handle(event) + decision = _coerce(result) + except Exception: + # A door that cannot decide refuses. Escaping here would exit 1 with a traceback, + # which every host reads as a non-blocking error and allows past; instead the + # failure is printed for the operator and the gate answers deny in 'openhands''s + # own dialect. Only the failure's class is named to the host: its message may quote + # the very payload content the policy was inspecting. + _report(traceback.format_exc()) + decision = Decision.deny( + "policy handler failed (%s); refusing rather than allowing what it could not judge" + % sys.exc_info()[0].__name__ + ) + return respond(degrade(decision, event), event) + + +def main(stdin=None, stdout=None, exit=True): + """Read one payload from stdin, dispatch, emit the 'openhands' 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: + raw = None + if not isinstance(raw, dict): + text, code = _chock_unreadable() + if text: + _emit(out, text) + if exit: + sys.exit(code) + return code + try: + text, code = _decide(raw) + except Exception: + # Past the handler, which _decide() already answers for: a fault in this file on a + # payload it did decode. With no Event to answer in dialect, the one refusal left is + # the host's blocking exit code, and nothing on stdout to be misread as a verdict. + _report(traceback.format_exc()) + text, code = "", 2 + 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 index 6d7f192c..e490c345 100644 --- a/tests/fixtures/runtime_goldens/tabnine.py +++ b/tests/fixtures/runtime_goldens/tabnine.py @@ -29,6 +29,7 @@ from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings +import zlib as _zlib # ------------------------------------------------------------------------------ # contract (agentseam 0.3.5) @@ -248,12 +249,22 @@ def _rejected_by_markers(c, raw): for marker in c.get("reject_markers", ()): if marker in raw: return True + for key, values in c.get("reject_values", {}).items(): + if key in raw and raw[key] in values: + return True for probe, markers in c.get("reject_markers_unless_probe", {}).items(): if any(marker in raw for marker in markers) and not PROBES[probe](raw): return True return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) +def _accepted_by_values(c, raw): + """True unless the entry names accepted values and the payload carries none of them.""" + values = c.get("accept_values") + return not values or any(key in raw and raw[key] in allowed for key, allowed in values.items()) + def _accepted_by_markers(c, raw, name): + if not _accepted_by_values(c, raw): + return False accept = c.get("accept_markers", ()) if not accept or any(marker in raw for marker in accept): return True @@ -292,12 +303,25 @@ def _segment(node, part): 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 _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + def _walk(node, path): - """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None if "[]." in path: head, sub = path.split("[].", 1) - items = _walk(node, head) - if not isinstance(items, (list, tuple)): + items = _list_at(node, head) + if items is None: return None joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) return joined or None @@ -327,7 +351,7 @@ def _tool_input_raw(cfg, raw): if not isinstance(raw, dict): return None for key in cfg["fields"].get("tool_input", ("tool_input",)): - value = raw.get(key) + value = _walk(raw, key) if value is not None: return value return None @@ -518,6 +542,8 @@ def hj_respond(cfg, decision, event, wire=None): return "", 0 if gate["grammar"] == "G2": return _g2(v, gate, decision, name) + if gate["grammar"] == "G7": + return _g7(v, decision, wire) return _g1(v, gate, decision, wire, name) _WINDOWS_KEYS = ("commandWindows", "windows") @@ -533,16 +559,25 @@ def _hook_dict(cfg, command): entry[key] = value return entry +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): rules = [] for ev in canonical_events: name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: - rule["matcher"] = matcher - rules.append(rule) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) return rules def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): @@ -571,6 +606,10 @@ def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): hooks.setdefault(extra, []).append({"command": command}) return {"hooks": hooks} +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] hooks = {} @@ -601,6 +640,8 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) if wrapper == "flat_entries": return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) return _default_wrapper(cfg, reverse, canonical_events, command, matcher) diff --git a/tests/fixtures/runtime_goldens/vscode_copilot.py b/tests/fixtures/runtime_goldens/vscode_copilot.py index c7c3a84a..82eaeddf 100644 --- a/tests/fixtures/runtime_goldens/vscode_copilot.py +++ b/tests/fixtures/runtime_goldens/vscode_copilot.py @@ -316,7 +316,7 @@ def _name(raw): def claims(raw): """True for a payload from either product.""" - if not isinstance(raw, dict): + if not isinstance(raw, dict) or _is_grok_build(raw): return False name = _name(raw) if name in EVENT_MAP and _VSCODE_ENVELOPE in raw and "turn_id" not in raw: @@ -334,6 +334,12 @@ def claims(raw): return raw.get("tool_name") in MEMORY_TOOLS and isinstance(ti, dict) and "command" in ti +def _is_grok_build(raw): + """Grok Build's envelope: hookEventName is hook_event_name's snake_case twin (pre_tool_use, PreToolUse).""" + snake, pascal = raw.get("hookEventName"), raw.get("hook_event_name") + return isinstance(pascal, str) and str(snake).islower() and str(snake).replace("_", "") == pascal.lower() + + def _tool_input(raw): """The tool's arguments: VS Code's `tool_input`, else the CLI's `toolArgs` (object or JSON text).""" ti = raw.get("tool_input") diff --git a/tests/fixtures/runtime_goldens/windsurf.py b/tests/fixtures/runtime_goldens/windsurf.py index 586e0904..4da24d18 100644 --- a/tests/fixtures/runtime_goldens/windsurf.py +++ b/tests/fixtures/runtime_goldens/windsurf.py @@ -29,6 +29,7 @@ from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings +import zlib as _zlib # ------------------------------------------------------------------------------ # contract (agentseam 0.3.5) @@ -253,12 +254,25 @@ def _segment(node, part): 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 _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + def _walk(node, path): - """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None if "[]." in path: head, sub = path.split("[].", 1) - items = _walk(node, head) - if not isinstance(items, (list, tuple)): + items = _list_at(node, head) + if items is None: return None joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) return joined or None @@ -288,7 +302,7 @@ def _tool_input_raw(cfg, raw): if not isinstance(raw, dict): return None for key in cfg["fields"].get("tool_input", ("tool_input",)): - value = raw.get(key) + value = _walk(raw, key) if value is not None: return value return None @@ -406,16 +420,25 @@ def _hook_dict(cfg, command): entry[key] = value return entry +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): rules = [] for ev in canonical_events: name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: - rule["matcher"] = matcher - rules.append(rule) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) return rules def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): @@ -444,6 +467,10 @@ def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): hooks.setdefault(extra, []).append({"command": command}) return {"hooks": hooks} +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] hooks = {} @@ -474,6 +501,8 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) if wrapper == "flat_entries": return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) return _default_wrapper(cfg, reverse, canonical_events, command, matcher) _MCP_EVENTS = ("pre_mcp_tool_use", "post_mcp_tool_use") diff --git a/tests/runtime_payloads.py b/tests/runtime_payloads.py new file mode 100644 index 00000000..b2efa3cb --- /dev/null +++ b/tests/runtime_payloads.py @@ -0,0 +1,128 @@ +"""One shell pre-tool payload per vendored runtime, and the deny word each one's wire carries.""" + +from __future__ import annotations + +PAYLOADS = { + "claude_code": lambda c: { + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": c}, + "session_id": "s", + "transcript_path": "/t", + "permission_mode": "default", + }, + "vscode_copilot": lambda c: { + "hook_event_name": "PreToolUse", + "timestamp": "2026-08-31T00:00:00Z", + "tool_name": "Bash", + "tool_input": {"command": c}, + "tool_use_id": "1", + }, + "cursor": lambda c: { + "hook_event_name": "beforeShellExecution", + "conversation_id": "a", + "generation_id": "b", + "cursor_version": "3.17.8", + "workspace_roots": ["/x"], + "command": c, + "cwd": "/x", + "sandbox": False, + }, + "codex_cli": lambda c: { + "hook_event_name": "PreToolUse", + "turn_id": "t1", + "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 next four: agentseam's golden pre_tool payloads (tests/fixtures/golden at bebbfa6), shell tool. + "factory_droid": lambda c: { + "hook_event_name": "PreToolUse", + "permission_mode": "auto-medium", + "session_id": "s", + "transcript_path": "/x/.factory/projects/x/session.jsonl", + "cwd": "/x", + "tool_name": "Execute", + "tool_input": {"command": c}, + }, + "goose": lambda c: { + "event": "PreToolUse", + "matcher_context": "shell", + "session_id": "s", + "tool_call_id": "call-1", + "tool_name": "shell", + "tool_input": {"command": c}, + "working_dir": "/x", + }, + "openhands": lambda c: { + "event_type": "PreToolUse", + "session_id": "s", + "tool_name": "terminal", + "tool_input": {"command": c}, + "working_dir": "/x", + }, + "mistral_vibe": lambda c: { + "hook_event_name": "pre_tool", + "parent_session_id": None, + "session_id": "s", + "tool_call_id": "call-1", + "tool_name": "bash", + "tool_input": {"command": c}, + "transcript_path": "/x/.vibe/session.jsonl", + "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", + "factory_droid": "deny", + "goose": "block", + "openhands": "deny", + "mistral_vibe": "deny", +} diff --git a/tests/test_generic_hooks_install.py b/tests/test_generic_hooks_install.py index 49f769c4..40065697 100644 --- a/tests/test_generic_hooks_install.py +++ b/tests/test_generic_hooks_install.py @@ -4,6 +4,7 @@ import json import sys +import tomllib from pathlib import Path import pytest @@ -33,7 +34,8 @@ def _repo(tmp_path: Path) -> Path: def _config(repo: Path, vendor: str) -> dict: - return json.loads((repo / vendors.config_path(vendor)).read_text(encoding="utf-8")) + text = (repo / vendors.config_path(vendor)).read_text(encoding="utf-8") + return tomllib.loads(text) if vendors.config_format(vendor) == "toml" else json.loads(text) def _commands(node) -> list[str]: diff --git a/tests/test_guard_can_ask.py b/tests/test_guard_can_ask.py index bdc15306..8ae3e323 100644 --- a/tests/test_guard_can_ask.py +++ b/tests/test_guard_can_ask.py @@ -21,6 +21,8 @@ #: The plan's table (org-plan plan/one-oh-gaps.md C1b): each vendor's value at its shell pre-tool event. PLANNED = {"claude_code": "1", "vscode_copilot": "1", "cursor": "1", "codex_cli": "0", "devin": "0"} +#: E-ALL's agents: agentseam records `honours_escalate: false` at each one's pre-tool gate (bebbfa6). +PLANNED.update(dict.fromkeys(("factory_droid", "goose", "mistral_vibe", "openhands"), "0")) _HOOK = {"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": "ls"}} _CURSOR = {"command": "ls", "cwd": ".", "conversation_id": "c", "workspace_roots": ["."]} @@ -34,6 +36,10 @@ "tabnine": ("BeforeTool", {**_HOOK, "hook_event_name": "BeforeTool"}), "vscode_copilot": ("PreToolUse", _HOOK), "windsurf": ("pre_run_command", {"agent_action_name": "pre_run_command", "tool_info": {"command_line": "ls"}}), + "factory_droid": ("PreToolUse", {**_HOOK, "tool_name": "Execute", "permission_mode": "auto-medium"}), + "goose": ("PreToolUse", {"event": "PreToolUse", "tool_name": "shell", "tool_input": {"command": "ls"}}), + "mistral_vibe": ("pre_tool", {**_HOOK, "hook_event_name": "pre_tool", "tool_name": "bash", "tool_call_id": "1"}), + "openhands": ("PreToolUse", {"event_type": "PreToolUse", "tool_name": "terminal", "tool_input": {"command": "ls"}}), } #: Cursor events beyond the shell one: the value follows the event that fired, not the agent. CURSOR_EVENTS = [ diff --git a/tests/test_guard_fail_to_ask.py b/tests/test_guard_fail_to_ask.py index 21886745..fd12ede4 100644 --- a/tests/test_guard_fail_to_ask.py +++ b/tests/test_guard_fail_to_ask.py @@ -10,6 +10,7 @@ import pytest from agentseam import contract as _contract +from runtime_payloads import DENY_ON_THE_WIRE, PAYLOADS from chock import evidence from chock.gate import budget, guard_runner, runtime_bundle @@ -34,92 +35,6 @@ def make_guard(tmp_path: Path, name: str, body: str) -> Path: return guard -PAYLOADS = { - "claude_code": lambda c: { - "hook_event_name": "PreToolUse", - "tool_name": "Bash", - "tool_input": {"command": c}, - "session_id": "s", - "transcript_path": "/t", - "permission_mode": "default", - }, - "vscode_copilot": lambda c: { - "hook_event_name": "PreToolUse", - "timestamp": "2026-08-31T00:00:00Z", - "tool_name": "Bash", - "tool_input": {"command": c}, - "tool_use_id": "1", - }, - "cursor": lambda c: { - "hook_event_name": "beforeShellExecution", - "conversation_id": "a", - "generation_id": "b", - "cursor_version": "3.17.8", - "workspace_roots": ["/x"], - "command": c, - "cwd": "/x", - "sandbox": False, - }, - "codex_cli": lambda c: { - "hook_event_name": "PreToolUse", - "turn_id": "t1", - "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", -} - - def run(runtimes: dict[str, Path], agent: str, guard: Path, command: str) -> subprocess.CompletedProcess: """Errors on DeprecationWarning: no path may lean on an agentseam spelling 0.2.0 retired.""" payload = json.dumps(PAYLOADS[agent](command)).encode("utf-8") diff --git a/tests/test_init_preserves_adopter_files.py b/tests/test_init_preserves_adopter_files.py index 9bf1440c..f96454db 100644 --- a/tests/test_init_preserves_adopter_files.py +++ b/tests/test_init_preserves_adopter_files.py @@ -54,29 +54,29 @@ def test_content_outside_the_marker_survives_a_second_init(onboarded: Path) -> N def test_a_deselected_wrapper_with_edits_is_not_deleted(tmp_path: Path) -> None: """Deleting a file the adopter added content to destroys it as surely as overwriting it.""" repo = init_repo(tmp_path) - assert _init(repo, "--agents", "grok") == 0 - grok_md = repo / ".grok" / "GROK.md" - _append(grok_md) + assert _init(repo, "--agents", "junie") == 0 + wrapper = repo / ".junie" / "guidelines.md" + _append(wrapper) assert _init(repo, "--agents", "claude") == 0 - assert grok_md.exists(), "init deleted an adopter-edited wrapper" - after = grok_md.read_text(encoding="utf-8") + assert wrapper.exists(), "init deleted an adopter-edited wrapper" + after = wrapper.read_text(encoding="utf-8") assert "never_deploy(on: friday)" in after, "the adopter's own content was deleted along with chock's block" - assert "Authoritative rules and conventions" not in after, "chock's block should be gone once grok is deselected" + assert "Authoritative rules and conventions" not in after, "chock's block should be gone once junie is deselected" def test_a_wrapper_with_no_adopter_content_is_deleted_on_deselect(tmp_path: Path) -> None: """The mirror case: nothing but chock's own block is an orphan, not adopter content.""" repo = init_repo(tmp_path) - assert _init(repo, "--agents", "grok") == 0 - grok_md = repo / ".grok" / "GROK.md" - assert grok_md.exists() + assert _init(repo, "--agents", "junie") == 0 + wrapper = repo / ".junie" / "guidelines.md" + assert wrapper.exists() assert _init(repo, "--agents", "claude") == 0 - assert not grok_md.exists(), "an orphaned wrapper with no adopter content was left behind" - assert not grok_md.parent.exists(), "its now-empty parent directory was left behind" + assert not wrapper.exists(), "an orphaned wrapper with no adopter content was left behind" + assert not wrapper.parent.exists(), "its now-empty parent directory was left behind" def test_the_marker_region_is_refreshed_with_no_force_needed(onboarded: Path) -> None: diff --git a/tests/test_surface_doc_matches_code.py b/tests/test_surface_doc_matches_code.py index 92429a44..31c7edc8 100644 --- a/tests/test_surface_doc_matches_code.py +++ b/tests/test_surface_doc_matches_code.py @@ -31,6 +31,8 @@ "kimi code": "kimi-code", "vs code": "vscode", "antigravity cli": "antigravity", + "factory droid": "factory-droid", + "mistral vibe": "mistral-vibe", } diff --git a/tests/test_sync_every_adapter.py b/tests/test_sync_every_adapter.py new file mode 100644 index 00000000..8cafa795 --- /dev/null +++ b/tests/test_sync_every_adapter.py @@ -0,0 +1,162 @@ +"""E-ALL: `chock sync` reaches every agentseam adapter whose hook config sits in the repository.""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +import pytest +from agentseam import tier_table +from agentseam.vendor_config import VENDOR_CONFIG +from conftest import baseline_policy, init_repo + +from chock import vendors +from chock.hooks import sync_notes +from chock.hooks.in_agent_install import WIRED_VENDORS, installed_policy_ids +from chock.hooks.in_agent_toml import OWNER +from chock.hooks.runtime_vendor import runtime_rel +from chock.scaffold.recompile import HookWiringError, recompile + +#: The agents E-ALL added, by chock agent name. Each answers in its own grammar (tests/test_guard_fail_to_ask.py). +NEW = {"factory-droid": "factory_droid", "goose": "goose", "openhands": "openhands", "mistral-vibe": "mistral_vibe"} +#: Adapters sync does not wire yet, and why: a hooks directory runs one command per event, and a +#: home-anchored config is outside the repository. A new upstream adapter lands in one list or the other. +NOT_WIRED = {"cline": "hook_dir", "junie": "home", "kimi_code": "home"} +POLICIES = ("block-destructive-commands", "block-invisible-unicode") + + +def _repo(tmp_path: Path) -> Path: + repo = init_repo(tmp_path) + for policy in POLICIES: + shutil.copytree(baseline_policy(policy), repo / ".agents" / "policies" / policy) + return repo + + +def _tree(repo: Path) -> dict[str, bytes]: + return { + p.relative_to(repo).as_posix(): p.read_bytes() + for p in repo.rglob("*") + if p.is_file() and ".git" not in p.relative_to(repo).parts + } + + +def test_every_adapter_is_a_selectable_agent() -> None: + assert set(VENDOR_CONFIG) <= set(vendors.CHOCK_AGENT.values()) + for name, vendor in NEW.items(): + assert vendors.CHOCK_AGENT[name] == vendor + + +def test_every_adapter_is_wired_or_named_here_with_its_reason() -> None: + unwired = {v for v in VENDOR_CONFIG if v not in WIRED_VENDORS} + assert unwired == set(NOT_WIRED) + for vendor, reason in NOT_WIRED.items(): + home = str(VENDOR_CONFIG[vendor]["config_path"]).startswith("~") + assert (reason == "home") == home + assert (reason == "hook_dir") == (vendors.config_format(vendor) == "hook_dir") + + +@pytest.mark.parametrize("name", sorted(NEW)) +def test_sync_writes_the_vendors_documented_hook_file(name: str, tmp_path: Path) -> None: + vendor = NEW[name] + repo = _repo(tmp_path) + recompile(repo, [name], skip_hooks=False) + + config = repo / vendors.config_path(vendor) + assert config.is_file() + assert (repo / runtime_rel(vendor)).is_file() + assert runtime_rel(vendor).as_posix() in config.read_text(encoding="utf-8") + assert set(POLICIES) <= installed_policy_ids(repo, vendor) + + +def test_goose_gets_chocks_own_plugin_directory() -> None: + assert vendors.config_path("goose") == ".agents/plugins/chock/hooks/hooks.json" + + +@pytest.mark.parametrize("name", sorted(NEW)) +def test_a_second_sync_changes_nothing(name: str, tmp_path: Path) -> None: + repo = _repo(tmp_path) + recompile(repo, [name], skip_hooks=False) + before = _tree(repo) + + recompile(repo, [name], skip_hooks=False) + + assert _tree(repo) == before + + +def _foreign_json(vendor: str) -> dict: + """A hook entry chock does not own, in the vendor's own document shape.""" + return vendors.pre_tool_hook_config(vendor, "./their-own-hook.sh", "Shell") + + +@pytest.mark.parametrize("name", sorted(n for n, v in NEW.items() if vendors.config_format(v) == "json")) +def test_a_strangers_json_entry_survives_sync_and_deselect(name: str, tmp_path: Path) -> None: + vendor = NEW[name] + repo = _repo(tmp_path) + config = repo / vendors.config_path(vendor) + config.parent.mkdir(parents=True, exist_ok=True) + config.write_text(json.dumps(_foreign_json(vendor)), encoding="utf-8") + + recompile(repo, [name], skip_hooks=False) + assert "./their-own-hook.sh" in config.read_text(encoding="utf-8") + + recompile(repo, ["claude"], skip_hooks=False) + assert json.loads(config.read_text(encoding="utf-8")) == _foreign_json(vendor) + assert not (repo / runtime_rel(vendor)).exists() + + +FOREIGN_RULE = '[[hooks]]\nname = "theirs"\ntype = "pre_tool"\ncommand = "./their-own-hook.sh"\n' + + +def test_vibe_rules_are_one_owned_block_beside_a_strangers_rule(tmp_path: Path) -> None: + repo = _repo(tmp_path) + config = repo / vendors.config_path("mistral_vibe") + config.parent.mkdir(parents=True) + config.write_text(FOREIGN_RULE, encoding="utf-8") + + recompile(repo, ["mistral-vibe"], skip_hooks=False) + text = config.read_text(encoding="utf-8") + assert text.startswith(FOREIGN_RULE) + assert text.count(f">>> agentseam >>> {OWNER}") == 1 + assert "timeout = 45" in text, "the vendor's documented timeout field carries chock's budget" + + recompile(repo, ["claude"], skip_hooks=False) + assert config.read_text(encoding="utf-8") == FOREIGN_RULE + + +def test_vibe_file_chock_alone_wrote_is_removed_on_deselect(tmp_path: Path) -> None: + repo = _repo(tmp_path) + recompile(repo, ["mistral-vibe"], skip_hooks=False) + config = repo / vendors.config_path("mistral_vibe") + assert config.is_file() + + recompile(repo, ["claude"], skip_hooks=False) + + assert not config.exists() + assert not (repo / runtime_rel("mistral_vibe")).exists() + + +def test_vibe_refuses_a_file_it_cannot_read(tmp_path: Path) -> None: + repo = _repo(tmp_path) + config = repo / vendors.config_path("mistral_vibe") + config.parent.mkdir(parents=True) + config.write_bytes(b"\xff\xfe not text") + + with pytest.raises(HookWiringError, match="not readable text"): + recompile(repo, ["mistral-vibe"], skip_hooks=False) + + assert config.read_bytes() == b"\xff\xfe not text" + + +@pytest.mark.parametrize("vendor", sorted(NEW.values())) +def test_sync_says_agentseams_row_and_that_the_agent_is_untested(vendor: str) -> None: + row = next(r for r in tier_table.rows() if r["agent"] == vendor) + lines = sync_notes.notes(vendor) + + assert any(row["basis"] in line and row["grade"] in line and row["recorded"] in line for line in lines) + assert any("Untested" in line for line in lines) + + +def test_a_witnessed_agent_is_not_called_untested() -> None: + assert sync_notes.witnessed("vscode_copilot") + assert not any("Untested" in line for line in sync_notes.notes("vscode_copilot")) diff --git a/tests/test_tool_call_gate.py b/tests/test_tool_call_gate.py index 21ed44d2..1dfe20e0 100644 --- a/tests/test_tool_call_gate.py +++ b/tests/test_tool_call_gate.py @@ -13,7 +13,17 @@ from chock.hooks.in_agent_install import WIRED_VENDORS, install_hooks, installed_policy_ids from chock.validation.loading import schema_validator -TOOL_CALL_VENDORS = {"claude_code", "codex_cli", "cursor", "gemini_cli", "vscode_copilot"} +TOOL_CALL_VENDORS = { + "claude_code", + "codex_cli", + "cursor", + "factory_droid", + "gemini_cli", + "goose", + "mistral_vibe", + "openhands", + "vscode_copilot", +} NO_EVIDENCE = {"antigravity", "devin", "grok", "tabnine", "windsurf"} From dddb4f8343d73d03764ed94174c59c5f84417ecd Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:41:47 +0000 Subject: [PATCH 2/8] brand-assets: the agentseam archive's sha256, as CI's pip measured it Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- requirements/brand-assets.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/brand-assets.txt b/requirements/brand-assets.txt index ec32a6c7..c5b6b7e2 100644 --- a/requirements/brand-assets.txt +++ b/requirements/brand-assets.txt @@ -5,7 +5,7 @@ # pip-compile --generate-hashes --no-index --output-file=requirements/brand-assets.txt --strip-extras requirements/brand-assets.in # agentseam @ https://github.com/open-coder-ai/agentseam/archive/bebbfa6f85cc6512238ecad5114c00d99952f664.tar.gz \ - --hash=sha256:0000000000000000000000000000000000000000000000000000000000000000 + --hash=sha256:76fd44a41a826da73f6838fb9eb1fdc4e356c69973ad9fc0cff02b5a82abf196 # via -r requirements/brand-assets.in attrs==26.1.0 \ --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ From 3b46cabbdd751efca5b9bb3fbb104da35d7be258 Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:59:52 +0000 Subject: [PATCH 3/8] install: Cursor is witnessed, drop it from the untested warning (W-CUR) Witnessed on Windows: block-no-verify refused at beforeShellExecution and pin-github-actions refused at the preToolUse write (org-plan witness/results/witness-cursor-2026-10-11.md). The client warning that chock has not witnessed an install now names no client; the test pins that no client gets it. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- changelog.d/cursor-witnessed.md | 4 ++++ src/chock/install/data/warnings.json | 4 +--- tests/test_install_clients.py | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) create mode 100644 changelog.d/cursor-witnessed.md diff --git a/changelog.d/cursor-witnessed.md b/changelog.d/cursor-witnessed.md new file mode 100644 index 00000000..941bcc7b --- /dev/null +++ b/changelog.d/cursor-witnessed.md @@ -0,0 +1,4 @@ +- **Cursor is witnessed: `chock install --selection --client cursor` no longer warns "Untested".** On Windows, + Cursor refused `block-no-verify` at `beforeShellExecution` and `pin-github-actions` at the `preToolUse` write + (org-plan `witness/results/witness-cursor-2026-10-11.md`). The `client` warning that chock has not witnessed + an install now names no client. Cursor's admin-setting warning stays. diff --git a/src/chock/install/data/warnings.json b/src/chock/install/data/warnings.json index c7ef47b0..7ec70bca 100644 --- a/src/chock/install/data/warnings.json +++ b/src/chock/install/data/warnings.json @@ -77,9 +77,7 @@ }, { "kind": "client", - "clients": [ - "cursor" - ], + "clients": [], "says": "Untested: chock has not witnessed an install in this client. Its steps, its hooks and the plugin version string (+) are unmeasured there." } ] diff --git a/tests/test_install_clients.py b/tests/test_install_clients.py index b1b0a1bd..b70b62c5 100644 --- a/tests/test_install_clients.py +++ b/tests/test_install_clients.py @@ -24,8 +24,8 @@ "cursor": ("chock-guardrails", ".cursor-plugin/plugin.json", []), "devin": ("chock-guardrails", ".devin-plugin/plugin.json", []), } -# Clients chock has not witnessed an install in; Claude Code, Codex, Copilot and Devin are witnessed. -UNWITNESSED = ("cursor",) +# Clients chock has not witnessed an install in; every client is witnessed (Cursor: W-CUR, 2026-10-11). +UNWITNESSED: tuple[str, ...] = () BINARY = {"claude-code": "claude", "copilot": "copilot", "codex": "codex", "devin": "devin"} From 9e09724ebd026b932d43f78aa15f0946a13d19f5 Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:01:41 +0000 Subject: [PATCH 4/8] figures: regenerate for 20 agent names (make_*.py, gen_brand_assets.py) Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- docs/assets/social-preview.png | Bin 77847 -> 78859 bytes docs/assets/social-preview.svg | 24 ++++++++++++------------ docs/figures/social-card.png | Bin 24352 -> 25503 bytes docs/figures/social-card.svg | 4 ++-- docs/figures/surfaces-dark.svg | 14 +++++++------- docs/figures/surfaces-light.svg | 14 +++++++------- 6 files changed, 28 insertions(+), 28 deletions(-) diff --git a/docs/assets/social-preview.png b/docs/assets/social-preview.png index 6e7c1cbbdd014677a411435c12fa96d73d7301f9..d6336ce663ef622ff53d04d47b5a9ac57e4f7155 100644 GIT binary patch literal 78859 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 KfclqqpZ*8l1IV!e literal 77847 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 B>!JVv diff --git a/docs/assets/social-preview.svg b/docs/assets/social-preview.svg index 1cfff643..8906638b 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. 20 agents (claude, aider, antigravity, cline, codex, copilot, cursor, devin, factory-droid, gemini, goose, grok, junie, kimi-code, mistral-vibe, openhands, replit, tabnine, vscode, windsurf); 9 enforcement surfaces (ambient-rule, git-hook, ci-gate, pre-tool-use, stop, managed-setting, gateway, mcp-gateway, agent-hooks), of which 4 are installed by chock sync (ambient-rule, ci-gate, git-hook, stop); 10 everyday commands (init, add, install, remove, sync, check, status, enable, disable, bundle). Python 3.11–3.13, Apache-2.0, on PyPI as chock, version 0.15.2."> @@ -26,7 +26,7 @@ AGENTS - 15 + 20 claude @@ -35,22 +35,22 @@ antigravity - codex + cline - copilot + codex - cursor + copilot - devin + cursor - gemini + devin - grok + factory-droid - junie + gemini - kimi-code - + 4 more + goose + + 9 more SURFACES @@ -100,7 +100,7 @@ bundle - 15 + 20 AGENTS 9 SURFACES diff --git a/docs/figures/social-card.png b/docs/figures/social-card.png index 3980c634410402794562b92d39155a573e97d55a..26f93806b50e28888fd92eba8b4d23299ad6b083 100644 GIT binary patch literal 25503 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 E7aMEO&j0`b literal 24352 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 chock - chock: one policy, compiled to every supported coding agent. 15 agents supported. + chock: one policy, compiled to every supported coding agent. 20 agents supported. chock one policy, compiled to every coding agent - 15 + 20 agents supported diff --git a/docs/figures/surfaces-dark.svg b/docs/figures/surfaces-dark.svg index 48836e01..0cc3fdf4 100644 --- a/docs/figures/surfaces-dark.svg +++ b/docs/figures/surfaces-dark.svg @@ -1,6 +1,6 @@ One policy, 9 surfaces - A fan-out diagram: one chock policy compiles into 9 enforcement surfaces. All 15 supported agent names get the advisory ambient rule and the two commit-time gates, git hook and CI gate. 9 also get a native pre-tool-use hook, enforced live in the agent, and 2 (vscode, copilot -- one underlying vendor) get chock's own agent-hooks file, also enforced in-agent. 9 get an end-of-turn hook that reads what the turn wrote: a backstop for what a pre-tool hook cannot see, carrying no coverage grade of its own. Three surfaces are named for honesty though no agent reaches them yet: managed-setting is compiled for Claude but not installed, gateway is modelled but not emitted, and mcp-gateway emits but is not yet credited to any agent. + A fan-out diagram: one chock policy compiles into 9 enforcement surfaces. All 20 supported agent names get the advisory ambient rule and the two commit-time gates, git hook and CI gate. 13 also get a native pre-tool-use hook, enforced live in the agent, and 2 (vscode, copilot -- one underlying vendor) get chock's own agent-hooks file, also enforced in-agent. 13 get an end-of-turn hook that reads what the turn wrote: a backstop for what a pre-tool hook cannot see, carrying no coverage grade of its own. Three surfaces are named for honesty though no agent reaches them yet: managed-setting is compiled for Claude but not installed, gateway is modelled but not emitted, and mcp-gateway emits but is not yet credited to any agent. one policy @@ -10,21 +10,21 @@ ambient-rule - 15 agents + 20 agents advisory only git-hook - 15 agents + 20 agents enforced at commit ci-gate - 15 agents + 20 agents enforced at commit @@ -33,14 +33,14 @@ pre-tool-use - 9 agents + 13 agents enforced in-agent stop - 9 agents + 13 agents backstop, no grade @@ -83,5 +83,5 @@ enforced in-agent absent (reaches no agent yet) - 15 chock agent names map to 14 agentseam vendors (vscode and copilot share vscode_copilot). + 20 chock agent names map to 19 agentseam vendors (vscode and copilot share vscode_copilot). diff --git a/docs/figures/surfaces-light.svg b/docs/figures/surfaces-light.svg index ad367c9d..d9498565 100644 --- a/docs/figures/surfaces-light.svg +++ b/docs/figures/surfaces-light.svg @@ -1,6 +1,6 @@ One policy, 9 surfaces - A fan-out diagram: one chock policy compiles into 9 enforcement surfaces. All 15 supported agent names get the advisory ambient rule and the two commit-time gates, git hook and CI gate. 9 also get a native pre-tool-use hook, enforced live in the agent, and 2 (vscode, copilot -- one underlying vendor) get chock's own agent-hooks file, also enforced in-agent. 9 get an end-of-turn hook that reads what the turn wrote: a backstop for what a pre-tool hook cannot see, carrying no coverage grade of its own. Three surfaces are named for honesty though no agent reaches them yet: managed-setting is compiled for Claude but not installed, gateway is modelled but not emitted, and mcp-gateway emits but is not yet credited to any agent. + A fan-out diagram: one chock policy compiles into 9 enforcement surfaces. All 20 supported agent names get the advisory ambient rule and the two commit-time gates, git hook and CI gate. 13 also get a native pre-tool-use hook, enforced live in the agent, and 2 (vscode, copilot -- one underlying vendor) get chock's own agent-hooks file, also enforced in-agent. 13 get an end-of-turn hook that reads what the turn wrote: a backstop for what a pre-tool hook cannot see, carrying no coverage grade of its own. Three surfaces are named for honesty though no agent reaches them yet: managed-setting is compiled for Claude but not installed, gateway is modelled but not emitted, and mcp-gateway emits but is not yet credited to any agent. one policy @@ -10,21 +10,21 @@ ambient-rule - 15 agents + 20 agents advisory only git-hook - 15 agents + 20 agents enforced at commit ci-gate - 15 agents + 20 agents enforced at commit @@ -33,14 +33,14 @@ pre-tool-use - 9 agents + 13 agents enforced in-agent stop - 9 agents + 13 agents backstop, no grade @@ -83,5 +83,5 @@ enforced in-agent absent (reaches no agent yet) - 15 chock agent names map to 14 agentseam vendors (vscode and copilot share vscode_copilot). + 20 chock agent names map to 19 agentseam vendors (vscode and copilot share vscode_copilot). From 1b1e6e2597584e8f26f907aef9f0ce7f57a9b99f Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:27:02 +0000 Subject: [PATCH 5/8] install: Copilot judges the write before it lands (witnessed CLI 1.0.94) Copilot CLI refused an unpinned workflow create at preToolUse and nothing landed (org-plan witness/results/witness-copilot-2026-10-11.md). copilot's gate_turn_end_only is false, so its gate label reads 'blocks on an agent's file writes and at turn end'; it leaves the no-write-tool warning, and a client note says VS Code agent mode is unmeasured. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- changelog.d/copilot-write-witnessed.md | 4 ++++ src/chock/install/data/install.json | 2 +- src/chock/install/data/warnings.json | 8 +++++++- tests/test_install_clients.py | 6 ++++-- 4 files changed, 16 insertions(+), 4 deletions(-) create mode 100644 changelog.d/copilot-write-witnessed.md diff --git a/changelog.d/copilot-write-witnessed.md b/changelog.d/copilot-write-witnessed.md new file mode 100644 index 00000000..b88dc04f --- /dev/null +++ b/changelog.d/copilot-write-witnessed.md @@ -0,0 +1,4 @@ +- **Copilot's label says the write is judged before it lands.** Copilot CLI 1.0.94 on Windows refused an + unpinned workflow `create` at `preToolUse`, and nothing landed (org-plan + `witness/results/witness-copilot-2026-10-11.md`). `chock install --client copilot` now reads "blocks on an + agent's file writes and at turn end", and a client note says VS Code agent mode's write path is unmeasured. diff --git a/src/chock/install/data/install.json b/src/chock/install/data/install.json index 1786cb48..10fca7d8 100644 --- a/src/chock/install/data/install.json +++ b/src/chock/install/data/install.json @@ -39,7 +39,7 @@ "binary": "copilot", "index": [{"path": ".claude-plugin/marketplace.json", "style": "claude"}], "label_prefix": "", - "gate_turn_end_only": true, + "gate_turn_end_only": false, "steps": [ {"command": ["copilot", "plugin", "marketplace", "add", "__DEST__"]}, {"command": ["copilot", "plugin", "install", "__PLUGIN__@__MARKETPLACE__"]}, diff --git a/src/chock/install/data/warnings.json b/src/chock/install/data/warnings.json index 7ec70bca..9d678c85 100644 --- a/src/chock/install/data/warnings.json +++ b/src/chock/install/data/warnings.json @@ -63,11 +63,17 @@ { "kind": "client", "clients": [ - "copilot", "devin" ], "says": "This client names no file-write tool a hook can match, so a gate judges what a turn wrote only at turn end; the write itself is not held." }, + { + "kind": "client", + "clients": [ + "copilot" + ], + "says": "A gate judges the write before it lands in Copilot CLI (witnessed 2026-10-11, CLI 1.0.94 on Windows); in VS Code agent mode that is unmeasured." + }, { "kind": "client", "clients": [ diff --git a/tests/test_install_clients.py b/tests/test_install_clients.py index b70b62c5..53085b3d 100644 --- a/tests/test_install_clients.py +++ b/tests/test_install_clients.py @@ -128,7 +128,8 @@ def test_the_flag_overrides_the_selections_client_and_the_marker_records_it(cata "once its hooks are trusted in /hooks: refuses a matched shell command", "once its hooks are trusted in /hooks: blocks on an agent's file writes", ), - ("copilot", "refuses a matched shell command", "blocks at turn end only; the write itself is not judged"), + # Copilot CLI refused the write before it landed (org-plan witness/results/witness-copilot-2026-10-11.md). + ("copilot", "refuses a matched shell command", "blocks on an agent's file writes and at turn end"), ( "devin", "best-effort, fails open: refuses a matched shell command", @@ -157,7 +158,8 @@ def test_client_warnings_say_untested_for_every_unwitnessed_client(catalog, tmp_ assert ("warning: Untested: chock has not witnessed an install" in out) == (client in UNWITNESSED) assert ("trust them in /hooks" in out) == (client == "codex") assert ("fails open: a hook that errors" in out) == (client == "devin") - assert ("only at turn end" in out) == (client in ("copilot", "devin")) + assert ("only at turn end" in out) == (client == "devin") + assert ("in VS Code agent mode that is unmeasured" in out) == (client == "copilot") assert ("Allow Local Plugin Imports" in out) == (client == "cursor") From 7d565b5e3cacaa163e77d5f281e56b3d0dbbb5a0 Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Mon, 12 Oct 2026 02:42:40 +0000 Subject: [PATCH 6/8] figures: print a drifted PNG as base64 when the drift check fails A PNG rasterizes differently off the runner (fonts), so it cannot be regenerated locally byte for byte. A failure-only step prints each differing docs/figures PNG so the runner's exact bytes can be committed. No new action, permission or secret; the drift check still fails. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- .github/workflows/figures.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/figures.yml b/.github/workflows/figures.yml index f3baeec9..c030cd2b 100644 --- a/.github/workflows/figures.yml +++ b/.github/workflows/figures.yml @@ -39,3 +39,14 @@ jobs: run: | cd docs/figures && for g in make_*.py; do python "$g"; done cd ../.. && git diff --exit-code -- docs/figures + # A PNG rasterizes differently off this runner (fonts), so it cannot be regenerated locally + # byte for byte. On drift, print each differing PNG so the exact runner bytes can be committed. + - name: Print drifted PNGs as base64 + if: failure() + run: | + git diff --name-only -- 'docs/figures/*.png' | while read -r png; do + echo "::group::$png" + base64 -w0 "$png" + echo + echo "::endgroup::" + done From b8326eff6168db3e8ea9aebe7d8d861240c8efb7 Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:56:36 -0400 Subject: [PATCH 7/8] chore: regenerate compiled hooks for E-ALL Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- .chock/bin/claude_code.py | 85 ++++++++++++-- .chock/bin/codex_cli.py | 61 ++++++++-- .chock/bin/cursor.py | 49 +++++++-- .chock/bin/devin.py | 83 ++++++++++++-- .chock/bin/gemini_cli.py | 83 ++++++++++++-- .chock/bin/grok.py | 104 +++++++++++++++--- .chock/bin/tabnine.py | 61 ++++++++-- .chock/bin/vscode_copilot.py | 8 +- .chock/bin/windsurf.py | 49 +++++++-- .../pre-tool-use/factory_droid-hooks.json | 14 +++ .../pre-tool-use/goose-hooks.json | 17 +++ .../pre-tool-use/mistral_vibe-hooks.json | 9 ++ .../pre-tool-use/openhands-hooks.json | 16 +++ .../factory_droid-write-hooks.json | 14 +++ .../pre-tool-use/goose-write-hooks.json | 17 +++ .../mistral_vibe-write-hooks.json | 9 ++ .../pre-tool-use/openhands-write-hooks.json | 16 +++ .../stop/factory_droid-hooks.json | 13 +++ .../stop/goose-hooks.json | 16 +++ .../stop/mistral_vibe-hooks.json | 8 ++ .../stop/openhands-hooks.json | 15 +++ .../pre-tool-use/factory_droid-hooks.json | 14 +++ .../pre-tool-use/goose-hooks.json | 17 +++ .../pre-tool-use/mistral_vibe-hooks.json | 9 ++ .../pre-tool-use/openhands-hooks.json | 16 +++ .../factory_droid-write-hooks.json | 14 +++ .../pre-tool-use/goose-write-hooks.json | 17 +++ .../mistral_vibe-write-hooks.json | 9 ++ .../pre-tool-use/openhands-write-hooks.json | 16 +++ .../stop/factory_droid-hooks.json | 13 +++ .../stop/goose-hooks.json | 16 +++ .../stop/mistral_vibe-hooks.json | 8 ++ .../stop/openhands-hooks.json | 15 +++ .../factory_droid-write-hooks.json | 14 +++ .../pre-tool-use/goose-write-hooks.json | 17 +++ .../mistral_vibe-write-hooks.json | 9 ++ .../pre-tool-use/openhands-write-hooks.json | 16 +++ .../stop/factory_droid-hooks.json | 13 +++ .../pin-github-actions/stop/goose-hooks.json | 16 +++ .../stop/mistral_vibe-hooks.json | 8 ++ .../stop/openhands-hooks.json | 15 +++ .../pre-tool-use/factory_droid-hooks.json | 14 +++ .../factory_droid-write-hooks.json | 14 +++ .../pre-tool-use/goose-hooks.json | 17 +++ .../pre-tool-use/goose-write-hooks.json | 17 +++ .../pre-tool-use/mistral_vibe-hooks.json | 9 ++ .../mistral_vibe-write-hooks.json | 9 ++ .../pre-tool-use/openhands-hooks.json | 16 +++ .../pre-tool-use/openhands-write-hooks.json | 16 +++ .../stop/factory_droid-hooks.json | 13 +++ .../stop/goose-hooks.json | 16 +++ .../stop/mistral_vibe-hooks.json | 8 ++ .../stop/openhands-hooks.json | 15 +++ .../pre-tool-use/factory_droid-hooks.json | 14 +++ .../pre-tool-use/goose-hooks.json | 17 +++ .../pre-tool-use/mistral_vibe-hooks.json | 9 ++ .../pre-tool-use/openhands-hooks.json | 16 +++ .../factory_droid-write-hooks.json | 14 +++ .../pre-tool-use/goose-write-hooks.json | 17 +++ .../mistral_vibe-write-hooks.json | 9 ++ .../pre-tool-use/openhands-write-hooks.json | 16 +++ .../stop/factory_droid-hooks.json | 13 +++ .../scan-secrets/stop/goose-hooks.json | 16 +++ .../scan-secrets/stop/mistral_vibe-hooks.json | 8 ++ .../scan-secrets/stop/openhands-hooks.json | 15 +++ .../factory_droid-tool-call-hooks.json | 24 ++++ .../pre-tool-use/goose-tool-call-hooks.json | 28 +++++ .../mistral_vibe-tool-call-hooks.json | 16 +++ .../openhands-tool-call-hooks.json | 26 +++++ chock.lock | 18 +-- 70 files changed, 1362 insertions(+), 97 deletions(-) create mode 100644 .chock/compiled/block-destructive-commands/pre-tool-use/factory_droid-hooks.json create mode 100644 .chock/compiled/block-destructive-commands/pre-tool-use/goose-hooks.json create mode 100644 .chock/compiled/block-destructive-commands/pre-tool-use/mistral_vibe-hooks.json create mode 100644 .chock/compiled/block-destructive-commands/pre-tool-use/openhands-hooks.json create mode 100644 .chock/compiled/block-invisible-unicode/pre-tool-use/factory_droid-write-hooks.json create mode 100644 .chock/compiled/block-invisible-unicode/pre-tool-use/goose-write-hooks.json create mode 100644 .chock/compiled/block-invisible-unicode/pre-tool-use/mistral_vibe-write-hooks.json create mode 100644 .chock/compiled/block-invisible-unicode/pre-tool-use/openhands-write-hooks.json create mode 100644 .chock/compiled/block-invisible-unicode/stop/factory_droid-hooks.json create mode 100644 .chock/compiled/block-invisible-unicode/stop/goose-hooks.json create mode 100644 .chock/compiled/block-invisible-unicode/stop/mistral_vibe-hooks.json create mode 100644 .chock/compiled/block-invisible-unicode/stop/openhands-hooks.json create mode 100644 .chock/compiled/block-no-verify/pre-tool-use/factory_droid-hooks.json create mode 100644 .chock/compiled/block-no-verify/pre-tool-use/goose-hooks.json create mode 100644 .chock/compiled/block-no-verify/pre-tool-use/mistral_vibe-hooks.json create mode 100644 .chock/compiled/block-no-verify/pre-tool-use/openhands-hooks.json create mode 100644 .chock/compiled/block-wildcard-agent-permissions/pre-tool-use/factory_droid-write-hooks.json create mode 100644 .chock/compiled/block-wildcard-agent-permissions/pre-tool-use/goose-write-hooks.json create mode 100644 .chock/compiled/block-wildcard-agent-permissions/pre-tool-use/mistral_vibe-write-hooks.json create mode 100644 .chock/compiled/block-wildcard-agent-permissions/pre-tool-use/openhands-write-hooks.json create mode 100644 .chock/compiled/block-wildcard-agent-permissions/stop/factory_droid-hooks.json create mode 100644 .chock/compiled/block-wildcard-agent-permissions/stop/goose-hooks.json create mode 100644 .chock/compiled/block-wildcard-agent-permissions/stop/mistral_vibe-hooks.json create mode 100644 .chock/compiled/block-wildcard-agent-permissions/stop/openhands-hooks.json create mode 100644 .chock/compiled/pin-github-actions/pre-tool-use/factory_droid-write-hooks.json create mode 100644 .chock/compiled/pin-github-actions/pre-tool-use/goose-write-hooks.json create mode 100644 .chock/compiled/pin-github-actions/pre-tool-use/mistral_vibe-write-hooks.json create mode 100644 .chock/compiled/pin-github-actions/pre-tool-use/openhands-write-hooks.json create mode 100644 .chock/compiled/pin-github-actions/stop/factory_droid-hooks.json create mode 100644 .chock/compiled/pin-github-actions/stop/goose-hooks.json create mode 100644 .chock/compiled/pin-github-actions/stop/mistral_vibe-hooks.json create mode 100644 .chock/compiled/pin-github-actions/stop/openhands-hooks.json create mode 100644 .chock/compiled/protect-agent-config/pre-tool-use/factory_droid-hooks.json create mode 100644 .chock/compiled/protect-agent-config/pre-tool-use/factory_droid-write-hooks.json create mode 100644 .chock/compiled/protect-agent-config/pre-tool-use/goose-hooks.json create mode 100644 .chock/compiled/protect-agent-config/pre-tool-use/goose-write-hooks.json create mode 100644 .chock/compiled/protect-agent-config/pre-tool-use/mistral_vibe-hooks.json create mode 100644 .chock/compiled/protect-agent-config/pre-tool-use/mistral_vibe-write-hooks.json create mode 100644 .chock/compiled/protect-agent-config/pre-tool-use/openhands-hooks.json create mode 100644 .chock/compiled/protect-agent-config/pre-tool-use/openhands-write-hooks.json create mode 100644 .chock/compiled/protect-agent-config/stop/factory_droid-hooks.json create mode 100644 .chock/compiled/protect-agent-config/stop/goose-hooks.json create mode 100644 .chock/compiled/protect-agent-config/stop/mistral_vibe-hooks.json create mode 100644 .chock/compiled/protect-agent-config/stop/openhands-hooks.json create mode 100644 .chock/compiled/protect-commit-privacy/pre-tool-use/factory_droid-hooks.json create mode 100644 .chock/compiled/protect-commit-privacy/pre-tool-use/goose-hooks.json create mode 100644 .chock/compiled/protect-commit-privacy/pre-tool-use/mistral_vibe-hooks.json create mode 100644 .chock/compiled/protect-commit-privacy/pre-tool-use/openhands-hooks.json create mode 100644 .chock/compiled/scan-secrets/pre-tool-use/factory_droid-write-hooks.json create mode 100644 .chock/compiled/scan-secrets/pre-tool-use/goose-write-hooks.json create mode 100644 .chock/compiled/scan-secrets/pre-tool-use/mistral_vibe-write-hooks.json create mode 100644 .chock/compiled/scan-secrets/pre-tool-use/openhands-write-hooks.json create mode 100644 .chock/compiled/scan-secrets/stop/factory_droid-hooks.json create mode 100644 .chock/compiled/scan-secrets/stop/goose-hooks.json create mode 100644 .chock/compiled/scan-secrets/stop/mistral_vibe-hooks.json create mode 100644 .chock/compiled/scan-secrets/stop/openhands-hooks.json create mode 100644 .chock/compiled/token-efficiency/pre-tool-use/factory_droid-tool-call-hooks.json create mode 100644 .chock/compiled/token-efficiency/pre-tool-use/goose-tool-call-hooks.json create mode 100644 .chock/compiled/token-efficiency/pre-tool-use/mistral_vibe-tool-call-hooks.json create mode 100644 .chock/compiled/token-efficiency/pre-tool-use/openhands-tool-call-hooks.json diff --git a/.chock/bin/claude_code.py b/.chock/bin/claude_code.py index f39b0fd3..f8e1fa2a 100755 --- a/.chock/bin/claude_code.py +++ b/.chock/bin/claude_code.py @@ -29,6 +29,7 @@ from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings +import zlib as _zlib # ------------------------------------------------------------------------------ # contract (agentseam 0.3.5) @@ -235,11 +236,31 @@ def tool_input_of(raw): "effort", ) +_MCP_PREFIX = "mcp_" + +_CLAUDE_MCP_PREFIX = "mcp__" + 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 names_a_single_underscore_mcp_tool(raw): + """True when tool_name is an MCP tool ID in a spelling Claude Code never sends.""" + name = raw.get("tool_name") if isinstance(raw, dict) else None + return isinstance(name, str) and name.startswith(_MCP_PREFIX) and not name.startswith(_CLAUDE_MCP_PREFIX) + +def event_keys_agree(raw): + """True when hookEventName names hook_event_name's event: the same text, or Grok Build's snake_case twin.""" + snake, pascal = (raw.get("hookEventName"), raw.get("hook_event_name")) if isinstance(raw, dict) else (None, None) + if not isinstance(pascal, str) or not isinstance(snake, str): + return False + return snake == pascal or (snake.islower() and snake.replace("_", "") == pascal.lower()) + +PROBES = { + "looks_like_claude_code": looks_like_claude_code, + "names_a_single_underscore_mcp_tool": names_a_single_underscore_mcp_tool, + "event_keys_agree": event_keys_agree, +} UNREADABLE_NAME = "" @@ -265,12 +286,22 @@ def _rejected_by_markers(c, raw): for marker in c.get("reject_markers", ()): if marker in raw: return True + for key, values in c.get("reject_values", {}).items(): + if key in raw and raw[key] in values: + return True for probe, markers in c.get("reject_markers_unless_probe", {}).items(): if any(marker in raw for marker in markers) and not PROBES[probe](raw): return True return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) +def _accepted_by_values(c, raw): + """True unless the entry names accepted values and the payload carries none of them.""" + values = c.get("accept_values") + return not values or any(key in raw and raw[key] in allowed for key, allowed in values.items()) + def _accepted_by_markers(c, raw, name): + if not _accepted_by_values(c, raw): + return False accept = c.get("accept_markers", ()) if not accept or any(marker in raw for marker in accept): return True @@ -309,12 +340,25 @@ def _segment(node, part): 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 _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + def _walk(node, path): - """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None if "[]." in path: head, sub = path.split("[].", 1) - items = _walk(node, head) - if not isinstance(items, (list, tuple)): + items = _list_at(node, head) + if items is None: return None joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) return joined or None @@ -344,7 +388,7 @@ def _tool_input_raw(cfg, raw): if not isinstance(raw, dict): return None for key in cfg["fields"].get("tool_input", ("tool_input",)): - value = raw.get(key) + value = _walk(raw, key) if value is not None: return value return None @@ -566,6 +610,8 @@ def hj_respond(cfg, decision, event, wire=None): return "", 0 if gate["grammar"] == "G2": return _g2(v, gate, decision, name) + if gate["grammar"] == "G7": + return _g7(v, decision, wire) return _g1(v, gate, decision, wire, name) _WINDOWS_KEYS = ("commandWindows", "windows") @@ -581,16 +627,25 @@ def _hook_dict(cfg, command): entry[key] = value return entry +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): rules = [] for ev in canonical_events: name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: - rule["matcher"] = matcher - rules.append(rule) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) return rules def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): @@ -619,6 +674,10 @@ def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): hooks.setdefault(extra, []).append({"command": command}) return {"hooks": hooks} +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] hooks = {} @@ -649,6 +708,8 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) if wrapper == "flat_entries": return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) return _default_wrapper(cfg, reverse, canonical_events, command, matcher) @@ -657,7 +718,7 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos AGENT = "claude_code" -VENDOR = {'agent': 'claude_code', 'claims': {'client_types': (None, 'claude_code'), 'event_key': ('hook_event_name',), 'mode': 'marker', 'notes': 'prompt_id rejects only when looks_like_claude_code(raw) is also false; a real Claude Code payload may carry prompt_id and must still be accepted (matrix-notes.json: fixed 2026-08-27).', 'reject_markers': ('turn_id', 'project_path', 'timestamp'), 'reject_markers_unless_probe': {'looks_like_claude_code': ('prompt_id',)}}, 'config_format': 'json', 'config_path': '.claude/settings.json', 'display': 'Claude Code', 'events': {'FileChanged': 'file_changed', 'InstructionsLoaded': 'instructions_loaded', 'PostToolUse': 'post_tool', 'PostToolUseFailure': 'tool_failure', 'PreCompact': 'pre_compact', 'PreToolUse': 'pre_tool', 'SessionEnd': 'session_end', 'SessionStart': 'session_start', 'Stop': 'stop', 'SubagentStart': 'subagent_start', 'SubagentStop': 'subagent_stop', 'UserPromptSubmit': 'prompt_submit'}, 'evidence': {'claims': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'repo_root_token': {'basis': 'vendor-docs', 'date': '2026-09-01', 'test': 'tests/test_vendor_config.py::test_repo_root_token_is_recorded_only_where_primary_sourced'}, 'tools': {'basis': 'vendor-docs', 'date': '2026-09-27', 'test': 'tests/test_adapter_claude_code.py::test_powershell_is_a_shell_tool_and_its_command_is_parsed'}, 'verdicts': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'hook_json', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content', 'tool_input.new_string', 'tool_input.new_source', 'content', 'tool_input.edits[].new_string'), 'cwd': ('cwd',), 'output': ('tool_output',), 'path': ('tool_input.file_path', 'tool_input.path', 'tool_input.notebook_path', 'file_path'), 'prompt': ('prompt',), 'session_id': ('session_id',), 'tool': ('tool_name',), 'tool_use_id': ('tool_use_id',)}, 'hook_entry': {'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'repo_root_token': '${CLAUDE_PROJECT_DIR}', 'tools': {'shell': ('Bash', 'PowerShell'), 'write': ('Write', 'Edit', 'MultiEdit', 'NotebookEdit')}, 'verdicts': {'answer_events': ('PreToolUse', 'Stop', 'UserPromptSubmit'), 'bare_allow': 'silent', 'context_events': ('SessionStart', 'UserPromptSubmit'), 'context_source': 'context', 'degrade_notes': {'escalate': 'confirmation requested; this event cannot prompt, so it blocks', 'transform': 'input rewrite requested; this event cannot modify input, so it blocks'}, 'echo': 'reverse_map', 'gates': {'PreToolUse': {'grammar': 'G2', 'honours_escalate': True, 'honours_transform': True}, 'Stop': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'UserPromptSubmit': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'suffix', 'reason_defaults': {'deny_gate': 'blocked', 'escalate_gate': 'confirmation required'}, 'transform_grammar': 'hook_specific_updated_input', 'vocabulary': ('allow', 'ask', 'block', 'deny'), 'vocabulary_basis': 'verified', 'words': {'block': 'block', 'deny': 'deny', 'escalate': 'ask', 'transform': 'allow', 'vouch': 'allow'}}} +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). A value Claude Code never sends beats every marker key, looks_like_claude_code's included (value-based detection, D1): permission_mode values off, spec, auto-low, auto-medium and auto-high are Factory Droid's (docs.factory.com/harness/hooks, read 2026-10-11); tool_name values fs_read, fsRead, fs_write, fsWrite, execute_bash, execute_cmd and use_aws (kiro.dev/docs/reference/built-in-tools, updated 2026-10-10), and an MCP tool spelled mcp__ rather than Claude's mcp____ (kiro.dev/docs/hooks/types, updated 2026-10-10), are Kiro's. Kiro's V3 primary names (read, write, shell) and its tool-less events carry nothing Claude Code does not, so those still read as claude_code. factory_droid claims Droid's payloads on those same permission_mode values (accept_values, D2a); Kiro has no adapter, so its separable payloads detect as no agent rather than as claude_code.", 'reject_markers': ('turn_id', 'project_path', 'timestamp'), 'reject_markers_unless_probe': {'looks_like_claude_code': ('prompt_id',)}, 'reject_probes': ('names_a_single_underscore_mcp_tool',), 'reject_values': {'permission_mode': ('off', 'spec', 'auto-low', 'auto-medium', 'auto-high'), 'tool_name': ('fs_read', 'fsRead', 'fs_write', 'fsWrite', 'execute_bash', 'execute_cmd', 'use_aws')}}, 'config_format': 'json', 'config_path': '.claude/settings.json', 'display': 'Claude Code', 'events': {'FileChanged': 'file_changed', 'InstructionsLoaded': 'instructions_loaded', 'PostToolUse': 'post_tool', 'PostToolUseFailure': 'tool_failure', 'PreCompact': 'pre_compact', 'PreToolUse': 'pre_tool', 'SessionEnd': 'session_end', 'SessionStart': 'session_start', 'Stop': 'stop', 'SubagentStart': 'subagent_start', 'SubagentStop': 'subagent_stop', 'UserPromptSubmit': 'prompt_submit'}, 'evidence': {'claims': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'repo_root_token': {'basis': 'vendor-docs', 'date': '2026-09-01', 'test': 'tests/test_vendor_config.py::test_repo_root_token_is_recorded_only_where_primary_sourced'}, 'tools': {'basis': 'vendor-docs', 'date': '2026-09-27', 'test': 'tests/test_adapter_claude_code.py::test_powershell_is_a_shell_tool_and_its_command_is_parsed'}, 'verdicts': {'basis': 'live-run', 'date': '2026-09-07', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'hook_json', 'fields': {'command': ('tool_input.command',), 'content': ('tool_input.content', 'tool_input.new_string', 'tool_input.new_source', 'content', 'tool_input.edits[].new_string'), 'cwd': ('cwd',), 'output': ('tool_output',), 'path': ('tool_input.file_path', 'tool_input.path', 'tool_input.notebook_path', 'file_path'), 'prompt': ('prompt',), 'session_id': ('session_id',), 'tool': ('tool_name',), 'tool_use_id': ('tool_use_id',)}, 'hook_entry': {'matcher': True, 'wrapper': 'hooks_map'}, 'needs_trust': False, 'repo_root_token': '${CLAUDE_PROJECT_DIR}', 'tools': {'shell': ('Bash', 'PowerShell'), 'write': ('Write', 'Edit', 'MultiEdit', 'NotebookEdit')}, 'verdicts': {'answer_events': ('PreToolUse', 'Stop', 'UserPromptSubmit'), 'bare_allow': 'silent', 'context_events': ('SessionStart', 'UserPromptSubmit'), 'context_source': 'context', 'degrade_notes': {'escalate': 'confirmation requested; this event cannot prompt, so it blocks', 'transform': 'input rewrite requested; this event cannot modify input, so it blocks'}, 'echo': 'reverse_map', 'gates': {'PreToolUse': {'grammar': 'G2', 'honours_escalate': True, 'honours_transform': True}, 'Stop': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}, 'UserPromptSubmit': {'grammar': 'G1', 'honours_escalate': False, 'honours_transform': False}}, 'note_style': 'suffix', 'reason_defaults': {'deny_gate': 'blocked', 'escalate_gate': 'confirmation required'}, 'transform_grammar': 'hook_specific_updated_input', 'vocabulary': ('allow', 'ask', 'block', 'deny'), 'vocabulary_basis': 'verified', 'words': {'block': 'block', 'deny': 'deny', 'escalate': 'ask', 'transform': 'allow', 'vouch': 'allow'}}} def claims(raw): diff --git a/.chock/bin/codex_cli.py b/.chock/bin/codex_cli.py index f1e41dba..6f7a3a65 100755 --- a/.chock/bin/codex_cli.py +++ b/.chock/bin/codex_cli.py @@ -29,6 +29,7 @@ from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings +import zlib as _zlib # ------------------------------------------------------------------------------ # contract (agentseam 0.3.5) @@ -262,12 +263,22 @@ def _rejected_by_markers(c, raw): for marker in c.get("reject_markers", ()): if marker in raw: return True + for key, values in c.get("reject_values", {}).items(): + if key in raw and raw[key] in values: + return True for probe, markers in c.get("reject_markers_unless_probe", {}).items(): if any(marker in raw for marker in markers) and not PROBES[probe](raw): return True return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) +def _accepted_by_values(c, raw): + """True unless the entry names accepted values and the payload carries none of them.""" + values = c.get("accept_values") + return not values or any(key in raw and raw[key] in allowed for key, allowed in values.items()) + def _accepted_by_markers(c, raw, name): + if not _accepted_by_values(c, raw): + return False accept = c.get("accept_markers", ()) if not accept or any(marker in raw for marker in accept): return True @@ -306,12 +317,25 @@ def _segment(node, part): 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 _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + def _walk(node, path): - """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None if "[]." in path: head, sub = path.split("[].", 1) - items = _walk(node, head) - if not isinstance(items, (list, tuple)): + items = _list_at(node, head) + if items is None: return None joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) return joined or None @@ -341,7 +365,7 @@ def _tool_input_raw(cfg, raw): if not isinstance(raw, dict): return None for key in cfg["fields"].get("tool_input", ("tool_input",)): - value = raw.get(key) + value = _walk(raw, key) if value is not None: return value return None @@ -563,6 +587,8 @@ def hj_respond(cfg, decision, event, wire=None): return "", 0 if gate["grammar"] == "G2": return _g2(v, gate, decision, name) + if gate["grammar"] == "G7": + return _g7(v, decision, wire) return _g1(v, gate, decision, wire, name) _WINDOWS_KEYS = ("commandWindows", "windows") @@ -578,16 +604,25 @@ def _hook_dict(cfg, command): entry[key] = value return entry +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): rules = [] for ev in canonical_events: name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: - rule["matcher"] = matcher - rules.append(rule) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) return rules def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): @@ -616,6 +651,10 @@ def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): hooks.setdefault(extra, []).append({"command": command}) return {"hooks": hooks} +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] hooks = {} @@ -646,6 +685,8 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) if wrapper == "flat_entries": return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) return _default_wrapper(cfg, reverse, canonical_events, command, matcher) diff --git a/.chock/bin/cursor.py b/.chock/bin/cursor.py index af70c81c..b4757b40 100755 --- a/.chock/bin/cursor.py +++ b/.chock/bin/cursor.py @@ -29,6 +29,7 @@ from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings +import zlib as _zlib # ------------------------------------------------------------------------------ # contract (agentseam 0.3.5) @@ -253,12 +254,25 @@ def _segment(node, part): 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 _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + def _walk(node, path): - """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None if "[]." in path: head, sub = path.split("[].", 1) - items = _walk(node, head) - if not isinstance(items, (list, tuple)): + items = _list_at(node, head) + if items is None: return None joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) return joined or None @@ -288,7 +302,7 @@ def _tool_input_raw(cfg, raw): if not isinstance(raw, dict): return None for key in cfg["fields"].get("tool_input", ("tool_input",)): - value = raw.get(key) + value = _walk(raw, key) if value is not None: return value return None @@ -369,16 +383,25 @@ def _hook_dict(cfg, command): entry[key] = value return entry +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): rules = [] for ev in canonical_events: name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: - rule["matcher"] = matcher - rules.append(rule) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) return rules def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): @@ -407,6 +430,10 @@ def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): hooks.setdefault(extra, []).append({"command": command}) return {"hooks": hooks} +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] hooks = {} @@ -437,6 +464,8 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) if wrapper == "flat_entries": return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) return _default_wrapper(cfg, reverse, canonical_events, command, matcher) _AMBIGUOUS_NAMES = ( diff --git a/.chock/bin/devin.py b/.chock/bin/devin.py index e93a7a95..a977783b 100755 --- a/.chock/bin/devin.py +++ b/.chock/bin/devin.py @@ -29,6 +29,7 @@ from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings +import zlib as _zlib # ------------------------------------------------------------------------------ # contract (agentseam 0.3.5) @@ -235,11 +236,31 @@ def tool_input_of(raw): "effort", ) +_MCP_PREFIX = "mcp_" + +_CLAUDE_MCP_PREFIX = "mcp__" + 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 names_a_single_underscore_mcp_tool(raw): + """True when tool_name is an MCP tool ID in a spelling Claude Code never sends.""" + name = raw.get("tool_name") if isinstance(raw, dict) else None + return isinstance(name, str) and name.startswith(_MCP_PREFIX) and not name.startswith(_CLAUDE_MCP_PREFIX) + +def event_keys_agree(raw): + """True when hookEventName names hook_event_name's event: the same text, or Grok Build's snake_case twin.""" + snake, pascal = (raw.get("hookEventName"), raw.get("hook_event_name")) if isinstance(raw, dict) else (None, None) + if not isinstance(pascal, str) or not isinstance(snake, str): + return False + return snake == pascal or (snake.islower() and snake.replace("_", "") == pascal.lower()) + +PROBES = { + "looks_like_claude_code": looks_like_claude_code, + "names_a_single_underscore_mcp_tool": names_a_single_underscore_mcp_tool, + "event_keys_agree": event_keys_agree, +} UNREADABLE_NAME = "" @@ -265,12 +286,22 @@ def _rejected_by_markers(c, raw): for marker in c.get("reject_markers", ()): if marker in raw: return True + for key, values in c.get("reject_values", {}).items(): + if key in raw and raw[key] in values: + return True for probe, markers in c.get("reject_markers_unless_probe", {}).items(): if any(marker in raw for marker in markers) and not PROBES[probe](raw): return True return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) +def _accepted_by_values(c, raw): + """True unless the entry names accepted values and the payload carries none of them.""" + values = c.get("accept_values") + return not values or any(key in raw and raw[key] in allowed for key, allowed in values.items()) + def _accepted_by_markers(c, raw, name): + if not _accepted_by_values(c, raw): + return False accept = c.get("accept_markers", ()) if not accept or any(marker in raw for marker in accept): return True @@ -309,12 +340,25 @@ def _segment(node, part): 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 _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + def _walk(node, path): - """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None if "[]." in path: head, sub = path.split("[].", 1) - items = _walk(node, head) - if not isinstance(items, (list, tuple)): + items = _list_at(node, head) + if items is None: return None joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) return joined or None @@ -344,7 +388,7 @@ def _tool_input_raw(cfg, raw): if not isinstance(raw, dict): return None for key in cfg["fields"].get("tool_input", ("tool_input",)): - value = raw.get(key) + value = _walk(raw, key) if value is not None: return value return None @@ -535,6 +579,8 @@ def hj_respond(cfg, decision, event, wire=None): return "", 0 if gate["grammar"] == "G2": return _g2(v, gate, decision, name) + if gate["grammar"] == "G7": + return _g7(v, decision, wire) return _g1(v, gate, decision, wire, name) _WINDOWS_KEYS = ("commandWindows", "windows") @@ -550,16 +596,25 @@ def _hook_dict(cfg, command): entry[key] = value return entry +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): rules = [] for ev in canonical_events: name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: - rule["matcher"] = matcher - rules.append(rule) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) return rules def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): @@ -588,6 +643,10 @@ def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): hooks.setdefault(extra, []).append({"command": command}) return {"hooks": hooks} +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] hooks = {} @@ -618,6 +677,8 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) if wrapper == "flat_entries": return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) return _default_wrapper(cfg, reverse, canonical_events, command, matcher) diff --git a/.chock/bin/gemini_cli.py b/.chock/bin/gemini_cli.py index 1afd4d41..98ecf634 100755 --- a/.chock/bin/gemini_cli.py +++ b/.chock/bin/gemini_cli.py @@ -29,6 +29,7 @@ from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings +import zlib as _zlib # ------------------------------------------------------------------------------ # contract (agentseam 0.3.5) @@ -235,11 +236,31 @@ def tool_input_of(raw): "effort", ) +_MCP_PREFIX = "mcp_" + +_CLAUDE_MCP_PREFIX = "mcp__" + 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 names_a_single_underscore_mcp_tool(raw): + """True when tool_name is an MCP tool ID in a spelling Claude Code never sends.""" + name = raw.get("tool_name") if isinstance(raw, dict) else None + return isinstance(name, str) and name.startswith(_MCP_PREFIX) and not name.startswith(_CLAUDE_MCP_PREFIX) + +def event_keys_agree(raw): + """True when hookEventName names hook_event_name's event: the same text, or Grok Build's snake_case twin.""" + snake, pascal = (raw.get("hookEventName"), raw.get("hook_event_name")) if isinstance(raw, dict) else (None, None) + if not isinstance(pascal, str) or not isinstance(snake, str): + return False + return snake == pascal or (snake.islower() and snake.replace("_", "") == pascal.lower()) + +PROBES = { + "looks_like_claude_code": looks_like_claude_code, + "names_a_single_underscore_mcp_tool": names_a_single_underscore_mcp_tool, + "event_keys_agree": event_keys_agree, +} UNREADABLE_NAME = "" @@ -265,12 +286,22 @@ def _rejected_by_markers(c, raw): for marker in c.get("reject_markers", ()): if marker in raw: return True + for key, values in c.get("reject_values", {}).items(): + if key in raw and raw[key] in values: + return True for probe, markers in c.get("reject_markers_unless_probe", {}).items(): if any(marker in raw for marker in markers) and not PROBES[probe](raw): return True return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) +def _accepted_by_values(c, raw): + """True unless the entry names accepted values and the payload carries none of them.""" + values = c.get("accept_values") + return not values or any(key in raw and raw[key] in allowed for key, allowed in values.items()) + def _accepted_by_markers(c, raw, name): + if not _accepted_by_values(c, raw): + return False accept = c.get("accept_markers", ()) if not accept or any(marker in raw for marker in accept): return True @@ -309,12 +340,25 @@ def _segment(node, part): 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 _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + def _walk(node, path): - """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None if "[]." in path: head, sub = path.split("[].", 1) - items = _walk(node, head) - if not isinstance(items, (list, tuple)): + items = _list_at(node, head) + if items is None: return None joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) return joined or None @@ -344,7 +388,7 @@ def _tool_input_raw(cfg, raw): if not isinstance(raw, dict): return None for key in cfg["fields"].get("tool_input", ("tool_input",)): - value = raw.get(key) + value = _walk(raw, key) if value is not None: return value return None @@ -535,6 +579,8 @@ def hj_respond(cfg, decision, event, wire=None): return "", 0 if gate["grammar"] == "G2": return _g2(v, gate, decision, name) + if gate["grammar"] == "G7": + return _g7(v, decision, wire) return _g1(v, gate, decision, wire, name) _WINDOWS_KEYS = ("commandWindows", "windows") @@ -550,16 +596,25 @@ def _hook_dict(cfg, command): entry[key] = value return entry +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): rules = [] for ev in canonical_events: name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: - rule["matcher"] = matcher - rules.append(rule) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) return rules def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): @@ -588,6 +643,10 @@ def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): hooks.setdefault(extra, []).append({"command": command}) return {"hooks": hooks} +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] hooks = {} @@ -618,6 +677,8 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) if wrapper == "flat_entries": return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) return _default_wrapper(cfg, reverse, canonical_events, command, matcher) diff --git a/.chock/bin/grok.py b/.chock/bin/grok.py index 470de4f2..1e48cb6f 100755 --- a/.chock/bin/grok.py +++ b/.chock/bin/grok.py @@ -29,6 +29,7 @@ from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings +import zlib as _zlib # ------------------------------------------------------------------------------ # contract (agentseam 0.3.5) @@ -224,6 +225,43 @@ def tool_input_of(raw): # ------------------------------------------------------------------------------ # 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", +) + +_MCP_PREFIX = "mcp_" + +_CLAUDE_MCP_PREFIX = "mcp__" + +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) + +def names_a_single_underscore_mcp_tool(raw): + """True when tool_name is an MCP tool ID in a spelling Claude Code never sends.""" + name = raw.get("tool_name") if isinstance(raw, dict) else None + return isinstance(name, str) and name.startswith(_MCP_PREFIX) and not name.startswith(_CLAUDE_MCP_PREFIX) + +def event_keys_agree(raw): + """True when hookEventName names hook_event_name's event: the same text, or Grok Build's snake_case twin.""" + snake, pascal = (raw.get("hookEventName"), raw.get("hook_event_name")) if isinstance(raw, dict) else (None, None) + if not isinstance(pascal, str) or not isinstance(snake, str): + return False + return snake == pascal or (snake.islower() and snake.replace("_", "") == pascal.lower()) + +PROBES = { + "looks_like_claude_code": looks_like_claude_code, + "names_a_single_underscore_mcp_tool": names_a_single_underscore_mcp_tool, + "event_keys_agree": event_keys_agree, +} + UNREADABLE_NAME = "" def wire_name_of(raw, key): @@ -248,12 +286,22 @@ def _rejected_by_markers(c, raw): for marker in c.get("reject_markers", ()): if marker in raw: return True + for key, values in c.get("reject_values", {}).items(): + if key in raw and raw[key] in values: + return True for probe, markers in c.get("reject_markers_unless_probe", {}).items(): if any(marker in raw for marker in markers) and not PROBES[probe](raw): return True return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) +def _accepted_by_values(c, raw): + """True unless the entry names accepted values and the payload carries none of them.""" + values = c.get("accept_values") + return not values or any(key in raw and raw[key] in allowed for key, allowed in values.items()) + def _accepted_by_markers(c, raw, name): + if not _accepted_by_values(c, raw): + return False accept = c.get("accept_markers", ()) if not accept or any(marker in raw for marker in accept): return True @@ -292,12 +340,25 @@ def _segment(node, part): 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 _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + def _walk(node, path): - """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None if "[]." in path: head, sub = path.split("[].", 1) - items = _walk(node, head) - if not isinstance(items, (list, tuple)): + items = _list_at(node, head) + if items is None: return None joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) return joined or None @@ -327,7 +388,7 @@ def _tool_input_raw(cfg, raw): if not isinstance(raw, dict): return None for key in cfg["fields"].get("tool_input", ("tool_input",)): - value = raw.get(key) + value = _walk(raw, key) if value is not None: return value return None @@ -518,6 +579,8 @@ def hj_respond(cfg, decision, event, wire=None): return "", 0 if gate["grammar"] == "G2": return _g2(v, gate, decision, name) + if gate["grammar"] == "G7": + return _g7(v, decision, wire) return _g1(v, gate, decision, wire, name) _WINDOWS_KEYS = ("commandWindows", "windows") @@ -533,16 +596,25 @@ def _hook_dict(cfg, command): entry[key] = value return entry +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): rules = [] for ev in canonical_events: name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: - rule["matcher"] = matcher - rules.append(rule) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) return rules def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): @@ -571,6 +643,10 @@ def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): hooks.setdefault(extra, []).append({"command": command}) return {"hooks": hooks} +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] hooks = {} @@ -601,6 +677,8 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) if wrapper == "flat_entries": return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) return _default_wrapper(cfg, reverse, canonical_events, command, matcher) @@ -609,7 +687,7 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos 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': 'unknown', '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'}} +VENDOR = {'agent': 'grok', 'claims': {'accept_markers': ('hookEventName',), 'event_key': ('hook_event_name', 'hookEventName'), 'mode': 'marker', 'notes': "Grok Build sends hookEventName with grok's snake_case value (pre_tool_use) beside a hook_event_name key carrying the PascalCase name (github.com/xai-org/grok-build user-guide/10-hooks.md, read 2026-10-11 against v1.0.50), so hook_event_name is read first, but only when event_keys_agree(raw) finds hookEventName naming the same event (its snake_case twin, or the same text); a payload whose two keys disagree is not grok's (Kimi Code sends both, with a camelCase hookEventName). The older camelCase-only shape falls back to hookEventName, which is required either way.", 'reject_markers_unless_probe': {'event_keys_agree': ('hook_event_name',)}}, 'config_format': 'json', 'config_path': '.grok/hooks/agentseam.json', 'display': 'Grok CLI', 'events': {'PermissionDenied': 'tool_failure', 'PostCompact': 'unknown', '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): @@ -2096,7 +2174,7 @@ def evaluate_tool_call(argv, event): ) #: A payload naming only this vendor's own wire event, so `respond` picks that event's grammar. -_CHOCK_WIRE_RAW = {PRE_TOOL: {'hookEventName': 'PreToolUse'}, STOP: {'hookEventName': 'Stop'}} +_CHOCK_WIRE_RAW = {PRE_TOOL: {'hook_event_name': 'PreToolUse'}, STOP: {'hook_event_name': 'Stop'}} #: The words a client reads as a refusal, in any of the answer fields a vendor grammar uses. _CHOCK_REFUSING = ("deny", "block", "ask", "continue") @@ -2165,7 +2243,7 @@ def _chock_unreadable(): return (text, code) if _chock_refuses(text, code) else ("", 2) #: Per wire event mapped to pre_tool: whether the client shows a guard's ask as a prompt. _CHOCK_CAN_ASK = {'PreToolUse': False} -_CHOCK_EVENT_KEYS = ('hookEventName', 'hook_event_name', 'agent_action_name') +_CHOCK_EVENT_KEYS = ('hook_event_name', 'hookEventName', 'agent_action_name') def _chock_wire_name(raw): diff --git a/.chock/bin/tabnine.py b/.chock/bin/tabnine.py index 6d7f192c..e490c345 100755 --- a/.chock/bin/tabnine.py +++ b/.chock/bin/tabnine.py @@ -29,6 +29,7 @@ from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings +import zlib as _zlib # ------------------------------------------------------------------------------ # contract (agentseam 0.3.5) @@ -248,12 +249,22 @@ def _rejected_by_markers(c, raw): for marker in c.get("reject_markers", ()): if marker in raw: return True + for key, values in c.get("reject_values", {}).items(): + if key in raw and raw[key] in values: + return True for probe, markers in c.get("reject_markers_unless_probe", {}).items(): if any(marker in raw for marker in markers) and not PROBES[probe](raw): return True return any(PROBES[probe](raw) for probe in c.get("reject_probes", ())) +def _accepted_by_values(c, raw): + """True unless the entry names accepted values and the payload carries none of them.""" + values = c.get("accept_values") + return not values or any(key in raw and raw[key] in allowed for key, allowed in values.items()) + def _accepted_by_markers(c, raw, name): + if not _accepted_by_values(c, raw): + return False accept = c.get("accept_markers", ()) if not accept or any(marker in raw for marker in accept): return True @@ -292,12 +303,25 @@ def _segment(node, part): 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 _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + def _walk(node, path): - """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None if "[]." in path: head, sub = path.split("[].", 1) - items = _walk(node, head) - if not isinstance(items, (list, tuple)): + items = _list_at(node, head) + if items is None: return None joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) return joined or None @@ -327,7 +351,7 @@ def _tool_input_raw(cfg, raw): if not isinstance(raw, dict): return None for key in cfg["fields"].get("tool_input", ("tool_input",)): - value = raw.get(key) + value = _walk(raw, key) if value is not None: return value return None @@ -518,6 +542,8 @@ def hj_respond(cfg, decision, event, wire=None): return "", 0 if gate["grammar"] == "G2": return _g2(v, gate, decision, name) + if gate["grammar"] == "G7": + return _g7(v, decision, wire) return _g1(v, gate, decision, wire, name) _WINDOWS_KEYS = ("commandWindows", "windows") @@ -533,16 +559,25 @@ def _hook_dict(cfg, command): entry[key] = value return entry +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): rules = [] for ev in canonical_events: name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: - rule["matcher"] = matcher - rules.append(rule) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) return rules def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): @@ -571,6 +606,10 @@ def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): hooks.setdefault(extra, []).append({"command": command}) return {"hooks": hooks} +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] hooks = {} @@ -601,6 +640,8 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) if wrapper == "flat_entries": return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) return _default_wrapper(cfg, reverse, canonical_events, command, matcher) diff --git a/.chock/bin/vscode_copilot.py b/.chock/bin/vscode_copilot.py index c7c3a84a..82eaeddf 100755 --- a/.chock/bin/vscode_copilot.py +++ b/.chock/bin/vscode_copilot.py @@ -316,7 +316,7 @@ def _name(raw): def claims(raw): """True for a payload from either product.""" - if not isinstance(raw, dict): + if not isinstance(raw, dict) or _is_grok_build(raw): return False name = _name(raw) if name in EVENT_MAP and _VSCODE_ENVELOPE in raw and "turn_id" not in raw: @@ -334,6 +334,12 @@ def claims(raw): return raw.get("tool_name") in MEMORY_TOOLS and isinstance(ti, dict) and "command" in ti +def _is_grok_build(raw): + """Grok Build's envelope: hookEventName is hook_event_name's snake_case twin (pre_tool_use, PreToolUse).""" + snake, pascal = raw.get("hookEventName"), raw.get("hook_event_name") + return isinstance(pascal, str) and str(snake).islower() and str(snake).replace("_", "") == pascal.lower() + + def _tool_input(raw): """The tool's arguments: VS Code's `tool_input`, else the CLI's `toolArgs` (object or JSON text).""" ti = raw.get("tool_input") diff --git a/.chock/bin/windsurf.py b/.chock/bin/windsurf.py index 586e0904..4da24d18 100755 --- a/.chock/bin/windsurf.py +++ b/.chock/bin/windsurf.py @@ -29,6 +29,7 @@ from pathlib import PurePosixPath as _chock_PurePosixPath, PureWindowsPath as _chock_PureWindowsPath import traceback import warnings as _warnings +import zlib as _zlib # ------------------------------------------------------------------------------ # contract (agentseam 0.3.5) @@ -253,12 +254,25 @@ def _segment(node, part): 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 _list_at(node, path): + """The list under `path`, decoding the JSON text Cline sends in a list's place.""" + items = _walk(node, path) + if isinstance(items, str) and items.lstrip()[:1] == "[": + try: + items = _json.loads(items) + except (ValueError, RecursionError): + return None + return items if isinstance(items, (list, tuple)) else None + def _walk(node, path): - """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[0]` indexes.""" + """A dotted path off `node`; `a[].b` joins `b` over `a`'s dict items, `a[]` its strings, `a[0]` indexes.""" + if path.endswith("[]"): + joined = "\n".join(item for item in _list_at(node, path[:-2]) or () if isinstance(item, str)) + return joined or None if "[]." in path: head, sub = path.split("[].", 1) - items = _walk(node, head) - if not isinstance(items, (list, tuple)): + items = _list_at(node, head) + if items is None: return None joined = "\n".join(str(item.get(sub, "")) for item in items if isinstance(item, dict)) return joined or None @@ -288,7 +302,7 @@ def _tool_input_raw(cfg, raw): if not isinstance(raw, dict): return None for key in cfg["fields"].get("tool_input", ("tool_input",)): - value = raw.get(key) + value = _walk(raw, key) if value is not None: return value return None @@ -406,16 +420,25 @@ def _hook_dict(cfg, command): entry[key] = value return entry +def _flat_list_rule(hook_entry, ev, name, command, matcher): + """One `[[hooks]]` rule, under the vendor's own key names where `rule_keys` renames them.""" + rule = {"event": name, "command": command} + if hook_entry["matcher"] and ev in _MATCHER_EVENTS: + matcher = matcher or hook_entry.get("default_match") + if matcher: + rule["matcher"] = matcher + if "rule_name_prefix" in hook_entry: + # Vibe dedupes [[hooks]] by name across files; the command's checksum keeps two owners' rules apart. + rule["name"] = "%s%s-%08x" % (hook_entry["rule_name_prefix"], name, _zlib.crc32(command.encode("utf-8"))) + keys = hook_entry.get("rule_keys", {}) + return {keys.get(key, key): value for key, value in rule.items()} + def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): rules = [] for ev in canonical_events: name = reverse.get(ev) - if not name: - continue - rule = {"event": name, "command": command} - if matcher and hook_entry["matcher"] and ev in _MATCHER_EVENTS: - rule["matcher"] = matcher - rules.append(rule) + if name: + rules.append(_flat_list_rule(hook_entry, ev, name, command, matcher)) return rules def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): @@ -444,6 +467,10 @@ def _flat_entries_wrapper(hook_entry, reverse, canonical_events, command): hooks.setdefault(extra, []).append({"command": command}) return {"hooks": hooks} +def _hook_dir_wrapper(reverse, canonical_events, command): + """File name -> command: one executable per event, named exactly as the wire event (cline).""" + return {reverse[ev]: command for ev in canonical_events if reverse.get(ev)} + def _default_wrapper(cfg, reverse, canonical_events, command, matcher): hook_entry = cfg["hook_entry"] hooks = {} @@ -474,6 +501,8 @@ def hook_entry_config(cfg, canonical_events, command, matcher=None, *, fail_clos return _cursor_wrapper(cfg, reverse, canonical_events, command, fail_closed=fail_closed) if wrapper == "flat_entries": return _flat_entries_wrapper(hook_entry, reverse, canonical_events, command) + if wrapper == "hook_dir": + return _hook_dir_wrapper(reverse, canonical_events, command) return _default_wrapper(cfg, reverse, canonical_events, command, matcher) _MCP_EVENTS = ("pre_mcp_tool_use", "post_mcp_tool_use") diff --git a/.chock/compiled/block-destructive-commands/pre-tool-use/factory_droid-hooks.json b/.chock/compiled/block-destructive-commands/pre-tool-use/factory_droid-hooks.json new file mode 100644 index 00000000..b17c61d0 --- /dev/null +++ b/.chock/compiled/block-destructive-commands/pre-tool-use/factory_droid-hooks.json @@ -0,0 +1,14 @@ +{ + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/factory_droid.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive-commands.py\"", + "timeout": 45 + } + ], + "matcher": "Execute" + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/block-destructive-commands/pre-tool-use/goose-hooks.json b/.chock/compiled/block-destructive-commands/pre-tool-use/goose-hooks.json new file mode 100644 index 00000000..65ac5aa3 --- /dev/null +++ b/.chock/compiled/block-destructive-commands/pre-tool-use/goose-hooks.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/goose.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive-commands.py\"", + "on_failure": "block", + "timeout": 45 + } + ], + "matcher": "shell" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-destructive-commands/pre-tool-use/mistral_vibe-hooks.json b/.chock/compiled/block-destructive-commands/pre-tool-use/mistral_vibe-hooks.json new file mode 100644 index 00000000..272cf309 --- /dev/null +++ b/.chock/compiled/block-destructive-commands/pre-tool-use/mistral_vibe-hooks.json @@ -0,0 +1,9 @@ +[ + { + "type": "pre_tool", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/mistral_vibe.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive-commands.py\"", + "match": "bash|powershell|git_bash", + "name": "agentseam-pre_tool-de73a44f", + "timeout": 45 + } +] \ No newline at end of file diff --git a/.chock/compiled/block-destructive-commands/pre-tool-use/openhands-hooks.json b/.chock/compiled/block-destructive-commands/pre-tool-use/openhands-hooks.json new file mode 100644 index 00000000..ef6105d6 --- /dev/null +++ b/.chock/compiled/block-destructive-commands/pre-tool-use/openhands-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/openhands.py --guard \".agents/policies/block-destructive-commands/implementations/block-destructive-commands.py\"", + "timeout": 45 + } + ], + "matcher": "terminal" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-invisible-unicode/pre-tool-use/factory_droid-write-hooks.json b/.chock/compiled/block-invisible-unicode/pre-tool-use/factory_droid-write-hooks.json new file mode 100644 index 00000000..830b1b36 --- /dev/null +++ b/.chock/compiled/block-invisible-unicode/pre-tool-use/factory_droid-write-hooks.json @@ -0,0 +1,14 @@ +{ + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/factory_droid.py --gate \".chock/compiled/block-invisible-unicode/pre-tool-use/gate.json\"", + "timeout": 45 + } + ], + "matcher": "Create|Edit|MultiEdit|ApplyPatch" + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/block-invisible-unicode/pre-tool-use/goose-write-hooks.json b/.chock/compiled/block-invisible-unicode/pre-tool-use/goose-write-hooks.json new file mode 100644 index 00000000..0de9a259 --- /dev/null +++ b/.chock/compiled/block-invisible-unicode/pre-tool-use/goose-write-hooks.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/goose.py --gate \".chock/compiled/block-invisible-unicode/pre-tool-use/gate.json\"", + "on_failure": "block", + "timeout": 45 + } + ], + "matcher": "write|edit" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-invisible-unicode/pre-tool-use/mistral_vibe-write-hooks.json b/.chock/compiled/block-invisible-unicode/pre-tool-use/mistral_vibe-write-hooks.json new file mode 100644 index 00000000..a96be9f7 --- /dev/null +++ b/.chock/compiled/block-invisible-unicode/pre-tool-use/mistral_vibe-write-hooks.json @@ -0,0 +1,9 @@ +[ + { + "type": "pre_tool", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/mistral_vibe.py --gate \".chock/compiled/block-invisible-unicode/pre-tool-use/gate.json\"", + "match": "write_file|edit", + "name": "agentseam-pre_tool-d550af0e", + "timeout": 45 + } +] \ No newline at end of file diff --git a/.chock/compiled/block-invisible-unicode/pre-tool-use/openhands-write-hooks.json b/.chock/compiled/block-invisible-unicode/pre-tool-use/openhands-write-hooks.json new file mode 100644 index 00000000..dadf1032 --- /dev/null +++ b/.chock/compiled/block-invisible-unicode/pre-tool-use/openhands-write-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/openhands.py --gate \".chock/compiled/block-invisible-unicode/pre-tool-use/gate.json\"", + "timeout": 45 + } + ], + "matcher": "file_editor" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-invisible-unicode/stop/factory_droid-hooks.json b/.chock/compiled/block-invisible-unicode/stop/factory_droid-hooks.json new file mode 100644 index 00000000..3276445c --- /dev/null +++ b/.chock/compiled/block-invisible-unicode/stop/factory_droid-hooks.json @@ -0,0 +1,13 @@ +{ + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/factory_droid.py --gate \".chock/compiled/block-invisible-unicode/stop/gate.json\"", + "timeout": 45 + } + ] + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/block-invisible-unicode/stop/goose-hooks.json b/.chock/compiled/block-invisible-unicode/stop/goose-hooks.json new file mode 100644 index 00000000..b914fa22 --- /dev/null +++ b/.chock/compiled/block-invisible-unicode/stop/goose-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/goose.py --gate \".chock/compiled/block-invisible-unicode/stop/gate.json\"", + "on_failure": "block", + "timeout": 45 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-invisible-unicode/stop/mistral_vibe-hooks.json b/.chock/compiled/block-invisible-unicode/stop/mistral_vibe-hooks.json new file mode 100644 index 00000000..e8090569 --- /dev/null +++ b/.chock/compiled/block-invisible-unicode/stop/mistral_vibe-hooks.json @@ -0,0 +1,8 @@ +[ + { + "type": "post_agent", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/mistral_vibe.py --gate \".chock/compiled/block-invisible-unicode/stop/gate.json\"", + "name": "agentseam-post_agent-908f1ff7", + "timeout": 45 + } +] \ No newline at end of file diff --git a/.chock/compiled/block-invisible-unicode/stop/openhands-hooks.json b/.chock/compiled/block-invisible-unicode/stop/openhands-hooks.json new file mode 100644 index 00000000..f440aad2 --- /dev/null +++ b/.chock/compiled/block-invisible-unicode/stop/openhands-hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/openhands.py --gate \".chock/compiled/block-invisible-unicode/stop/gate.json\"", + "timeout": 45 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-no-verify/pre-tool-use/factory_droid-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/factory_droid-hooks.json new file mode 100644 index 00000000..b414f1ec --- /dev/null +++ b/.chock/compiled/block-no-verify/pre-tool-use/factory_droid-hooks.json @@ -0,0 +1,14 @@ +{ + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/factory_droid.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.py\"", + "timeout": 45 + } + ], + "matcher": "Execute" + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/block-no-verify/pre-tool-use/goose-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/goose-hooks.json new file mode 100644 index 00000000..54c2a0c2 --- /dev/null +++ b/.chock/compiled/block-no-verify/pre-tool-use/goose-hooks.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/goose.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.py\"", + "on_failure": "block", + "timeout": 45 + } + ], + "matcher": "shell" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-no-verify/pre-tool-use/mistral_vibe-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/mistral_vibe-hooks.json new file mode 100644 index 00000000..b04c9f6a --- /dev/null +++ b/.chock/compiled/block-no-verify/pre-tool-use/mistral_vibe-hooks.json @@ -0,0 +1,9 @@ +[ + { + "type": "pre_tool", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/mistral_vibe.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.py\"", + "match": "bash|powershell|git_bash", + "name": "agentseam-pre_tool-15b7ea20", + "timeout": 45 + } +] \ No newline at end of file diff --git a/.chock/compiled/block-no-verify/pre-tool-use/openhands-hooks.json b/.chock/compiled/block-no-verify/pre-tool-use/openhands-hooks.json new file mode 100644 index 00000000..c7b34eb5 --- /dev/null +++ b/.chock/compiled/block-no-verify/pre-tool-use/openhands-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/openhands.py --guard \".agents/policies/block-no-verify/implementations/block-no-verify.py\"", + "timeout": 45 + } + ], + "matcher": "terminal" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-wildcard-agent-permissions/pre-tool-use/factory_droid-write-hooks.json b/.chock/compiled/block-wildcard-agent-permissions/pre-tool-use/factory_droid-write-hooks.json new file mode 100644 index 00000000..3bb8e0c0 --- /dev/null +++ b/.chock/compiled/block-wildcard-agent-permissions/pre-tool-use/factory_droid-write-hooks.json @@ -0,0 +1,14 @@ +{ + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/factory_droid.py --gate \".chock/compiled/block-wildcard-agent-permissions/pre-tool-use/gate.json\"", + "timeout": 45 + } + ], + "matcher": "Create|Edit|MultiEdit|ApplyPatch" + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/block-wildcard-agent-permissions/pre-tool-use/goose-write-hooks.json b/.chock/compiled/block-wildcard-agent-permissions/pre-tool-use/goose-write-hooks.json new file mode 100644 index 00000000..0f7335e2 --- /dev/null +++ b/.chock/compiled/block-wildcard-agent-permissions/pre-tool-use/goose-write-hooks.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/goose.py --gate \".chock/compiled/block-wildcard-agent-permissions/pre-tool-use/gate.json\"", + "on_failure": "block", + "timeout": 45 + } + ], + "matcher": "write|edit" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-wildcard-agent-permissions/pre-tool-use/mistral_vibe-write-hooks.json b/.chock/compiled/block-wildcard-agent-permissions/pre-tool-use/mistral_vibe-write-hooks.json new file mode 100644 index 00000000..415e1032 --- /dev/null +++ b/.chock/compiled/block-wildcard-agent-permissions/pre-tool-use/mistral_vibe-write-hooks.json @@ -0,0 +1,9 @@ +[ + { + "type": "pre_tool", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/mistral_vibe.py --gate \".chock/compiled/block-wildcard-agent-permissions/pre-tool-use/gate.json\"", + "match": "write_file|edit", + "name": "agentseam-pre_tool-1be1eff7", + "timeout": 45 + } +] \ No newline at end of file diff --git a/.chock/compiled/block-wildcard-agent-permissions/pre-tool-use/openhands-write-hooks.json b/.chock/compiled/block-wildcard-agent-permissions/pre-tool-use/openhands-write-hooks.json new file mode 100644 index 00000000..f3a46425 --- /dev/null +++ b/.chock/compiled/block-wildcard-agent-permissions/pre-tool-use/openhands-write-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/openhands.py --gate \".chock/compiled/block-wildcard-agent-permissions/pre-tool-use/gate.json\"", + "timeout": 45 + } + ], + "matcher": "file_editor" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-wildcard-agent-permissions/stop/factory_droid-hooks.json b/.chock/compiled/block-wildcard-agent-permissions/stop/factory_droid-hooks.json new file mode 100644 index 00000000..5a9076e7 --- /dev/null +++ b/.chock/compiled/block-wildcard-agent-permissions/stop/factory_droid-hooks.json @@ -0,0 +1,13 @@ +{ + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/factory_droid.py --gate \".chock/compiled/block-wildcard-agent-permissions/stop/gate.json\"", + "timeout": 45 + } + ] + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/block-wildcard-agent-permissions/stop/goose-hooks.json b/.chock/compiled/block-wildcard-agent-permissions/stop/goose-hooks.json new file mode 100644 index 00000000..1c89c8a7 --- /dev/null +++ b/.chock/compiled/block-wildcard-agent-permissions/stop/goose-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/goose.py --gate \".chock/compiled/block-wildcard-agent-permissions/stop/gate.json\"", + "on_failure": "block", + "timeout": 45 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/block-wildcard-agent-permissions/stop/mistral_vibe-hooks.json b/.chock/compiled/block-wildcard-agent-permissions/stop/mistral_vibe-hooks.json new file mode 100644 index 00000000..27357a81 --- /dev/null +++ b/.chock/compiled/block-wildcard-agent-permissions/stop/mistral_vibe-hooks.json @@ -0,0 +1,8 @@ +[ + { + "type": "post_agent", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/mistral_vibe.py --gate \".chock/compiled/block-wildcard-agent-permissions/stop/gate.json\"", + "name": "agentseam-post_agent-5991567d", + "timeout": 45 + } +] \ No newline at end of file diff --git a/.chock/compiled/block-wildcard-agent-permissions/stop/openhands-hooks.json b/.chock/compiled/block-wildcard-agent-permissions/stop/openhands-hooks.json new file mode 100644 index 00000000..a806a126 --- /dev/null +++ b/.chock/compiled/block-wildcard-agent-permissions/stop/openhands-hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/openhands.py --gate \".chock/compiled/block-wildcard-agent-permissions/stop/gate.json\"", + "timeout": 45 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/pin-github-actions/pre-tool-use/factory_droid-write-hooks.json b/.chock/compiled/pin-github-actions/pre-tool-use/factory_droid-write-hooks.json new file mode 100644 index 00000000..e593d00d --- /dev/null +++ b/.chock/compiled/pin-github-actions/pre-tool-use/factory_droid-write-hooks.json @@ -0,0 +1,14 @@ +{ + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/factory_droid.py --gate \".chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", + "timeout": 45 + } + ], + "matcher": "Create|Edit|MultiEdit|ApplyPatch" + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/pin-github-actions/pre-tool-use/goose-write-hooks.json b/.chock/compiled/pin-github-actions/pre-tool-use/goose-write-hooks.json new file mode 100644 index 00000000..6cb3c5b1 --- /dev/null +++ b/.chock/compiled/pin-github-actions/pre-tool-use/goose-write-hooks.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/goose.py --gate \".chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", + "on_failure": "block", + "timeout": 45 + } + ], + "matcher": "write|edit" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/pin-github-actions/pre-tool-use/mistral_vibe-write-hooks.json b/.chock/compiled/pin-github-actions/pre-tool-use/mistral_vibe-write-hooks.json new file mode 100644 index 00000000..d7ccc723 --- /dev/null +++ b/.chock/compiled/pin-github-actions/pre-tool-use/mistral_vibe-write-hooks.json @@ -0,0 +1,9 @@ +[ + { + "type": "pre_tool", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/mistral_vibe.py --gate \".chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", + "match": "write_file|edit", + "name": "agentseam-pre_tool-2a087702", + "timeout": 45 + } +] \ No newline at end of file diff --git a/.chock/compiled/pin-github-actions/pre-tool-use/openhands-write-hooks.json b/.chock/compiled/pin-github-actions/pre-tool-use/openhands-write-hooks.json new file mode 100644 index 00000000..883a32d3 --- /dev/null +++ b/.chock/compiled/pin-github-actions/pre-tool-use/openhands-write-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/openhands.py --gate \".chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", + "timeout": 45 + } + ], + "matcher": "file_editor" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/pin-github-actions/stop/factory_droid-hooks.json b/.chock/compiled/pin-github-actions/stop/factory_droid-hooks.json new file mode 100644 index 00000000..0e15b8dc --- /dev/null +++ b/.chock/compiled/pin-github-actions/stop/factory_droid-hooks.json @@ -0,0 +1,13 @@ +{ + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/factory_droid.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", + "timeout": 45 + } + ] + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/pin-github-actions/stop/goose-hooks.json b/.chock/compiled/pin-github-actions/stop/goose-hooks.json new file mode 100644 index 00000000..5b022eef --- /dev/null +++ b/.chock/compiled/pin-github-actions/stop/goose-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/goose.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", + "on_failure": "block", + "timeout": 45 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/pin-github-actions/stop/mistral_vibe-hooks.json b/.chock/compiled/pin-github-actions/stop/mistral_vibe-hooks.json new file mode 100644 index 00000000..f707c1d9 --- /dev/null +++ b/.chock/compiled/pin-github-actions/stop/mistral_vibe-hooks.json @@ -0,0 +1,8 @@ +[ + { + "type": "post_agent", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/mistral_vibe.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", + "name": "agentseam-post_agent-b976d9db", + "timeout": 45 + } +] \ No newline at end of file diff --git a/.chock/compiled/pin-github-actions/stop/openhands-hooks.json b/.chock/compiled/pin-github-actions/stop/openhands-hooks.json new file mode 100644 index 00000000..9ce454f9 --- /dev/null +++ b/.chock/compiled/pin-github-actions/stop/openhands-hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/openhands.py --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", + "timeout": 45 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/factory_droid-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/factory_droid-hooks.json new file mode 100644 index 00000000..19a1a3ee --- /dev/null +++ b/.chock/compiled/protect-agent-config/pre-tool-use/factory_droid-hooks.json @@ -0,0 +1,14 @@ +{ + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/factory_droid.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.py\"", + "timeout": 45 + } + ], + "matcher": "Execute" + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/factory_droid-write-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/factory_droid-write-hooks.json new file mode 100644 index 00000000..edd584d1 --- /dev/null +++ b/.chock/compiled/protect-agent-config/pre-tool-use/factory_droid-write-hooks.json @@ -0,0 +1,14 @@ +{ + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/factory_droid.py --gate \".chock/compiled/protect-agent-config/pre-tool-use/gate.json\"", + "timeout": 45 + } + ], + "matcher": "Create|Edit|MultiEdit|ApplyPatch" + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/goose-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/goose-hooks.json new file mode 100644 index 00000000..d17afaac --- /dev/null +++ b/.chock/compiled/protect-agent-config/pre-tool-use/goose-hooks.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/goose.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.py\"", + "on_failure": "block", + "timeout": 45 + } + ], + "matcher": "shell" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/goose-write-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/goose-write-hooks.json new file mode 100644 index 00000000..46ae4353 --- /dev/null +++ b/.chock/compiled/protect-agent-config/pre-tool-use/goose-write-hooks.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/goose.py --gate \".chock/compiled/protect-agent-config/pre-tool-use/gate.json\"", + "on_failure": "block", + "timeout": 45 + } + ], + "matcher": "write|edit" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/mistral_vibe-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/mistral_vibe-hooks.json new file mode 100644 index 00000000..ef053a6d --- /dev/null +++ b/.chock/compiled/protect-agent-config/pre-tool-use/mistral_vibe-hooks.json @@ -0,0 +1,9 @@ +[ + { + "type": "pre_tool", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/mistral_vibe.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.py\"", + "match": "bash|powershell|git_bash", + "name": "agentseam-pre_tool-02fb3e0c", + "timeout": 45 + } +] \ No newline at end of file diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/mistral_vibe-write-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/mistral_vibe-write-hooks.json new file mode 100644 index 00000000..62461e0a --- /dev/null +++ b/.chock/compiled/protect-agent-config/pre-tool-use/mistral_vibe-write-hooks.json @@ -0,0 +1,9 @@ +[ + { + "type": "pre_tool", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/mistral_vibe.py --gate \".chock/compiled/protect-agent-config/pre-tool-use/gate.json\"", + "match": "write_file|edit", + "name": "agentseam-pre_tool-be959742", + "timeout": 45 + } +] \ No newline at end of file diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/openhands-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/openhands-hooks.json new file mode 100644 index 00000000..6b85a527 --- /dev/null +++ b/.chock/compiled/protect-agent-config/pre-tool-use/openhands-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/openhands.py --guard \".agents/policies/protect-agent-config/implementations/protect-agent-config.py\"", + "timeout": 45 + } + ], + "matcher": "terminal" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/protect-agent-config/pre-tool-use/openhands-write-hooks.json b/.chock/compiled/protect-agent-config/pre-tool-use/openhands-write-hooks.json new file mode 100644 index 00000000..93608ceb --- /dev/null +++ b/.chock/compiled/protect-agent-config/pre-tool-use/openhands-write-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/openhands.py --gate \".chock/compiled/protect-agent-config/pre-tool-use/gate.json\"", + "timeout": 45 + } + ], + "matcher": "file_editor" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/protect-agent-config/stop/factory_droid-hooks.json b/.chock/compiled/protect-agent-config/stop/factory_droid-hooks.json new file mode 100644 index 00000000..70adcf87 --- /dev/null +++ b/.chock/compiled/protect-agent-config/stop/factory_droid-hooks.json @@ -0,0 +1,13 @@ +{ + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/factory_droid.py --gate \".chock/compiled/protect-agent-config/stop/gate.json\"", + "timeout": 45 + } + ] + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/protect-agent-config/stop/goose-hooks.json b/.chock/compiled/protect-agent-config/stop/goose-hooks.json new file mode 100644 index 00000000..2720bbcd --- /dev/null +++ b/.chock/compiled/protect-agent-config/stop/goose-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/goose.py --gate \".chock/compiled/protect-agent-config/stop/gate.json\"", + "on_failure": "block", + "timeout": 45 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/protect-agent-config/stop/mistral_vibe-hooks.json b/.chock/compiled/protect-agent-config/stop/mistral_vibe-hooks.json new file mode 100644 index 00000000..040a064b --- /dev/null +++ b/.chock/compiled/protect-agent-config/stop/mistral_vibe-hooks.json @@ -0,0 +1,8 @@ +[ + { + "type": "post_agent", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/mistral_vibe.py --gate \".chock/compiled/protect-agent-config/stop/gate.json\"", + "name": "agentseam-post_agent-8a1a761d", + "timeout": 45 + } +] \ No newline at end of file diff --git a/.chock/compiled/protect-agent-config/stop/openhands-hooks.json b/.chock/compiled/protect-agent-config/stop/openhands-hooks.json new file mode 100644 index 00000000..e982cd60 --- /dev/null +++ b/.chock/compiled/protect-agent-config/stop/openhands-hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/openhands.py --gate \".chock/compiled/protect-agent-config/stop/gate.json\"", + "timeout": 45 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/protect-commit-privacy/pre-tool-use/factory_droid-hooks.json b/.chock/compiled/protect-commit-privacy/pre-tool-use/factory_droid-hooks.json new file mode 100644 index 00000000..5d529717 --- /dev/null +++ b/.chock/compiled/protect-commit-privacy/pre-tool-use/factory_droid-hooks.json @@ -0,0 +1,14 @@ +{ + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/factory_droid.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.py\"", + "timeout": 45 + } + ], + "matcher": "Execute" + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/protect-commit-privacy/pre-tool-use/goose-hooks.json b/.chock/compiled/protect-commit-privacy/pre-tool-use/goose-hooks.json new file mode 100644 index 00000000..b00719c6 --- /dev/null +++ b/.chock/compiled/protect-commit-privacy/pre-tool-use/goose-hooks.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/goose.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.py\"", + "on_failure": "block", + "timeout": 45 + } + ], + "matcher": "shell" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/protect-commit-privacy/pre-tool-use/mistral_vibe-hooks.json b/.chock/compiled/protect-commit-privacy/pre-tool-use/mistral_vibe-hooks.json new file mode 100644 index 00000000..30616e14 --- /dev/null +++ b/.chock/compiled/protect-commit-privacy/pre-tool-use/mistral_vibe-hooks.json @@ -0,0 +1,9 @@ +[ + { + "type": "pre_tool", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/mistral_vibe.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.py\"", + "match": "bash|powershell|git_bash", + "name": "agentseam-pre_tool-5f6e48e6", + "timeout": 45 + } +] \ No newline at end of file diff --git a/.chock/compiled/protect-commit-privacy/pre-tool-use/openhands-hooks.json b/.chock/compiled/protect-commit-privacy/pre-tool-use/openhands-hooks.json new file mode 100644 index 00000000..a0ecd254 --- /dev/null +++ b/.chock/compiled/protect-commit-privacy/pre-tool-use/openhands-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/openhands.py --guard \".agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.py\"", + "timeout": 45 + } + ], + "matcher": "terminal" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/scan-secrets/pre-tool-use/factory_droid-write-hooks.json b/.chock/compiled/scan-secrets/pre-tool-use/factory_droid-write-hooks.json new file mode 100644 index 00000000..8f15c8e5 --- /dev/null +++ b/.chock/compiled/scan-secrets/pre-tool-use/factory_droid-write-hooks.json @@ -0,0 +1,14 @@ +{ + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/factory_droid.py --gate \".chock/compiled/scan-secrets/pre-tool-use/gate.json\"", + "timeout": 45 + } + ], + "matcher": "Create|Edit|MultiEdit|ApplyPatch" + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/scan-secrets/pre-tool-use/goose-write-hooks.json b/.chock/compiled/scan-secrets/pre-tool-use/goose-write-hooks.json new file mode 100644 index 00000000..aa59e0bc --- /dev/null +++ b/.chock/compiled/scan-secrets/pre-tool-use/goose-write-hooks.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/goose.py --gate \".chock/compiled/scan-secrets/pre-tool-use/gate.json\"", + "on_failure": "block", + "timeout": 45 + } + ], + "matcher": "write|edit" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/scan-secrets/pre-tool-use/mistral_vibe-write-hooks.json b/.chock/compiled/scan-secrets/pre-tool-use/mistral_vibe-write-hooks.json new file mode 100644 index 00000000..623bc77a --- /dev/null +++ b/.chock/compiled/scan-secrets/pre-tool-use/mistral_vibe-write-hooks.json @@ -0,0 +1,9 @@ +[ + { + "type": "pre_tool", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/mistral_vibe.py --gate \".chock/compiled/scan-secrets/pre-tool-use/gate.json\"", + "match": "write_file|edit", + "name": "agentseam-pre_tool-29b54959", + "timeout": 45 + } +] \ No newline at end of file diff --git a/.chock/compiled/scan-secrets/pre-tool-use/openhands-write-hooks.json b/.chock/compiled/scan-secrets/pre-tool-use/openhands-write-hooks.json new file mode 100644 index 00000000..3d856f3c --- /dev/null +++ b/.chock/compiled/scan-secrets/pre-tool-use/openhands-write-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/openhands.py --gate \".chock/compiled/scan-secrets/pre-tool-use/gate.json\"", + "timeout": 45 + } + ], + "matcher": "file_editor" + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/scan-secrets/stop/factory_droid-hooks.json b/.chock/compiled/scan-secrets/stop/factory_droid-hooks.json new file mode 100644 index 00000000..bf8d9420 --- /dev/null +++ b/.chock/compiled/scan-secrets/stop/factory_droid-hooks.json @@ -0,0 +1,13 @@ +{ + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/factory_droid.py --gate \".chock/compiled/scan-secrets/stop/gate.json\"", + "timeout": 45 + } + ] + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/scan-secrets/stop/goose-hooks.json b/.chock/compiled/scan-secrets/stop/goose-hooks.json new file mode 100644 index 00000000..50b5b410 --- /dev/null +++ b/.chock/compiled/scan-secrets/stop/goose-hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/goose.py --gate \".chock/compiled/scan-secrets/stop/gate.json\"", + "on_failure": "block", + "timeout": 45 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/scan-secrets/stop/mistral_vibe-hooks.json b/.chock/compiled/scan-secrets/stop/mistral_vibe-hooks.json new file mode 100644 index 00000000..9818de43 --- /dev/null +++ b/.chock/compiled/scan-secrets/stop/mistral_vibe-hooks.json @@ -0,0 +1,8 @@ +[ + { + "type": "post_agent", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/mistral_vibe.py --gate \".chock/compiled/scan-secrets/stop/gate.json\"", + "name": "agentseam-post_agent-253321d2", + "timeout": 45 + } +] \ No newline at end of file diff --git a/.chock/compiled/scan-secrets/stop/openhands-hooks.json b/.chock/compiled/scan-secrets/stop/openhands-hooks.json new file mode 100644 index 00000000..f5e49dcb --- /dev/null +++ b/.chock/compiled/scan-secrets/stop/openhands-hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/openhands.py --gate \".chock/compiled/scan-secrets/stop/gate.json\"", + "timeout": 45 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/token-efficiency/pre-tool-use/factory_droid-tool-call-hooks.json b/.chock/compiled/token-efficiency/pre-tool-use/factory_droid-tool-call-hooks.json new file mode 100644 index 00000000..2674dc71 --- /dev/null +++ b/.chock/compiled/token-efficiency/pre-tool-use/factory_droid-tool-call-hooks.json @@ -0,0 +1,24 @@ +{ + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/factory_droid.py --tool-call \".chock/compiled/token-efficiency/pre-tool-use/tool-call-gate.json\"", + "timeout": 45 + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/factory_droid.py --record \".chock/compiled/token-efficiency/pre-tool-use/tool-call-gate.json\"", + "timeout": 45 + } + ] + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/token-efficiency/pre-tool-use/goose-tool-call-hooks.json b/.chock/compiled/token-efficiency/pre-tool-use/goose-tool-call-hooks.json new file mode 100644 index 00000000..0477af8e --- /dev/null +++ b/.chock/compiled/token-efficiency/pre-tool-use/goose-tool-call-hooks.json @@ -0,0 +1,28 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/goose.py --tool-call \".chock/compiled/token-efficiency/pre-tool-use/tool-call-gate.json\"", + "on_failure": "block", + "timeout": 45 + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/goose.py --record \".chock/compiled/token-efficiency/pre-tool-use/tool-call-gate.json\"", + "on_failure": "block", + "timeout": 45 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.chock/compiled/token-efficiency/pre-tool-use/mistral_vibe-tool-call-hooks.json b/.chock/compiled/token-efficiency/pre-tool-use/mistral_vibe-tool-call-hooks.json new file mode 100644 index 00000000..7a661516 --- /dev/null +++ b/.chock/compiled/token-efficiency/pre-tool-use/mistral_vibe-tool-call-hooks.json @@ -0,0 +1,16 @@ +[ + { + "type": "pre_tool", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/mistral_vibe.py --tool-call \".chock/compiled/token-efficiency/pre-tool-use/tool-call-gate.json\"", + "match": "*", + "name": "agentseam-pre_tool-cfb34027", + "timeout": 45 + }, + { + "type": "post_tool", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/mistral_vibe.py --record \".chock/compiled/token-efficiency/pre-tool-use/tool-call-gate.json\"", + "match": "*", + "name": "agentseam-post_tool-0203fa14", + "timeout": 45 + } +] \ No newline at end of file diff --git a/.chock/compiled/token-efficiency/pre-tool-use/openhands-tool-call-hooks.json b/.chock/compiled/token-efficiency/pre-tool-use/openhands-tool-call-hooks.json new file mode 100644 index 00000000..d8797dad --- /dev/null +++ b/.chock/compiled/token-efficiency/pre-tool-use/openhands-tool-call-hooks.json @@ -0,0 +1,26 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/openhands.py --tool-call \".chock/compiled/token-efficiency/pre-tool-use/tool-call-gate.json\"", + "timeout": 45 + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "git -c \"alias.chock-hook=!test -f .chock/bin/launch.sh || { echo chock: no .chock/bin/launch.sh here, run chock sync --repo . >&2; exit 2; }; sh .chock/bin/launch.sh\" chock-hook .chock/bin/openhands.py --record \".chock/compiled/token-efficiency/pre-tool-use/tool-call-gate.json\"", + "timeout": 45 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/chock.lock b/chock.lock index 76260650..515d987c 100644 --- a/chock.lock +++ b/chock.lock @@ -16,7 +16,7 @@ "managed": false, "sha256": "ba1108ffdfce230bf66ee9d83a71c46cddb7d7c995759d4daee987439107afc6", "source": "local", - "artifacts_sha256": "4a6e2049781696c7111ac99333efc71b85308b71b329ecd05a2b2f8b5ffdcc9c" + "artifacts_sha256": "d8681dfc8b57f3c78799d509886b45bbfdd4477b170ff358e113ae8dacc1a310" }, { "id": "block-invisible-unicode", @@ -24,7 +24,7 @@ "managed": false, "sha256": "bc91b8805d38ab92649b5eb69cf7a40c311ad86263ba343935ba4a5a4c3a8d3e", "source": "local", - "artifacts_sha256": "9b92f04913f3aaa206f48ec3832fc5ba6669d797dfc8a8fb3c6a3d4c2bc27189" + "artifacts_sha256": "e0403ed9ac6246cd613740910d819e9fcfaa9185d74074919301557bb73efe2a" }, { "id": "block-no-verify", @@ -32,7 +32,7 @@ "managed": false, "sha256": "67c7a06a6676eb0ecc8f929dbdf8fc181ead721c8aa7b6e0f29e1757fdfbb3ff", "source": "local", - "artifacts_sha256": "37f633ac5859acf1394ef78203ac7ebc4efa64b3f8387b791ff0648894d1f1a7" + "artifacts_sha256": "435d6552977fbf856ac3ee29ea2902e4292072b6e35bf09d3635b4915dbc5cde" }, { "id": "block-wildcard-agent-permissions", @@ -40,7 +40,7 @@ "managed": false, "sha256": "123c2f3a5c286c2677f00e0ac93cef75c4604e84a1af2464fb89c91b0575aed4", "source": "local", - "artifacts_sha256": "31cb6ecf602628ee4419f4ee5ef6eb298a3bc070ba94935f823e705a452716d4" + "artifacts_sha256": "ce7a8b83daecf2c9479ac233068216be5d5ec086caf8f2c6f6a18dc619b8a830" }, { "id": "code-safety", @@ -96,7 +96,7 @@ "managed": false, "sha256": "53677765d84916220ac717424bbdf9dce4089257bfc873a3ed354cdb38f7865c", "source": "local", - "artifacts_sha256": "5a555c646bcb5ec19e9f607b2227c512fcd5a2f399915e2d6f72754fb5491e12" + "artifacts_sha256": "8b40e464285d8637314e1105012f7a969ddd96fef94338000281f588fbba30d2" }, { "id": "pre-generated-scripts", @@ -112,7 +112,7 @@ "managed": false, "sha256": "29c1a18fa1bd3ee2ab34a0ea1d1c07617a5841b8afbd65ad8704d31093c4fcc9", "source": "local", - "artifacts_sha256": "0f26eedc043ac84f1ce6ddee47d257b8f9c283d105d116d4532ee3cf8d0d0f2d" + "artifacts_sha256": "07a281e17de3e63a0eedd4fb32914f2c3c3cc7c59468213edba63e698d2d7fcc" }, { "id": "protect-commit-privacy", @@ -120,7 +120,7 @@ "managed": false, "sha256": "22045da796ba466d8043b7d4322b6dbfdcc46e3fd9709221c505acdf24d08119", "source": "local", - "artifacts_sha256": "bc816a321567a172180472a8e3d0b90461a22f5bde2a36892b257735ceaa6fa6" + "artifacts_sha256": "c752bb2b3f20c6d75c9b2c90bd176fbf2c7c54b7e480147d875442150c103294" }, { "id": "protect-main-branch", @@ -144,7 +144,7 @@ "managed": false, "sha256": "ac98dc12772df80b1133d262996d7e52b279281c1f1df2095693aceba58952ec", "source": "local", - "artifacts_sha256": "592c913e0528b81cd8796bf03e23c0712e963d2c834e582f435cf60b0df6bfb7" + "artifacts_sha256": "44a9631994c1beb8fe05e7de973de7a68312a9f6d63b9a0c5868515777c9dc70" }, { "id": "test-integrity", @@ -160,7 +160,7 @@ "managed": false, "sha256": "cea845541e17c74ce0ffe8fd269c1b00d649d96c61a0b504a185b7ed6e247b4e", "source": "local", - "artifacts_sha256": "600d16af2a46cfa721808ab8c16bf2b5965e4f69c04a671e2a13084a9cf7f1c5" + "artifacts_sha256": "dbc26807e0d6ab796e909fd55a874546e6777bbdfb288edc7c3e7e92bd7cad5b" }, { "id": "verify-dependency-exists", From 63bfdc2a910f1653fa4397a2e22206dfadcd4fd4 Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:57:45 +0000 Subject: [PATCH 8/8] figures: social-card.png as the CI runner renders it Decoded from the figures job's base64 print (job 114641371959); blob f7e4600 matches the post-image in CI's drift diff, and every chunk CRC checks. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- docs/figures/social-card.png | Bin 25503 -> 27033 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/figures/social-card.png b/docs/figures/social-card.png index 26f93806b50e28888fd92eba8b4d23299ad6b083..f7e46004f58587461a247ac3be411319cc721d5e 100644 GIT binary patch literal 27033 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 E7aMEO&j0`b