diff --git a/docs/proposals/RFC-023-hook-bundle-hardening-consensus-plan.md b/docs/proposals/RFC-023-hook-bundle-hardening-consensus-plan.md new file mode 100644 index 0000000..88d1549 --- /dev/null +++ b/docs/proposals/RFC-023-hook-bundle-hardening-consensus-plan.md @@ -0,0 +1,133 @@ +# RFC-023: Hook Bundle Hardening — Consensus Execution Plan + +Status: Draft +Plan type: Non-normative execution plan for the reference-implementation hook bundle +Created: 2026-05-21 +Authors: Codex + Claude (claude-opus-4-7), iterated to a fixed point and owner-decided + +Inputs strictly audited: + +- A multi-round Codex × Claude consensus series (Codex r1–r5 × Claude v1–v6) stored as local scratch under `.agent-protocol-runtime/` (gitignored; not part of this tree). +- Direct empirical probing of the reference hook scripts under `reference-implementations/hooks-claude-code/hooks/`. + +Scope: capture the findings of a risk audit of this repository's reference-implementation hook bundle, the dispositions both reviewers agreed on after error-correction, and the six owner decisions that resolved the open policy questions. This proposal does **not** modify canonical methodology and does not itself change any hook. It is an execution-ready plan that should be promoted to implementation wave-by-wave per the sequencing below. + +This proposal is non-normative per `docs/proposals/README.md`. The hook bundle it plans to change is itself a reference implementation (`docs/runtime-hook-contract.md` is the canonical contract; the scripts are one conforming realization). + +--- + +## 0. Verdict and consensus + +Two independent agents reviewed the audit, corrected each other's factual errors over several rounds, and reached a fixed point (two consecutive rounds in which neither found a substantive factual error in the other). The owner then decided the six open policy questions. + +- Fact / repro / wiring consensus: ~99% (every claim below was verified by running the actual hooks). +- Disposition consensus: ~97%. +- Decision-complete executable consensus: ~98%. The residual ~2% is execution-time validation that cannot close in a planning document — an implementer must add a failing fixture for each false-block first, and an independent reviewer must confirm every repro is fixed and every destructive positive control still blocks. + +--- + +## 1. Two-layer wiring model + +A recurring source of confusion in the audit was the word "wired." It has two distinct meanings in this repo, and every statement about a hook must name which layer it refers to: + +- **Layer A — repo plugin entry point**: `hooks/hooks.json`, consumed by Claude Code plugin auto-discovery. This is the set that is active simply by installing the plugin. It wires 10 hook invocations. +- **Layer B — shipped reference / adopter examples**: the five `settings.example.*` files (`reference-implementations/hooks-claude-code/settings.example.json` plus the Codex / Cursor / Gemini-CLI [TOML] / Windsurf settings examples). This is the set a user gets after copying an example settings file, and it is broader than Layer A. + +| Hook | Layer A | Layer B | Notes | +|---|---|---|---| +| `manifest-required.sh` | block | block | Blocks non-doc staged commits even when discovery finds no manifest (absent manifest **is** the violation). | +| `evidence-artifact-exists.sh` | block | block | No-op when no manifest is discovered or supplied. | +| `consumer-registry-check.sh` | warn | warn | **Only hook that invokes the network**; only after a manifest is discovered/supplied with an `external_registry_url`. | +| `cckn-canonical-sync-check.sh` | warn | warn | Local git + frontmatter drift; **does not** touch the network. | +| `risky-bash-block.sh` | block | block | Subject of P3 / P4 / P5 / P6. | +| `risky-file-block.sh` | block | block | P4 consistency reference. | +| `risky-mcp-block.sh` | block | block | Subject of P5 (MCP SQL literals). | +| `sot-drift-check.sh` | warn | warn | Post-edit; no-op without a manifest. | +| `drift-doc-refresh.sh` | warn | warn | Post-edit; no-op without a manifest. | +| `completion-audit.sh` | block | block | **Only Layer A Stop blocker**; no-op without a manifest. | +| `handoff-prompt-validator.sh` | not wired | block/warn | **Layer B** Stop / session-end blocker (`on-stop`, block on hard cap), **not** Layer A. | +| `context-budget-warn.sh` | not wired | warn | Layer B `pre-tool-use:Read,Grep`. | +| `resume-mode-attestation.sh` | not wired | warn | Layer B `pre-tool-use:Read,Grep,Bash`. | +| `router-justification-warn.sh` | not wired | warn | Layer B `pre-tool-use:Read,Grep`. | +| `file-emission-budget-warn.sh` | not wired | warn | Layer B `post-tool-use:Edit|Write|MultiEdit`. | +| `manifest-size-warn.sh` | not wired | warn | Layer B `pre-commit`. | + +--- + +## 2. Findings + +Each finding was confirmed by running the hook against the literal command. False-block repros below are verified to block on the current `main`. + +- **P1 — `consumer-registry-check.sh` manifest-controlled network egress.** The hook reads `external_registry_url` values out of a manifest and probes them with `curl` on `git push`. It never blocks (warn-level), so this is a network-egress / transparency concern, not a correctness one. Default timeout is configurable via `AGENT_PROTOCOL_NET_TIMEOUT` (default 5). The bundle README (lines ~92 and ~153) incorrectly states the bundle has "no network" / "never ... invoke network", which contradicts this script. +- **P2 — `manifest-required.sh` docs-grade allowlist is narrow.** Allowlist is `*.md|docs/*|*.txt|CHANGELOG*|LICENSE*|.gitignore`. Non-doc edits require either a manifest or the existing Lean escape (`AGENT_PROTOCOL_LEAN_SKIP_MANIFEST=1` **and** a repo-root `lean-mode.flag`). +- **P3 — protected-branch force-push matcher is too broad.** Group 1's unqualified `\b$br\b` alternative matches a protected name anywhere in the command. Confirmed: `git push --force-with-lease origin release-1.5` blocks (false-block — a release-prefixed feature branch), and `git push --force origin feature/release-notes` blocks (false-block). Naively removing the broad alternative would *under*-block real protected destinations such as `feature:main` and `:main`, so the fix must add destination-refspec matching, not just delete. +- **P4 — Bash `.pem` read has no `tests/` exception.** `risky-bash-block.sh` Group 9 blocks `cat tests/fixtures/test.pem`, while `risky-file-block.sh` Matcher 5 already exempts `.pem` under `tests/` and `test/`. Inconsistent. +- **P5 — destructive verbs inside SQL string literals false-block.** `psql -c "SELECT 'DROP TABLE x' AS warning"` blocks; MCP `SELECT ' DROP TABLE x' AS warning` blocks (leading whitespace inside the quoted literal satisfies the matcher boundary). The originally-suspected examples (`'DELETE_USER'`, `aws lambda invoke --function-name delete-old-items`) are **not** repros — they already pass. +- **P6 — `eval` blocks common shell init.** Group 8 blocks `eval "$(direnv hook bash)"` and similar (`ssh-agent`, `starship`, `rbenv`, …). +- **P7 — completion / evidence gates have no bypass.** `completion-audit.sh` and `evidence-artifact-exists.sh` have no `*_ALLOW` escape. This asymmetry vs the risky-action hooks is by design — these gates enforce truthfulness, not operator-approved risk. +- **NEW-A — `evidence-artifact-exists.sh`** (commit-time block hook) was missing from the original audit's scope; folded into P7 / P8. +- **NEW-B — `sot-drift-check.sh` + `drift-doc-refresh.sh`** fire warn-level on every `Edit|Write|MultiEdit`; potential noise, observe-only for now. +- **NEW-C — shared manifest discovery.** Fourteen hooks use `MANIFEST_PATH=$(git ls-files 'change-manifest*.yaml' | head -1)`. This pathspec is root-anchored and returns nothing in this repo, so manifest-consuming hooks no-op via the fallback (and `manifest-required.sh` *blocks* non-doc commits because absent manifest is its violation). A nested manifest is invisible to the fallback; `AGENT_PROTOCOL_MANIFEST_PATH` is the reliable binding. This can stack with P2 friction. + +--- + +## 3. Owner decisions (binding for this plan) + +| # | Question | Decision | +|---|---|---| +| 1 | P1 execution mode | **Full** (default network-behavior change on a Layer A wired hook + cross-runtime adopter behavior). | +| 2 | P2 docs-grade allowlist | **Keep narrow (Option A)**; document the existing Lean-skip better. No hook change. | +| 3 | P3 release-prefixed branches | **Treat as normal feature branches** (`release-1.5`, `feature/release-notes` pass); only the protected name as a refspec **destination** blocks. | +| 4 | P7 / NEW-A bypass | **Keep both gates non-bypassable.** Recovery from a stuck gate is to fix the manifest, not to skip the check. | +| 5 | P5 cloud-CLI scope | **First wave does SQL/MCP literals only.** Cloud-CLI Group 6 argument/path narrowing is deferred. | +| 6 | NEW-C discovery hardening | **Document only.** No discovery change now. | + +Decisions 2, 4, and 6 mean the corresponding work is documentation-only or closed; they do not open behavior-changing waves. + +--- + +## 4. Execution waves + +All acceptance uses the selftest harness (`reference-implementations/hooks-claude-code/selftests/`), never live `git commit` / `git push` / network / packet capture. Every false-block fix begins with a fixture that fails on the current `main`; every destructive positive control must keep blocking. + +### Wave 0 — re-baseline (read-only) +Confirm the suite is green (`151 cases, 0 failed` for the Claude bundle; `4 cases, 0 failed` for each of the Codex/Cursor/Gemini-CLI/Windsurf adapters), record NEW-C current behavior (`test -z "$(git ls-files 'change-manifest*.yaml')"`), and have a reviewer accept the P3 token/refspec fixture matrix before any code change. + +### Wave 1 — behavior-disclosure docs (Lean, docs-only) — do first +Files: `reference-implementations/hooks-claude-code/README.md`, `docs/runtime-hooks-in-practice.md`, possibly `reference-implementations/hooks-claude-code/DEVIATIONS.md`. Do **not** edit `AGENTS.md`. +Content: the two-layer wiring model; the only network hook (consumer-registry-check); the Stop blockers (Layer A = completion-audit; Layer B adds handoff-prompt-validator); commit blockers (manifest-required blocks on absent manifest, evidence-artifact-exists); each warn hook's verified trigger; the existing Lean-skip mechanism (decision 2); that completion/evidence gates are intentionally non-bypassable (decision 4); the NEW-C discovery behavior and `AGENT_PROTOCOL_MANIFEST_PATH` binding (decision 6); and a correction of the README "no network" contradiction. + +### Wave 2 — Bash false-block narrowing (Lean) — P3 first (risk driver) +File: `risky-bash-block.sh` + its fixtures. +- **P3**: remove the broad whole-command `\b$br\b`; match a protected name only as a refspec **destination** — ` main` (2nd positional, any remote), `src:dst`, `:dst` (delete), `HEAD:dst`, `refs/heads/dst`; a 1st-positional token (the repository, e.g. lone `release`) and a path-segment substring pass. Specification-first + fixture-driven; if the matcher grows beyond a local token/refspec helper, P3 becomes its own sub-wave. +- **P4**: add the `tests/` / `test/` exception to Group 9 to match `risky-file-block.sh` Matcher 5; do not touch `.env` in this wave. +- **P6**: narrow whitelist for `eval "$( ...)"` over `{direnv, ssh-agent, starship, rbenv, pyenv, nodenv, goenv, fnm, nvm, zoxide, atuin, mise}`; do not downgrade all `eval` to warn. + +### Wave 3 — network probe opt-in (Full) — P1 +Files: `consumer-registry-check.sh` + fixtures + README + `docs/runtime-hooks-in-practice.md` + `DEVIATIONS.md`. +Add `AGENT_PROTOCOL_CONSUMER_PROBE` (default 0): no outbound probe unless set; on opt-in, print one stderr disclosure of the URLs before probing; preserve warn-only behavior. Fixtures use the `curl-exit` stub (default-off + `curl-exit=99` → exit 0 proves curl was not invoked; opt-in + `curl-exit=0` → exit 0 with disclosure; opt-in + `curl-exit=7` → exit 2). Full is justified by the Layer A / cross-runtime behavior change; the README correction is incidental, not the trigger. + +### Wave 4 — SQL / MCP literal narrowing (Lean+) — P5, SQL/MCP only +Files: `risky-bash-block.sh` (Group 5) + `risky-mcp-block.sh` (Matcher 3) + fixtures. +Add failing fixtures first, then fix via fixture-proven literal masking (strip single/double-quoted literals before destructive-verb matching; statement-anchoring alone cannot fix leading whitespace inside a quote). Keep literal handling deliberately narrow. **Do not touch cloud-CLI Group 6** (decision 5). A shared parser helper across Bash and MCP would upgrade ceremony to Full. + +### Closed by decision +P2 hook change (decision 2 → docs only), P7/NEW-A bypass (decision 4 → none), P5 cloud-CLI Group 6 (decision 5 → deferred), NEW-C discovery hardening (decision 6 → docs only). If any decision is later reversed, the corresponding wave reopens (NEW-C hardening would be Full and touch all fourteen scripts or introduce a shared helper). + +--- + +## 5. Stop rules + +Stop and escalate if: a baseline selftest is red; a fixture for a claimed false-block already passes on `main`; a destructive positive control flips from block to pass; acceptance would require live network / commit / push / packet capture; a Lean wave would change `docs/runtime-hook-contract.md` normative semantics; a patch silently broadens `.env` / credential / protected-branch / manifest-discovery / bypass policy beyond the named item; adapter settings drift from the shared hook script paths; or any plan / summary says "wired" without naming Layer A or Layer B. + +--- + +## 6. Relationship to canonical content + +Per `docs/proposals/README.md`, this RFC is never cited as a normative source. The canonical contract for these hooks is `docs/runtime-hook-contract.md`; if a Wave 1 doc correction reveals a contract gap (e.g. the network-degradation clause vs the README "no network" claim), the canonical layer is the source of truth and is updated through the standard `docs/file-role-map.md` flow — not by citing this RFC. + +--- + +## Changelog + +- 2026-05-21: Created (Status: Draft). Promoted from the Codex × Claude consensus scratch series after the series reached a fixed point and the owner decided the six open policy questions. Wave 1 Change Manifest drafted alongside at `docs/proposals/RFC-023-wave1.change-manifest.yaml`. diff --git a/docs/proposals/RFC-023-wave1.change-manifest.yaml b/docs/proposals/RFC-023-wave1.change-manifest.yaml new file mode 100644 index 0000000..67ec4e4 --- /dev/null +++ b/docs/proposals/RFC-023-wave1.change-manifest.yaml @@ -0,0 +1,99 @@ +# Draft Change Manifest for RFC-023 Wave 1 (behavior-disclosure docs). +# Status: draft / phase: plan — not yet executed. Stored beside the RFC, NOT at +# repo root, so the root-anchored manifest-discovery fallback (RFC-023 §NEW-C) +# does not auto-bind the wired hooks to it prematurely. When Wave 1 execution +# begins, the implementer either moves this to the active location or exports +# AGENT_PROTOCOL_MANIFEST_PATH to point at it. + +change_id: 2026-05-21-rfc023-w1-hook-disclosure-docs +title: "RFC-023 Wave 1 — hook bundle behavior-disclosure docs" +phase: plan +status: draft +execution_mode: lean +manifest_role: monolithic + +authors: + - name: owner + role: human + - name: claude-opus-4-7 + role: ai + identifier: claude-opus-4-7 + +last_updated: "2026-05-21T00:00:00Z" + +strategic_parent: + kind: rfc + location: docs/proposals/RFC-023-hook-bundle-hardening-consensus-plan.md + summary: >- + RFC-023 Wave 1 is the docs-only first wave: disclose the two-layer wiring + model, the only network hook, the Stop/commit blockers, the existing + Lean-skip, the intentionally non-bypassable gates, and NEW-C discovery + behavior; and correct the README "no network" contradiction. + +surfaces_touched: + - surface: information + role: primary + notes: >- + README + runtime-hooks-in-practice docs are the information surface that + describes hook behavior; they currently drift from the scripts. + +sot_map: + - info_name: "hook bundle behavior (wiring + network/Stop/commit/bypass semantics)" + pattern: 2 + source: "hooks/hooks.json + reference-implementations/hooks-claude-code/hooks/*.sh" + consumers: + - name: "reference-implementations/hooks-claude-code/README.md" + kind: documentation + sync_lag: manual + - name: "docs/runtime-hooks-in-practice.md" + kind: documentation + sync_lag: manual + sync_mechanism: "manual doc update on hook behavior change" + desync_risk: medium + notes: >- + README (~lines 92/153) claims the bundle has "no network" / "never + invoke network", which contradicts consumer-registry-check.sh's curl + probe. Wave 1 reconciles the docs to the scripts. + +breaking_change: + level: L0 + self_assessed_vs_worst_case: matches + +rollback: + overall_mode: 1 + per_surface_modes: + - surface: information + mode: 1 + rationale: "Docs-only; revert the commit to restore prior text." + +evidence_plan: + - type: runbook_update + surface: information + status: planned + summary: >- + README §Behavior summary + docs/runtime-hooks-in-practice.md disclosure: + two-layer (A/B) wiring table, only-network-hook = consumer-registry-check, + Stop blockers (A=completion-audit; B adds handoff-prompt-validator), + commit blockers, each warn hook's verified trigger, existing Lean-skip, + non-bypassable completion/evidence gates, and NEW-C discovery behavior + + AGENT_PROTOCOL_MANIFEST_PATH binding. + - type: changelog_entry + surface: information + status: planned + summary: >- + Correct the README "no network" / "never invoke network" claim to reflect + that consumer-registry-check.sh performs an outbound curl probe. + +assumptions: + - statement: >- + Wave 1 is docs-only and changes no hook behavior, so the Claude bundle + selftest (151/0) and the four adapter selftests (4/0 each) remain green + with no fixture changes. + confidence: high + validation_plan: "Run all five selftest suites + git diff --check + make verify-local." + - statement: >- + Correcting the README "no network" text does not alter docs/runtime-hook-contract.md + normative semantics; if it surfaces a contract gap, the contract is the SoT + and is updated via the docs/file-role-map.md flow, not this wave. + confidence: medium + validation_plan: "Reviewer confirms no normative contract edit is required for Wave 1." diff --git a/docs/proposals/RFC-023-wave3.change-manifest.yaml b/docs/proposals/RFC-023-wave3.change-manifest.yaml new file mode 100644 index 0000000..de6fa79 --- /dev/null +++ b/docs/proposals/RFC-023-wave3.change-manifest.yaml @@ -0,0 +1,236 @@ +change_id: 2026-05-21-rfc023-w3-consumer-probe-optin +title: "RFC-023 Wave 3 — consumer-registry-check network probe opt-in" +phase: review +status: accepted +execution_mode: full +forced_full_trigger: other +manifest_role: monolithic + +authors: + - name: owner + role: human + - name: claude-opus-4-7 + role: ai + identifier: claude-opus-4-7 + +last_updated: "2026-05-21T00:00:00Z" + +strategic_parent: + kind: rfc + location: docs/proposals/RFC-023-hook-bundle-hardening-consensus-plan.md + summary: >- + Wave 3 makes consumer-registry-check.sh's outbound curl probe opt-in + (default off via AGENT_PROTOCOL_CONSUMER_PROBE); on opt-in it prints one + stderr disclosure of target URLs before probing; warn-only preserved. + +surfaces_touched: + - surface: system_interface + role: primary + notes: "hook default network posture + fixtures" + - surface: information + role: consumer + notes: "README + practice doc + DEVIATIONS reconcile to new default" + +sot_map: + - info_name: "consumer-registry-check network probe behavior" + pattern: 2 + source: "reference-implementations/hooks-claude-code/hooks/consumer-registry-check.sh" + consumers: + - name: "reference-implementations/hooks-claude-code/README.md" + kind: documentation + sync_lag: manual + - name: "docs/runtime-hooks-in-practice.md" + kind: documentation + sync_lag: manual + - name: "reference-implementations/hooks-claude-code/DEVIATIONS.md" + kind: documentation + sync_lag: manual + - name: "reference-implementations/hooks-claude-code/selftests/README.md" + kind: documentation + sync_lag: manual + sync_mechanism: "manual doc update on hook behavior change" + desync_risk: medium + notes: "Wave 1 corrected the 'no network' claim; Wave 3 adds the opt-in/default-off qualifier." + +breaking_change: + level: L1 + self_assessed_vs_worst_case: matches + affected_consumers: + - category: third_party_external + identifier: "Layer A plugin adopters + Layer B settings.example.* adopters" + notes: >- + Rely on the push-time consumer-registry staleness warn; lose it + silently after this wave unless they opt back in. + migration_path: custom + migration_plan: "Set AGENT_PROTOCOL_CONSUMER_PROBE=1 to restore the prior auto-probe behavior." + +rollback: + overall_mode: 1 + per_surface_modes: + - surface: information + mode: 1 + rationale: "docs revert cleanly" + +evidence_plan: + - type: unit_test + surface: system_interface + status: collected + tier: critical + artifact_location: "reference-implementations/hooks-claude-code/selftests/fixtures/consumer-registry-check/ + selftest.sh output" + summary: >- + Fixture-first red gate against 4007bf6 ended # 177 cases, 3 failed + (default-off-skips-probe, pass-reachable, warn-unreachable). After the + hook edit, reference-implementations/hooks-claude-code/selftests/selftest.sh + ended # 177 cases, 0 failed. Positive controls covered default-off + curl-exit=99 -> exit 0 with no curl invocation; opt-in reachable + curl-exit=0 -> exit 0 plus stderr disclosure; opt-in unreachable + curl-exit=7 -> exit 2 warn. + +implementation_notes: + - type: evidence_added + description: >- + Added consumer-registry-check fixtures before editing the hook and + confirmed the baseline hook failed the new expectations: default-off + still invoked curl, and opt-in disclosure expectations were absent. + scope_delta: none + recorded_at: "2026-05-21T00:00:00Z" + - type: plan_delta + description: >- + The warn-unreachable fixture also asserts the opt-in disclosure so the + fixture-first gate proves the disclosure is absent on 4007bf6 and present + after the implementation. + scope_delta: none + rationale: >- + The hook requirement applies the one-line stderr disclosure to the whole + opt-in path, including reachable and unreachable probes. + recorded_at: "2026-05-21T00:00:00Z" + - type: discovery + description: >- + The selftest harness sources fixture env files before invoking hook + scripts as subprocesses; the new AGENT_PROTOCOL_CONSUMER_PROBE fixture + files therefore export the variable so it reaches the hook environment. + scope_delta: none + recorded_at: "2026-05-21T00:00:00Z" + - type: evidence_added + description: >- + Post-implementation Claude Code hook selftest passed with # 177 cases, + 0 failed, covering the new default-off case plus the existing reachable + and unreachable branches on the opt-in path. + scope_delta: none + recorded_at: "2026-05-21T00:00:00Z" + +review_notes: + - topic: "Scope / state — only the allowed surface set touched" + finding: pass + detail: >- + git status re-run at HEAD 4007bf6: exactly 8 modified tracked files + (consumer-registry-check.sh; pass-reachable/expected; + warn-unreachable/{curl-exit,expected}; README.md; + runtime-hooks-in-practice.md; DEVIATIONS.md; selftests/README.md) + 4 + untracked (this manifest; default-off-skips-probe/; pass-reachable/env; + warn-unreachable/env). git diff --name-only contains no other path. + Confirmed UNCHANGED (absent from diff): hooks/hooks.json, every + settings.example.*, docs/runtime-hook-contract.md, and + risky-bash/file/mcp-block.sh. + recorded_at: "2026-05-21T00:00:00Z" + - topic: "Selftest suites green at HEAD" + finding: pass + detail: >- + Independently re-run. Claude bundle selftest.sh ended `# 177 cases, 0 + failed` (yq is mikefarah v4.53.2 on PATH, so consumer-registry-check + cases execute rather than SKIP). Each adapter bundle + (codex/cursor/gemini-cli/windsurf) ended `# 4 cases, 0 failed`. Baseline + was 176/0; the +1 is the new default-off-skips-probe case. + recorded_at: "2026-05-21T00:00:00Z" + - topic: "Manifest schema gate" + finding: pass + detail: >- + validate-change-manifests.py --manifest reported + `OK RFC-023-wave3.change-manifest.yaml in-flight; schema clean`. + recorded_at: "2026-05-21T00:00:00Z" + - topic: "Hook source structure — opt-in gate ordering and disclosure placement" + finding: pass + detail: >- + Read-only audit of consumer-registry-check.sh. The opt-in gate + (AGENT_PROTOCOL_CONSUMER_PROBE != 1 -> exit 0) at lines 28-30 sits BEFORE + the yq/curl TOOL_ERROR checks (lines 32-40), so default-off exits 0 + invoking neither. On opt-in, the one-line disclosure prints to real + stderr (>&2) at lines 51-52, OUTSIDE the warns=$(...) capture (line 64) — + verified at runtime that reachable opt-in still exits 0 (disclosure does + not flip exit 2). All exits are 0 or 2; never exit 1 (warn-only + preserved). NET_TIMEOUT honored: timeout_s defaults to 5 (line 42) and + feeds curl --max-time (line 76). + recorded_at: "2026-05-21T00:00:00Z" + - topic: "Positive controls — direct case execution" + finding: pass + detail: >- + Ran each case directly mirroring run-case.sh env/curl-stub setup. + default-off-skips-probe -> ACTUAL_EXIT=0, empty stderr (curl-exit=99 + never consulted, proving curl NOT invoked). pass-reachable (curl-exit=0) + -> ACTUAL_EXIT=0 AND stderr discloses AGENT_PROTOCOL_CONSUMER_PROBE=1 + + https://registry.example.test/... . warn-unreachable (curl-exit=7) -> + ACTUAL_EXIT=2 with disclosure + the drift.consumer-registry-stale + unreachable warning naming registry.example.invalid. All three match + expected. + recorded_at: "2026-05-21T00:00:00Z" + - topic: "Fixture-first red gate re-derived against baseline 4007bf6" + finding: pass + detail: >- + Extracted the pristine hook from 4007bf6 (confirmed: contains no + AGENT_PROTOCOL_CONSUMER_PROBE token — gate absent) and ran the three + new/updated fixtures against it. default-off-skips-probe: baseline probes + and exits 2 vs expected 0 -> FAIL. pass-reachable: baseline exits 0 but + emits NO disclosure -> stderr-missing (AGENT_PROTOCOL_CONSUMER_PROBE, + registry.example.test) -> FAIL. warn-unreachable: baseline exits 2 but NO + disclosure -> stderr-missing -> FAIL. All three encode behavior absent on + baseline. Worktree was never stashed/modified (used a /tmp copy of the + old hook); git status unchanged afterward. + recorded_at: "2026-05-21T00:00:00Z" + - topic: "Docs reconcile to the opt-in/default-off default" + finding: pass + detail: >- + README (4 sites: dependencies blurb, two-layer 'wired' table — names + Layer A hooks.json + Layer B settings.example.*, Dependencies, No-side- + effects row), runtime-hooks-in-practice.md, and DEVIATIONS.md §4 all now + state the probe is opt-in / default-off with the one-line stderr + disclosure. Both README and practice-doc env tables add an + AGENT_PROTOCOL_CONSUMER_PROBE row and re-scope the NET_TIMEOUT row to the + enabled path. selftests/README floor table updated 3->4 cases for + consumer-registry-check and the total Fourteen->Fifteen. No bare 'wired' + claim without naming the layer. + recorded_at: "2026-05-21T00:00:00Z" + - topic: "README testing-section count (line 201, '151 cases') left as logged follow-up" + finding: pass_with_followup + detail: >- + README line 201 still reads `The expected full gate is 151 cases, 0 + failed`. This count is stale relative to the actual 177-case suite, but + it predates Wave 3 (the consensus plan itself cites the 151 baseline) and + is explicitly out of Wave 3 scope. Correct scope discipline to leave it; + the manifest makes no claim that the README testing section is fully + reconciled. Tracked as a follow-up count-reconciliation item, not done in + this wave. + recorded_at: "2026-05-21T00:00:00Z" + - topic: "Manifest breaking-change / surface / rollback fields" + finding: pass + detail: >- + surfaces_touched[1].role == consumer (surface: information; not + secondary). affected_consumers is an object array with category + third_party_external. migration_path == custom with migration_plan + carrying the restore path (AGENT_PROTOCOL_CONSUMER_PROBE=1). + breaking_change.level == L1. rollback.overall_mode == 1. All conform to + schema and to the plan. + recorded_at: "2026-05-21T00:00:00Z" + +approvals: + - approver: "claude-opus-4-7 (Reviewer, independent audit session)" + role: ai + scope: >- + RFC-023 Wave 3 full change — consumer-registry-check network probe + opt-in: hook source, 3 new/updated fixtures + 1 new fixture dir, and the + README / practice-doc / DEVIATIONS / selftests-README reconcile. Sign-off + is advisory; the owner gates commit/push/release in this initiative. + timestamp: "2026-05-21T00:00:00Z" + conditions: >- + README line 201 ('151 cases') count-reconciliation remains an open + follow-up (see review_notes); it is intentionally out of Wave 3 scope and + does not block this sign-off. diff --git a/docs/runtime-hooks-in-practice.md b/docs/runtime-hooks-in-practice.md index 52675a1..019a05c 100644 --- a/docs/runtime-hooks-in-practice.md +++ b/docs/runtime-hooks-in-practice.md @@ -19,16 +19,36 @@ Current hook families: | Family | Examples | Blocks (exit 1) / Warns (exit 2) | |---|---|---| | Phase-gate and evidence hooks | `manifest-required.sh`, `manifest-size-lint.sh`, `context-pack-required.sh`, `anti-full-justification.sh`, `manifest-fragment-consistency.sh`, `evidence-artifact-exists.sh` | Block | -| Drift and context-pressure hooks | `sot-drift-check.sh`, `drift-doc-refresh.sh`, `context-budget-warn.sh`, `resume-mode-attestation.sh`, `consumer-registry-check.sh`, `manifest-size-warn.sh` | Warn | +| Drift and context-pressure hooks | `sot-drift-check.sh`, `drift-doc-refresh.sh`, `context-budget-warn.sh`, `resume-mode-attestation.sh`, `consumer-registry-check.sh`, `manifest-size-warn.sh`, `manifest-hierarchical-pressure-warn.sh`, `router-justification-warn.sh`, `file-emission-budget-warn.sh` | Warn | | Completion-audit hooks | `completion-audit.sh`, `handoff-prompt-validator.sh` | Block or warn, depending on the rule | | Risky-action interception hooks | `risky-bash-block.sh`, `risky-file-block.sh`, `risky-mcp-block.sh` | Block; operator bypass is explicit through the documented `AGENT_PROTOCOL_RISKY_{BASH,FILE,MCP}_ALLOW` variables | +## Wiring layers + +"Wired" is layer-specific: + +- **Layer A — plugin entry point:** [`hooks/hooks.json`](../hooks/hooks.json), consumed by Claude Code plugin auto-discovery. It wires 10 hook invocations. +- **Layer B — adopter settings examples:** the five `settings.example.*` files under `reference-implementations/hooks-{claude-code,codex,cursor,gemini-cli,windsurf}/`. These examples wire a broader set than Layer A. + +| Behavior | Layer A wiring | Layer B wiring | +|---|---|---| +| Commit blockers | `manifest-required.sh` and `evidence-artifact-exists.sh` on `PreToolUse` / `Bash(git commit*)` | Layer A blockers plus `manifest-size-lint.sh`, `context-pack-required.sh`, `anti-full-justification.sh`, and `manifest-fragment-consistency.sh`; `manifest-size-warn.sh` and `manifest-hierarchical-pressure-warn.sh` warn on this trigger | +| Push-time warnings | `consumer-registry-check.sh` and `cckn-canonical-sync-check.sh` on `PreToolUse` / `Bash(git push*)` | Same two warnings | +| Edit-time warnings | `sot-drift-check.sh` and `drift-doc-refresh.sh` on `PostToolUse` / `Edit\|Write\|MultiEdit` | Same two plus `file-emission-budget-warn.sh` | +| Read-time warnings | Not Layer A wired | `context-budget-warn.sh`, `resume-mode-attestation.sh`, and `router-justification-warn.sh` on `PreToolUse` / `Read\|Grep` | +| Stop blockers | `completion-audit.sh` only | `completion-audit.sh` plus `handoff-prompt-validator.sh`; the handoff hook warns at soft cap and blocks at hard cap | +| Risky-action blockers | `risky-bash-block.sh`, `risky-file-block.sh`, and `risky-mcp-block.sh` | Same three blockers | + +`consumer-registry-check.sh` is the only hook in either layer that can use the network. Its default path performs no outbound network call. When `AGENT_PROTOCOL_CONSUMER_PROBE=1`, it probes HTTP(S) `external_registry_url` values with `curl` only after a manifest is discovered or supplied, prints one stderr disclosure listing the target URL(s), and remains warn-only (`exit 2`). + ## Install on Claude Code Prerequisites: - [`yq`](https://github.com/mikefarah/yq) v4+ on PATH (`brew install yq` / `apt install yq`). If missing, hooks exit 2 with `TOOL_ERROR: yq not found on PATH` — they degrade gracefully, they do not spuriously block commits. - `git` on PATH (already assumed). +- `curl` only if `AGENT_PROTOCOL_CONSUMER_PROBE=1` and `consumer-registry-check.sh` will probe HTTP(S) consumer registries. +- `python3` only for the Layer B `manifest-size-warn.sh` advisory path. **Step 1.** Copy the hooks bundle to a stable location (don't invoke directly from the plugin cache — version bumps move that path): @@ -100,17 +120,22 @@ Other contract triggers (`on-phase-transition`) are not currently mapped by the | Variable | Default | Effect | |---|---|---| -| `AGENT_PROTOCOL_MANIFEST_PATH` | `git ls-files change-manifest*.yaml \| head -1` | Point hooks at a specific manifest path; useful when multiple manifests coexist in a monorepo. | +| `AGENT_PROTOCOL_MANIFEST_PATH` | `git ls-files change-manifest*.yaml \| head -1` | Point hooks at a specific manifest path; useful when multiple manifests coexist in a monorepo. The fallback pathspec is root-anchored; nested manifests are not discovered. | | `AGENT_PROTOCOL_MIN_EVIDENCE_PER_PRIMARY` | `1` | Minimum evidence items required per `role: primary` surface. Raise for stricter gating. | -| `AGENT_PROTOCOL_LEAN_SKIP_MANIFEST` | unset | Set to `1` and create an empty `lean-mode.flag` file at the repo root to let `manifest-required.sh` pass for Lean-mode trivial changes. | +| `AGENT_PROTOCOL_LEAN_SKIP_MANIFEST` | unset | Set to `1` and create an empty `lean-mode.flag` file at the repo root to let `manifest-required.sh` pass for Lean-mode trivial non-doc changes. Docs-only commits already pass. | | `AGENT_PROTOCOL_STRUCTURED_OUTPUT` | unset | Set to `1` to emit structured JSON on stdout (per the contract's optional output shape) in addition to stderr messages. Useful for aggregation dashboards. | -| `AGENT_PROTOCOL_NET_TIMEOUT` | `5` | Seconds for the `consumer-registry-check.sh` network probe before the hook degrades to advisory. | +| `AGENT_PROTOCOL_CONSUMER_PROBE` | unset / `0` | Set to `1` to enable `consumer-registry-check.sh`'s HTTP(S) registry probe. The default path performs no outbound network call; enabled probes print one stderr disclosure listing target URL(s). | +| `AGENT_PROTOCOL_NET_TIMEOUT` | `5` | Seconds for the enabled `consumer-registry-check.sh` network probe before the hook degrades to advisory. | + +Fourteen manifest-consuming hooks share the fallback `git ls-files 'change-manifest*.yaml' | head -1`. In this repository that fallback currently returns no path, so a proposal-scoped manifest under `docs/proposals/` is invisible unless `AGENT_PROTOCOL_MANIFEST_PATH` names it directly. + +`completion-audit.sh` and `evidence-artifact-exists.sh` intentionally do not expose `*_ALLOW` bypass variables. They enforce truthfulness gates; recovery is to correct the manifest/evidence state or bind the intended manifest path. Set these in `~/.zshrc`, `~/.bashrc`, or per-project `.envrc` (direnv) — **not** inside the hook scripts themselves. ## Adoption ramp -Don't enable all four hooks on day one. Common staging: +Don't enable every hook on day one. Common staging: 1. **Week 1 — Observe.** Install `sot-drift-check.sh` only (warn-level; can't block). See what fires, tune the manifest if the signal is noisy. 2. **Week 2 — Gate.** Add `manifest-required.sh` at block-level. Creates a forcing function: non-trivial commits now require a manifest. @@ -121,12 +146,12 @@ Skipping straight to stage 4 usually produces "hook fatigue" — teams disable t ## Writing your own hook -The four shipped hooks are ~50–80 lines of POSIX sh each; read them as templates. A custom hook must: +The shipped hooks are small POSIX-sh entrypoints; read them as templates. A custom hook must: 1. Parse the event payload from stdin (or Claude Code env vars; the reference hooks do a best-effort read of both). 2. Read the Change Manifest via `yq` (or any YAML library in your language of choice — hooks aren't required to be shell). 3. Exit `0` / `1` / `2` per the contract. Stderr on non-zero with a one-sentence human message prefixed by `[agent-protocol/]`. -4. Stay offline, deterministic, side-effect-free, and under the latency budget (< 500 ms for A/B; < 2 s for C). **No model-in-hook** — hooks are mechanical decision nodes, not agents. +4. Stay deterministic, side-effect-free, and under the latency budget (< 500 ms for A/B; < 2 s for C). Network checks are allowed only as Category C degradation checks that warn and never block; `consumer-registry-check.sh` is the reference example. **No model-in-hook** — hooks are mechanical decision nodes, not agents. Register the custom hook by adding another `{"type": "command", "command": "..."}` entry under the appropriate event's `hooks` array in `settings.json`. @@ -137,6 +162,6 @@ See [`runtime-hook-contract.md`](./runtime-hook-contract.md) for the full anti-p - [`runtime-hook-contract.md`](./runtime-hook-contract.md) — normative capability contract (event schema, latency budgets, category definitions). - [`runtime-hook-threat-model.md`](./runtime-hook-threat-model.md) — security-boundary companion. Read this before treating hook coverage as safety: names the eight hook failure modes (false-block / false-pass / fail-open / fail-closed / local shell authority / dependency drift / bypass-as-audit-signal / supply-chain exposure) and the adopter-responsibility split for controls hooks do not provide. - [`ci-cd-integration-hooks.md`](./ci-cd-integration-hooks.md) — sibling CI-layer discipline; same exit codes, different trigger surface. -- [`../reference-implementations/hooks-claude-code/`](../reference-implementations/hooks-claude-code/) — primary bundle (five hooks + settings + selftests). +- [`../reference-implementations/hooks-claude-code/`](../reference-implementations/hooks-claude-code/) — primary bundle (hook scripts + settings + selftests). - [`../reference-implementations/hooks-claude-code/scripts/install-check.sh`](../reference-implementations/hooks-claude-code/scripts/install-check.sh) — install-time preflight (POSIX shell + `git` + `mikefarah/yq` v4+ + executable bits + `hooks.json` registration + selftest harness invocability). Run before wiring `settings.example.json`. - [`../reference-implementations/hooks-cursor/`](../reference-implementations/hooks-cursor/), [`hooks-gemini-cli/`](../reference-implementations/hooks-gemini-cli/), [`hooks-windsurf/`](../reference-implementations/hooks-windsurf/), [`hooks-codex/`](../reference-implementations/hooks-codex/) — adapter bundles. diff --git a/reference-implementations/hooks-claude-code/DEVIATIONS.md b/reference-implementations/hooks-claude-code/DEVIATIONS.md index ad1bf7c..b5bb015 100644 --- a/reference-implementations/hooks-claude-code/DEVIATIONS.md +++ b/reference-implementations/hooks-claude-code/DEVIATIONS.md @@ -45,15 +45,27 @@ The contract declares structured stdout as **optional**. These hooks gate it beh As of v1.3.0 the bundle ships `hooks/consumer-registry-check.sh`, the reference implementation of the contract's network-degradation clause. -The hook walks every `.consumers[].external_registry_url` in the -manifest, probes each with `curl -fsS --max-time ${AGENT_PROTOCOL_NET_TIMEOUT:-5}`, -and emits `exit 2` (warn) — never `exit 1` — on timeout, DNS failure, -non-HTTP URL, or non-2xx response. Missing `curl` or `yq` degrades to -`exit 2` via the same `TOOL_ERROR` path used elsewhere in this bundle. +As of RFC-023 Wave 3, the outbound probe is opt-in: the default unset / +`0` value of `AGENT_PROTOCOL_CONSUMER_PROBE` performs no outbound +network call at all. When `AGENT_PROTOCOL_CONSUMER_PROBE=1`, the hook +walks every `.consumers[].external_registry_url` in the manifest, prints +one stderr disclosure listing target URL(s), probes each HTTP(S) URL with +`curl -fsS --max-time ${AGENT_PROTOCOL_NET_TIMEOUT:-5}`, and emits +`exit 2` (warn) — never `exit 1` — on timeout, DNS failure, or non-2xx +response. Non-HTTP URLs are skipped with a stderr note and do not count +as warnings. Missing `curl` or `yq` on the opt-in path degrades +to `exit 2` via the same `TOOL_ERROR` path used elsewhere in this +bundle. It is the only hook in the current Claude Code bundle or +cross-runtime settings examples that can invoke the network. + +Layer A (`hooks/hooks.json`) and Layer B (`settings.example.*`) both wire +this hook on the `Bash(git push*)` trigger. Layer B does not add any other +network-using hook. Selftest fixtures under `selftests/fixtures/consumer-registry-check/` -exercise the no-consumers, reachable, and unreachable branches by way -of a `curl` stub that honors a per-fixture `curl-exit` file. A real +exercise the default-off, no-consumers, opt-in reachable, and opt-in +unreachable branches by way of a `curl` stub that honors a per-fixture +`curl-exit` file. A real end-to-end smoke (against a reachable vs. deliberately-invalid host) remains outside the selftest because CI sandboxes typically block arbitrary egress. @@ -99,7 +111,7 @@ Limitations: the hook compares `git log -1 --format=%cI` mtimes at file level, n Two distinct binaries ship under the `yq` name. The hooks here use mikefarah-only syntax: - `cckn-canonical-sync-check.sh` uses `yq --front-matter=extract '' ` for CCKN frontmatter parsing. The Python `kislyuk/yq` (a jq-wrapper) does not implement `--front-matter=extract` and exits with `Unknown option`. -- `consumer-registry-check.sh` historically used `yq -r '.. | select(has("external_registry_url")) | .external_registry_url' `, which crashes under `kislyuk/yq` because recursive descent (`..`) visits primitives and `has()` errors on non-objects. As of the post-1.36 merge the hook uses `.. | .external_registry_url? // empty`, which works under both variants — but the harness still skips the case under non-mikefarah yq as a precaution against future syntax additions. +- `consumer-registry-check.sh` uses `yq -r '.. | select(has("external_registry_url")) | .external_registry_url' `, which relies on mikefarah/yq behavior for recursive descent. The Python `kislyuk/yq` (a jq-wrapper) can error on primitive recursive-descent nodes, so the harness skips this fixture set under non-mikefarah yq rather than reporting spurious failures. A repo running with the wrong `yq` variant on `PATH` will see the cckn drift signal silently fail-open (frontmatter parse returns empty → "no mirrors declared" branch → exit 0). The user-visible hook output is "everything passed" — exactly the outcome the back-pressure pattern is designed to surface honestly. @@ -190,3 +202,32 @@ across the post-1.20 batches: All three hooks operate on the same level as `manifest-size-lint.sh`: schema-first enforcement with hook-side belt-and-braces for cross-file consistency that single-file schema validation cannot perform. + +--- + +## 9. Manifest discovery fallback is root-anchored + +Fourteen manifest-consuming scripts share this fallback when +`AGENT_PROTOCOL_MANIFEST_PATH` is unset: + +```sh +git ls-files 'change-manifest*.yaml' | head -1 +``` + +That pathspec is root-anchored. In this repository it currently returns no +path, so nested manifests such as +`docs/proposals/RFC-023-wave1.change-manifest.yaml` are not discovered by +fallback. For most manifest-consuming hooks, no discovered manifest means +the hook no-ops. `manifest-required.sh` is different: for non-doc staged +changes, an absent manifest is itself the violation and the hook blocks. + +The reliable binding is explicit: + +```sh +AGENT_PROTOCOL_MANIFEST_PATH=docs/proposals/RFC-023-wave1.change-manifest.yaml +``` + +The existing Lean escape for `manifest-required.sh` remains intentionally +two-part: `AGENT_PROTOCOL_LEAN_SKIP_MANIFEST=1` plus a repo-root +`lean-mode.flag`. This bypass applies only to that manifest-presence gate; +completion and evidence gates are not bypassable by design. diff --git a/reference-implementations/hooks-claude-code/README.md b/reference-implementations/hooks-claude-code/README.md index 68a9452..f172648 100644 --- a/reference-implementations/hooks-claude-code/README.md +++ b/reference-implementations/hooks-claude-code/README.md @@ -2,7 +2,7 @@ > **Not normative.** This directory holds **example** Claude Code runtime hooks that demonstrate `docs/runtime-hook-contract.md`. The methodology layer never ships tool-specific hooks; this directory exists so teams adopting the methodology on Claude Code have a concrete starting point they can copy, customize, and narrow. -These hooks are deliberately **shell scripts with zero runtime dependencies** (POSIX sh + `yq` for YAML reads, nothing else). They are runnable on macOS / Linux / any CI container with `yq` installed. A Node / Python rewrite would fit equally well; the point is the contract, not the language. +These hooks are deliberately small shell entrypoints. Most checks use POSIX `sh`, `git`, and `yq`; the Layer B-only pressure warning invokes `python3`, and the registry drift check invokes `curl` only when `AGENT_PROTOCOL_CONSUMER_PROBE=1` and a manifest declares an HTTP(S) consumer registry URL. A Node / Python rewrite would fit equally well; the point is the contract, not the language. --- @@ -28,11 +28,44 @@ These hooks are deliberately **shell scripts with zero runtime dependencies** (P | [`hooks/risky-bash-block.sh`](./hooks/risky-bash-block.sh) | A: phase-gate | `pre-tool-use:Bash` | `PreToolUse` + `matcher: "Bash"` | block (exit 1); bypass via `AGENT_PROTOCOL_RISKY_BASH_ALLOW=1` | | [`hooks/risky-file-block.sh`](./hooks/risky-file-block.sh) | A: phase-gate | `pre-tool-use:Edit` / `Write` / `MultiEdit` | `PreToolUse` + `matcher: "Edit\|Write\|MultiEdit"` | block (exit 1); bypass via `AGENT_PROTOCOL_RISKY_FILE_ALLOW=1` | | [`hooks/risky-mcp-block.sh`](./hooks/risky-mcp-block.sh) | A: phase-gate | `pre-tool-use:mcp__*` | `PreToolUse` + `matcher: "mcp__.*"` | block (exit 1); bypass via `AGENT_PROTOCOL_RISKY_MCP_ALLOW=1` | +| [`hooks/manifest-hierarchical-pressure-warn.sh`](./hooks/manifest-hierarchical-pressure-warn.sh) | C: drift | `pre-commit` | `PreToolUse` + `matcher: "Bash(git commit*)"` | warn (exit 2) — Layer B advisory when `evidence_plan` length crosses the hierarchical-manifest pressure threshold | **Why `PreToolUse` and not `PostToolUse` for commit gating:** Claude Code's `PreToolUse` runs *before* the Bash tool invokes `git commit`; exit code 1 cancels the tool call. `PostToolUse` would fire *after* the commit was already written, giving the hook no blocking power. Use `PreToolUse` for any hook whose purpose is to prevent an action. **Composite vs granular pre-commit wiring.** Each of the six pre-commit A/B hooks (manifest-required, manifest-size-lint, context-pack-required, anti-full-justification, manifest-fragment-consistency, evidence-artifact-exists) has its own `ruleIds` line in `settings.example.json`. The granular form is the **canonical** wiring — runtime dashboards / log readers see one stderr line per rule when something fails, which makes the failure trivially attributable to a single rule. For adapters where wiring is repetitive boilerplate, `manifest-batch-precommit.sh` runs all six in one pass and is wired with one entry; `AGENT_PROTOCOL_BATCH_PRECOMMIT_SKIP=` toggles inner rules. Use the composite when you want fewer wiring entries; use the granular form when per-rule observability matters more. Do **not** wire both — the inner hooks would run twice. +### Wiring layers + +In this repository, "wired" has two distinct meanings. Name the layer when reporting hook coverage: + +- **Layer A — plugin entry point:** [`hooks/hooks.json`](../../hooks/hooks.json), consumed by Claude Code plugin auto-discovery. Installing the plugin wires 10 hook invocations. +- **Layer B — adopter settings examples:** the five `settings.example.*` files under `reference-implementations/hooks-{claude-code,codex,cursor,gemini-cli,windsurf}/`. Copying one of these examples wires the broader reference set for that runtime. + +| Hook group | Layer A (`hooks/hooks.json`) | Layer B (`settings.example.*`) | +|---|---|---| +| Commit blockers | `manifest-required.sh`, `evidence-artifact-exists.sh` on `PreToolUse` / `Bash(git commit*)` | Layer A commit blockers plus `manifest-size-lint.sh`, `context-pack-required.sh`, `anti-full-justification.sh`, and `manifest-fragment-consistency.sh`; `manifest-size-warn.sh` and `manifest-hierarchical-pressure-warn.sh` warn on the same trigger | +| Push-time drift warnings | `consumer-registry-check.sh`, `cckn-canonical-sync-check.sh` on `PreToolUse` / `Bash(git push*)` | Same two push-time warnings | +| Edit-time drift warnings | `sot-drift-check.sh`, `drift-doc-refresh.sh` on `PostToolUse` / `Edit\|Write\|MultiEdit` | Same two plus `file-emission-budget-warn.sh` | +| Read-time warnings | Not Layer A wired | `context-budget-warn.sh`, `resume-mode-attestation.sh`, `router-justification-warn.sh` on `PreToolUse` / `Read\|Grep` | +| Risky-action blockers | `risky-bash-block.sh`, `risky-file-block.sh`, `risky-mcp-block.sh` | Same three blockers | +| Stop blockers | `completion-audit.sh` only | `completion-audit.sh` plus `handoff-prompt-validator.sh` (`handoff-prompt-validator.sh` warns at the soft cap and blocks at the hard cap) | + +The only hook in either layer that can invoke the network is `consumer-registry-check.sh`. By default it performs no outbound network call. When `AGENT_PROTOCOL_CONSUMER_PROBE=1`, it probes HTTP(S) `external_registry_url` values with `curl` after a manifest is discovered or supplied, prints one stderr disclosure listing the target URL(s), and remains warn-only (`exit 2`), never a block. + +### Manifest discovery and Lean skip + +Fourteen manifest-consuming scripts use the same fallback when `AGENT_PROTOCOL_MANIFEST_PATH` is unset: + +```sh +git ls-files 'change-manifest*.yaml' | head -1 +``` + +That pathspec is root-anchored. In this repository it currently returns no path, so nested proposal manifests such as `docs/proposals/RFC-023-wave1.change-manifest.yaml` are not discovered by fallback. Set `AGENT_PROTOCOL_MANIFEST_PATH=` when a hook must bind to a specific manifest, especially in repos with nested manifests or multiple manifests. + +`manifest-required.sh` is the exception to "no manifest means no-op": for non-doc staged changes, an absent manifest is the violation and the hook blocks. The existing Lean escape is deliberately explicit: set `AGENT_PROTOCOL_LEAN_SKIP_MANIFEST=1` **and** create a repo-root `lean-mode.flag`. Docs-only staged changes pass without that escape. + +`completion-audit.sh` and `evidence-artifact-exists.sh` intentionally have no `*_ALLOW` bypass. Recovery is to fix the manifest / evidence state or bind the intended manifest with `AGENT_PROTOCOL_MANIFEST_PATH`, not to skip the truthfulness gate. + All hooks follow the I/O contract from `docs/runtime-hook-contract.md`: JSON event on stdin, exit code `0 / 1 / 2`, human-readable stderr message on non-zero, optional structured stdout JSON. Each script begins with a contract-stamp header comment that references the category, trigger, and rule IDs it implements. ### Runtime helpers (non-hook) @@ -87,9 +120,11 @@ The script verifies that POSIX `sh` + `git` + `mikefarah/yq` v4+ are on PATH, ev - POSIX-compatible shell (`sh` / `bash` / `zsh`). - [`yq`](https://github.com/mikefarah/yq) (v4+) for reading the manifest YAML — **must be the Go binary from `mikefarah/yq`**, not the Python `kislyuk/yq` jq-wrapper of the same name. -- `git` (used only by `sot-drift-check.sh` to compute touched paths). +- `git` for staged-file checks, manifest discovery fallback, and drift comparisons. +- `curl` only for `consumer-registry-check.sh`, and only when `AGENT_PROTOCOL_CONSUMER_PROBE=1` and a discovered/supplied manifest declares an HTTP(S) `external_registry_url`. +- `python3` plus the manifest-compactor dependencies only for the Layer B-only `manifest-size-warn.sh` advisory path. -No Node, no Python, no network, no API keys. If `yq` is missing, hooks exit code `2` with `TOOL_ERROR: yq not found on PATH` — per the contract, tool errors are surfaced as warnings, not rule-violation blocks. +No Node and no API keys. Network use is limited to `consumer-registry-check.sh`'s opt-in, warn-only `curl` probe. With the default unset / `0` probe setting, the hook performs no outbound network call and does not require `curl` for the probe. On the opt-in path, if `yq` is missing, hooks exit code `2` with `TOOL_ERROR: yq not found on PATH` — per the contract, tool errors are surfaced as warnings, not rule-violation blocks. ### Why mikefarah/yq specifically @@ -113,8 +148,9 @@ Each hook reads these optional env vars to stay contract-compliant when the Clau |-----|---------|---------| | `AGENT_PROTOCOL_MANIFEST_PATH` | Path to the Change Manifest relative to repo root | auto-discover via `git ls-files change-manifest*.yaml \| head -1` | | `AGENT_PROTOCOL_MIN_EVIDENCE_PER_PRIMARY` | Integer — minimum evidence items per primary surface | `1` | -| `AGENT_PROTOCOL_LEAN_SKIP_MANIFEST` | If set to `1`, `manifest-required.sh` passes when `lean-mode.flag` exists at repo root | unset (manifest always required) | -| `AGENT_PROTOCOL_NET_TIMEOUT` | Seconds before `consumer-registry-check.sh` declares a registry unreachable (never escalates past `exit 2`) | `5` | +| `AGENT_PROTOCOL_LEAN_SKIP_MANIFEST` | If set to `1`, `manifest-required.sh` passes when `lean-mode.flag` exists at repo root; docs-only commits do not need this escape | unset (manifest always required for non-doc staged changes) | +| `AGENT_PROTOCOL_CONSUMER_PROBE` | Set to `1` to enable `consumer-registry-check.sh`'s HTTP(S) registry probe. The default unset / `0` path performs no outbound network call; enabled probes print one stderr disclosure listing target URL(s). | unset / `0` | +| `AGENT_PROTOCOL_NET_TIMEOUT` | Seconds before enabled `consumer-registry-check.sh` probes declare a registry unreachable (never escalates past `exit 2`) | `5` | | `AGENT_PROTOCOL_MANIFEST_LINE_CEILING` | Per-manifest hard line ceiling for `manifest-size-lint.sh` and `context-budget-warn.sh` | `2000` | | `AGENT_PROTOCOL_MANIFEST_TOKEN_CEILING` | Per-manifest estimated-token ceiling for `manifest-size-lint.sh` (bytes/4 + word_count proxy) | `25000` | | `AGENT_PROTOCOL_HANDOFF_SOFT_CAP_WORDS` | Soft cap (warn) for `handoff_narrative` word count, mirrors `templates/handoff-prompt-template.md §Budget` | `400` | @@ -149,14 +185,20 @@ Set these in your shell profile or per-project `.envrc` (direnv) — **not** ins | Exit codes 0 / 1 / 2 | Scripts exit with these codes; never `3+`. | | Stderr on non-zero | Always a one-sentence message beginning with `[agent-protocol/]`. | | Stdout JSON (optional) | Emitted only when `AGENT_PROTOCOL_STRUCTURED_OUTPUT=1`. | -| Latency budget | All four scripts complete in < 100 ms on a manifest with ~50 sot_map entries and ~30 evidence items. | -| No side effects | Scripts only read files. They never write, commit, or invoke network. | +| Latency budget | Hook families stay within the contract budgets: Category A/B checks are intended to stay sub-500 ms; Category C drift checks are warn-only and stay under the drift budget. | +| No side effects | Scripts do not write, commit, or mutate runtime state. `consumer-registry-check.sh` is the sole hook that can use the network; it performs its warn-only `curl` probe only when `AGENT_PROTOCOL_CONSUMER_PROBE=1`, and never treats network reachability as a blocking authority. | --- ## Testing -The `selftests/` directory (to be populated) will contain fixture events + fixture manifests. Running `./selftest.sh` (when present) should exercise each hook against pass / fail fixtures and confirm exit codes. +The `selftests/` directory contains hermetic fixture events, manifests, git / curl stubs, and expected exit-code assertions. Run: + +```sh +reference-implementations/hooks-claude-code/selftests/selftest.sh +``` + +The expected full gate is `151 cases, 0 failed` when `mikefarah/yq` is on `PATH`. --- diff --git a/reference-implementations/hooks-claude-code/hooks/consumer-registry-check.sh b/reference-implementations/hooks-claude-code/hooks/consumer-registry-check.sh index 6616829..51e44ff 100755 --- a/reference-implementations/hooks-claude-code/hooks/consumer-registry-check.sh +++ b/reference-implementations/hooks-claude-code/hooks/consumer-registry-check.sh @@ -7,11 +7,12 @@ # see: docs/runtime-hook-contract.md §category-c-drift-hook # docs/runtime-hook-contract.md §network-degradation-clause # -# For every consumer in the manifest that declares an `external_registry_url`, -# probe the URL with curl. Unreachable or non-2xx responses emit an advisory -# and the hook exits with 2 (warn) — never 1 (block). This is the reference -# implementation of the contract's "checks that genuinely require network must -# degrade to advisory" clause. +# When AGENT_PROTOCOL_CONSUMER_PROBE=1, probe every consumer in the manifest +# that declares an `external_registry_url` with curl. Unreachable or non-2xx +# responses emit an advisory and the hook exits with 2 (warn) — never 1 +# (block). By default, the network probe is off and the hook exits silently. +# This is the reference implementation of the contract's "checks that genuinely +# require network must degrade to advisory" clause. set -eu @@ -24,6 +25,10 @@ if [ -z "$MANIFEST_PATH" ] || [ ! -f "$MANIFEST_PATH" ]; then exit 0 fi +if [ "${AGENT_PROTOCOL_CONSUMER_PROBE:-0}" != "1" ]; then + exit 0 +fi + if ! command -v yq >/dev/null 2>&1; then echo "[agent-protocol/drift.consumer-registry-stale] TOOL_ERROR: yq not found on PATH; skipping consumer-registry probe" >&2 exit 2 @@ -36,6 +41,16 @@ fi timeout_s="${AGENT_PROTOCOL_NET_TIMEOUT:-5}" +urls=$(yq -r '.. | select(has("external_registry_url")) | .external_registry_url' "$MANIFEST_PATH" 2>/dev/null \ + | grep -v '^$' || true) + +if [ -z "$urls" ]; then + exit 0 +fi + +disclosure_urls=$(printf '%s\n' "$urls" | paste -sd ' ' -) +printf '[agent-protocol/drift.consumer-registry-stale] AGENT_PROTOCOL_CONSUMER_PROBE=1; probing consumer registry URL(s): %s\n' "$disclosure_urls" >&2 + # Iterate URLs via newline-delimited read instead of unquoted word splitting, # so URLs containing whitespace or glob metacharacters are handled as data. # Warnings emitted on stdout inside the pipeline subshell are captured into @@ -46,8 +61,7 @@ timeout_s="${AGENT_PROTOCOL_NET_TIMEOUT:-5}" # the variant required by reference-implementations/hooks-claude-code/README.md # §Dependencies). The selftest harness skips this directory entirely when # yq is missing or the wrong variant — so jq-only environments do not regress. -warns=$(yq -r '.. | select(has("external_registry_url")) | .external_registry_url' "$MANIFEST_PATH" 2>/dev/null \ - | grep -v '^$' \ +warns=$(printf '%s\n' "$urls" \ | while IFS= read -r url; do # Skip non-HTTP URLs with an informational note. `case` inside `$(...|while...)` # trips a bash 3.2 parser bug on `;;`, so we use a prefix test instead. diff --git a/reference-implementations/hooks-claude-code/hooks/risky-bash-block.sh b/reference-implementations/hooks-claude-code/hooks/risky-bash-block.sh index c627643..603a9d8 100755 --- a/reference-implementations/hooks-claude-code/hooks/risky-bash-block.sh +++ b/reference-implementations/hooks-claude-code/hooks/risky-bash-block.sh @@ -141,15 +141,67 @@ PROTECTED_DEFAULT="main master production release" PROTECTED_LIST="${AGENT_PROTOCOL_PROTECTED_BRANCHES:-$PROTECTED_DEFAULT}" PROTECTED_LIST=$(printf '%s' "$PROTECTED_LIST" | tr ',' ' ') -if printf '%s' "$command_str" | grep -Eq '\bgit[[:space:]]+push\b.*(--force\b|-f\b|--force-with-lease\b)'; then - for br in $PROTECTED_LIST; do - if printf '%s' "$command_str" | grep -Eq "(\\b$br\\b|origin[[:space:]]+$br\\b|HEAD:$br\\b|:refs/heads/$br\\b)"; then - reason="bash command issues a force push to protected branch \"$br\"" - location="bash:tool_input.command" - hint="see docs/runtime-hook-contract.md §Risky-action interception list (Force-push or rewrite history on a protected branch); use a non-protected branch + PR review or set AGENT_PROTOCOL_RISKY_BASH_ALLOW=1 for an operator-authorized run" - break +refspec_targets_protected_branch() { + refspec=$1 + branch=$2 + + refspec=${refspec#+} + case "$refspec" in + *:*) destination=${refspec##*:} ;; + *) destination=$refspec ;; + esac + destination=${destination#refs/heads/} + + [ "$destination" = "$branch" ] +} + +force_push_protected_branch="" +force_push_targets_protected_branch() { + seen_git=0 + seen_push=0 + positional_index=0 + + for token in $command_str; do + if [ "$seen_git" = 0 ]; then + [ "$token" = "git" ] && seen_git=1 + continue fi + + if [ "$seen_push" = 0 ]; then + [ "$token" = "push" ] && seen_push=1 + continue + fi + + case "$token" in + -*) + continue + ;; + esac + + positional_index=$((positional_index + 1)) + # The first positional token is the repository / remote. A protected + # branch name is only risky when it is a pushed refspec destination. + if [ "$positional_index" -eq 1 ]; then + continue + fi + + for branch in $PROTECTED_LIST; do + if refspec_targets_protected_branch "$token" "$branch"; then + force_push_protected_branch=$branch + return 0 + fi + done done + + return 1 +} + +if printf '%s' "$command_str" | grep -Eq '\bgit[[:space:]]+push\b.*(--force\b|-f\b|--force-with-lease\b)'; then + if force_push_targets_protected_branch; then + reason="bash command issues a force push to protected branch \"$force_push_protected_branch\"" + location="bash:tool_input.command" + hint="see docs/runtime-hook-contract.md §Risky-action interception list (Force-push or rewrite history on a protected branch); use a non-protected branch + PR review or set AGENT_PROTOCOL_RISKY_BASH_ALLOW=1 for an operator-authorized run" + fi fi # Group 2: bulk-delete @@ -200,11 +252,121 @@ fi # carrying a destructive verb (`DROP`, `DELETE`, `TRUNCATE`, `FLUSHALL`, # `FLUSHDB`, `drop`, etc.) in either the `-c "..."` arg or as a piped # heredoc. Matches case-insensitively against the verb (SQL keywords). +sql_mask_literals() { + printf '%s\n' "$1" | awk ' +BEGIN { + sq = sprintf("%c", 39) + dq = sprintf("%c", 34) + in_sq = 0 + in_dq = 0 +} +{ + for (i = 1; i <= length($0); i++) { + c = substr($0, i, 1) + n = substr($0, i + 1, 1) + + if (in_sq) { + if (c == sq) { + if (n == sq) { + printf " " + i++ + } else { + in_sq = 0 + printf " " + } + } else { + printf " " + } + continue + } + + if (in_dq) { + if (c == dq) { + if (n == dq) { + printf " " + i++ + } else { + in_dq = 0 + printf " " + } + } else { + printf " " + } + continue + } + + if (c == sq) { + in_sq = 1 + printf " " + } else if (c == dq) { + in_dq = 1 + printf " " + } else { + printf "%s", c + } + } + printf "\n" +}' +} + +dash_c_sql_payload() { + printf '%s\n' "$command_str" | awk ' +BEGIN { + sq = sprintf("%c", 39) + dq = sprintf("%c", 34) +} +{ + text = $0 + for (i = 1; i <= length(text); i++) { + if (substr(text, i, 2) != "-c") { + continue + } + + if (i == 1) { + prev = " " + } else { + prev = substr(text, i - 1, 1) + } + nextc = substr(text, i + 2, 1) + if (prev ~ /[A-Za-z0-9_-]/ || nextc ~ /[A-Za-z0-9_-]/) { + continue + } + + j = i + 2 + while (j <= length(text) && substr(text, j, 1) ~ /[[:space:]]/) { + j++ + } + quote = substr(text, j, 1) + if (quote != sq && quote != dq) { + continue + } + + start = j + 1 + for (k = start; k <= length(text); k++) { + c = substr(text, k, 1) + if (c == quote) { + print substr(text, start, k - start) + exit 0 + } + } + print substr(text, start) + exit 0 + } +}' +} + if [ -z "$reason" ]; then - if printf '%s' "$command_str" | grep -Eqi '\b(psql|mysql|mongosh?|redis-cli)\b.*([-]c[[:space:]]+["'"'"']|<<|<[[:space:]]).*\b(DROP|DELETE|TRUNCATE|FLUSHALL|FLUSHDB|REMOVE)\b'; then - reason="bash command issues a direct destructive mutation against a database client" - location="bash:tool_input.command" - hint="see docs/runtime-hook-contract.md §Risky-action interception list (Production data-store write); production writes must come from a reviewed deploy pipeline, not an agent shell — set AGENT_PROTOCOL_RISKY_BASH_ALLOW=1 only for sandbox / staging operations" + if printf '%s' "$command_str" | grep -Eqi '\b(psql|mysql|mongosh?|redis-cli)\b.*([-]c[[:space:]]+["'"'"']|<<|<[[:space:]])'; then + sql_to_check=$(dash_c_sql_payload) + if [ -z "$sql_to_check" ]; then + sql_to_check=$command_str + fi + sql_to_check=$(sql_mask_literals "$sql_to_check") + if printf '%s' "$sql_to_check" | grep -Eqi '\b(DROP|DELETE|TRUNCATE|FLUSHALL|FLUSHDB|REMOVE)\b'; then + reason="bash command issues a direct destructive mutation against a database client" + location="bash:tool_input.command" + hint="see docs/runtime-hook-contract.md §Risky-action interception list (Production data-store write); production writes must come from a reviewed deploy pipeline, not an agent shell — set AGENT_PROTOCOL_RISKY_BASH_ALLOW=1 only for sandbox / staging operations" + fi fi fi @@ -251,12 +413,43 @@ fi # discipline.md §3` ("the contract a destructive call must satisfy" — # explicit, per-invocation, scoped) by refusing the smuggled form that # launders intent through an arbitrary-string interpreter. +shell_init_eval_is_allowed() { + # shellcheck disable=SC2016 + payload=$(printf '%s' "$command_str" | sed -n 's/.*eval[[:space:]]*"\$(\([^)]*\))".*/\1/p' | head -1) + if [ -z "$payload" ]; then + return 1 + fi + + if printf '%s' "$payload" | grep -Eq '[;&|<>`$]'; then + return 1 + fi + + case "$payload" in + "direnv hook "*|\ + "ssh-agent -s"|\ + "starship init "*|\ + "rbenv init "*|\ + "pyenv init "*|\ + "nodenv init "*|\ + "goenv init "*|\ + "fnm env"|"fnm env "*|\ + "nvm env"|"nvm env "*|\ + "zoxide init "*|\ + "atuin init "*|\ + "mise activate "*) + return 0 + ;; + esac + + return 1 +} + if [ -z "$reason" ]; then if printf '%s' "$command_str" | grep -Eq '\b(curl|wget)\b[^|]*\|[[:space:]]*(sh|bash)\b'; then reason="bash command pipes remote-fetched content into a shell interpreter" location="bash:tool_input.command" hint="see docs/runtime-hook-contract.md §Risky-action interception list (defensive eval-or-pipe-sh group) and docs/destructive-action-discipline.md §3; download to a file, review it, then run — do not pipe network content into sh" - elif printf '%s' "$command_str" | grep -Eq '(^|[[:space:];|&])eval[[:space:]]+'; then + elif printf '%s' "$command_str" | grep -Eq '(^|[[:space:];|&])eval[[:space:]]+' && ! shell_init_eval_is_allowed; then reason="bash command invokes eval against a runtime-constructed string" location="bash:tool_input.command" hint="see docs/runtime-hook-contract.md §Risky-action interception list (defensive eval-or-pipe-sh group); replace eval with the explicit command form so intent is auditable" @@ -274,10 +467,50 @@ fi # `*.pem` outside `tests/`, `.env`, and any path containing `production` # + `credentials`. The file-edit aspect of the same row is covered by # `risky-file-block.sh`. +pem_read_outside_tests() { + after_read_verb=0 + + for token in $command_str; do + case "$token" in + "|"|";"|"&&"|"||") + after_read_verb=0 + continue + ;; + esac + + if [ "$after_read_verb" = 0 ]; then + case "$token" in + cat|head|tail|less|more|grep|awk|sed|xargs|env|printenv|export) + after_read_verb=1 + ;; + esac + continue + fi + + case "$token" in + -*) + continue + ;; + esac + + path_token=$(printf '%s' "$token" | sed "s/^[\"']//; s/[\"';,)]*$//") + case "$path_token" in + *.pem) + case "$path_token" in + tests/*|./tests/*|*/tests/*|test/*|./test/*|*/test/*) ;; + *) return 0 ;; + esac + ;; + esac + done + + return 1 +} + if [ -z "$reason" ]; then read_verb='(cat|head|tail|less|more|grep|awk|sed|xargs|env|printenv|export[[:space:]]+-p)' - cred_path='(~/\.aws/|/\.aws/|~/\.ssh/|/\.ssh/|~/\.kube/|/\.kube/|\.env\b|\bproduction[a-zA-Z0-9_/-]*credentials|\bcredentials[a-zA-Z0-9_/-]*production|\.pem\b|/etc/shadow|/etc/sudoers)' - if printf '%s' "$command_str" | grep -Eq "\\b$read_verb\\b[[:space:]]+[^|;&]*$cred_path"; then + cred_path='(~/\.aws/|/\.aws/|~/\.ssh/|/\.ssh/|~/\.kube/|/\.kube/|\.env\b|\bproduction[a-zA-Z0-9_/-]*credentials|\bcredentials[a-zA-Z0-9_/-]*production|/etc/shadow|/etc/sudoers)' + if printf '%s' "$command_str" | grep -Eq "\\b$read_verb\\b[[:space:]]+[^|;&]*$cred_path" || pem_read_outside_tests; then reason="bash command reads a credential / secret path" location="bash:tool_input.command" hint="see docs/runtime-hook-contract.md §Risky-action interception list (Production-credential touch, read aspect) and docs/destructive-action-discipline.md §2 (Token discovery anti-pattern); the agent must never search for alternative credentials in response to an authorization signal — escalate per AGENTS.md §Stop conditions instead" diff --git a/reference-implementations/hooks-claude-code/hooks/risky-mcp-block.sh b/reference-implementations/hooks-claude-code/hooks/risky-mcp-block.sh index 02fad7f..d586e18 100755 --- a/reference-implementations/hooks-claude-code/hooks/risky-mcp-block.sh +++ b/reference-implementations/hooks-claude-code/hooks/risky-mcp-block.sh @@ -124,22 +124,80 @@ fi # Many database-MCP servers expose a single `query` / `execute` tool that # accepts an arbitrary SQL string. The verb is in the `tool_input.sql` / # `.query` / `.statement` field, not the tool name. Inspect those fields. +sql_mask_literals() { + printf '%s\n' "$1" | awk ' +BEGIN { + sq = sprintf("%c", 39) + dq = sprintf("%c", 34) + in_sq = 0 + in_dq = 0 +} +{ + for (i = 1; i <= length($0); i++) { + c = substr($0, i, 1) + n = substr($0, i + 1, 1) + + if (in_sq) { + if (c == sq) { + if (n == sq) { + printf " " + i++ + } else { + in_sq = 0 + printf " " + } + } else { + printf " " + } + continue + } + + if (in_dq) { + if (c == dq) { + if (n == dq) { + printf " " + i++ + } else { + in_dq = 0 + printf " " + } + } else { + printf " " + } + continue + } + + if (c == sq) { + in_sq = 1 + printf " " + } else if (c == dq) { + in_dq = 1 + printf " " + } else { + printf "%s", c + } + } + printf "\n" +}' +} + if [ -z "$reason" ]; then for field_path in '.tool_input.sql' '.tool_input.query' '.tool_input.statement' '.tool_input.command'; do sql_arg=$(extract "$field_path") if [ -z "$sql_arg" ]; then continue fi + sql_arg_masked=$(sql_mask_literals "$sql_arg") - if printf '%s' "$sql_arg" | grep -Eqi '(^|[[:space:];])(DROP[[:space:]]+(TABLE|DATABASE|SCHEMA|INDEX|VIEW|FUNCTION|TYPE|ROLE|USER)|TRUNCATE([[:space:]]+TABLE)?|DELETE[[:space:]]+FROM|ALTER[[:space:]]+TABLE[[:space:]]+[a-zA-Z_."]+[[:space:]]+DROP)\b'; then + if printf '%s' "$sql_arg_masked" | grep -Eqi '(^|[[:space:];])(DROP[[:space:]]+(TABLE|DATABASE|SCHEMA|INDEX|VIEW|FUNCTION|TYPE|ROLE|USER)|TRUNCATE([[:space:]]+TABLE)?|DELETE[[:space:]]+FROM|ALTER[[:space:]]+TABLE[[:space:]]+[a-zA-Z_."]+[[:space:]]+DROP)\b'; then reason="MCP SQL argument carries a destructive DDL / DML verb" location="mcp:tool_input${field_path#.tool_input}" hint="see docs/runtime-hook-contract.md §Risky-action interception list (Production data-store write, MCP-SQL aspect) and docs/destructive-action-discipline.md §3; production destructive SQL must come from a reviewed migration pipeline, not an MCP shell — set AGENT_PROTOCOL_RISKY_MCP_ALLOW=1 only for sandbox / staging operations" break fi - if printf '%s' "$sql_arg" | grep -Eqi '(^|[[:space:];])UPDATE[[:space:]]+[a-zA-Z_."]+[[:space:]]+SET[[:space:]]+'; then - if ! printf '%s' "$sql_arg" | grep -Eqi '\bWHERE\b'; then + if printf '%s' "$sql_arg_masked" | grep -Eqi '(^|[[:space:];])UPDATE[[:space:]]+[a-zA-Z_."]+[[:space:]]+SET[[:space:]]+'; then + if ! printf '%s' "$sql_arg_masked" | grep -Eqi '\bWHERE\b'; then reason="MCP SQL argument issues UPDATE without WHERE clause (mass-row mutation)" location="mcp:tool_input${field_path#.tool_input}" hint="see docs/runtime-hook-contract.md §Risky-action interception list (Production data-store write, mass-row mutation); add a WHERE clause that scopes the update, or set AGENT_PROTOCOL_RISKY_MCP_ALLOW=1 for an authorized full-table rewrite" @@ -147,7 +205,7 @@ if [ -z "$reason" ]; then fi fi - if printf '%s' "$sql_arg" | grep -Eqi '(^|[[:space:];])(GRANT|REVOKE)[[:space:]]+'; then + if printf '%s' "$sql_arg_masked" | grep -Eqi '(^|[[:space:];])(GRANT|REVOKE)[[:space:]]+'; then reason="MCP SQL argument issues a GRANT / REVOKE (privilege change)" location="mcp:tool_input${field_path#.tool_input}" hint="see docs/runtime-hook-contract.md §Risky-action interception list (Production data-store write, privilege-change aspect); privilege changes require explicit authorization — record in escalations[*] before invoking" diff --git a/reference-implementations/hooks-claude-code/selftests/README.md b/reference-implementations/hooks-claude-code/selftests/README.md index 08de595..fbf84af 100644 --- a/reference-implementations/hooks-claude-code/selftests/README.md +++ b/reference-implementations/hooks-claude-code/selftests/README.md @@ -78,7 +78,7 @@ does not match. | `evidence-artifact-exists.sh` | 2 | | `sot-drift-check.sh` | 2 | | `completion-audit.sh` | 2 | -| `consumer-registry-check.sh` | 3 | +| `consumer-registry-check.sh` | 4 | -Fourteen cases total. Anything below that floor is a regression in the +Fifteen cases total. Anything below that floor is a regression in the bundle's observable contract. diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/consumer-registry-check/default-off-skips-probe/curl-exit b/reference-implementations/hooks-claude-code/selftests/fixtures/consumer-registry-check/default-off-skips-probe/curl-exit new file mode 100644 index 0000000..3ad5abd --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/consumer-registry-check/default-off-skips-probe/curl-exit @@ -0,0 +1 @@ +99 diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/consumer-registry-check/default-off-skips-probe/expected b/reference-implementations/hooks-claude-code/selftests/fixtures/consumer-registry-check/default-off-skips-probe/expected new file mode 100644 index 0000000..9200c2a --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/consumer-registry-check/default-off-skips-probe/expected @@ -0,0 +1 @@ +exit=0 diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/consumer-registry-check/default-off-skips-probe/manifest.yaml b/reference-implementations/hooks-claude-code/selftests/fixtures/consumer-registry-check/default-off-skips-probe/manifest.yaml new file mode 100644 index 0000000..51b2744 --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/consumer-registry-check/default-off-skips-probe/manifest.yaml @@ -0,0 +1,14 @@ +change_id: CHG-0011 +title: Default-off consumer registry probe +phase: execute +surfaces_touched: [system_interface] +sot_map: + - pattern_id: "4-enum-sot" + source: app/enums/invoice_status.py + consumers: + - id: partner-bridge + external_registry_url: https://registry.example.test/schemas/invoice-status +evidence_plan: [] +rollback: + mode: 2 + notes: redeploy previous revision diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/consumer-registry-check/pass-reachable/env b/reference-implementations/hooks-claude-code/selftests/fixtures/consumer-registry-check/pass-reachable/env new file mode 100644 index 0000000..32a67e7 --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/consumer-registry-check/pass-reachable/env @@ -0,0 +1 @@ +export AGENT_PROTOCOL_CONSUMER_PROBE=1 diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/consumer-registry-check/pass-reachable/expected b/reference-implementations/hooks-claude-code/selftests/fixtures/consumer-registry-check/pass-reachable/expected index 9200c2a..c8ab43b 100644 --- a/reference-implementations/hooks-claude-code/selftests/fixtures/consumer-registry-check/pass-reachable/expected +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/consumer-registry-check/pass-reachable/expected @@ -1 +1,3 @@ exit=0 +stderr~=AGENT_PROTOCOL_CONSUMER_PROBE +stderr~=registry\.example\.test diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/consumer-registry-check/warn-unreachable/curl-exit b/reference-implementations/hooks-claude-code/selftests/fixtures/consumer-registry-check/warn-unreachable/curl-exit index 9902f17..7f8f011 100644 --- a/reference-implementations/hooks-claude-code/selftests/fixtures/consumer-registry-check/warn-unreachable/curl-exit +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/consumer-registry-check/warn-unreachable/curl-exit @@ -1 +1 @@ -28 +7 diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/consumer-registry-check/warn-unreachable/env b/reference-implementations/hooks-claude-code/selftests/fixtures/consumer-registry-check/warn-unreachable/env new file mode 100644 index 0000000..32a67e7 --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/consumer-registry-check/warn-unreachable/env @@ -0,0 +1 @@ +export AGENT_PROTOCOL_CONSUMER_PROBE=1 diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/consumer-registry-check/warn-unreachable/expected b/reference-implementations/hooks-claude-code/selftests/fixtures/consumer-registry-check/warn-unreachable/expected index f2111c7..55a0162 100644 --- a/reference-implementations/hooks-claude-code/selftests/fixtures/consumer-registry-check/warn-unreachable/expected +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/consumer-registry-check/warn-unreachable/expected @@ -1,3 +1,4 @@ exit=2 +stderr~=AGENT_PROTOCOL_CONSUMER_PROBE stderr~=drift.consumer-registry-stale -stderr~=registry.example.invalid +stderr~=registry\.example\.invalid diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-cat-pem-outside-tests/event.json b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-cat-pem-outside-tests/event.json new file mode 100644 index 0000000..4844044 --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-cat-pem-outside-tests/event.json @@ -0,0 +1 @@ +{"tool_name":"Bash","tool_input":{"command":"cat secrets/client.pem"}} diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-cat-pem-outside-tests/expected b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-cat-pem-outside-tests/expected new file mode 100644 index 0000000..5958670 --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-cat-pem-outside-tests/expected @@ -0,0 +1,2 @@ +exit=1 +stderr~=reads a credential / secret path diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-eval-unlisted-init/event.json b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-eval-unlisted-init/event.json new file mode 100644 index 0000000..cce9a75 --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-eval-unlisted-init/event.json @@ -0,0 +1 @@ +{"tool_name":"Bash","tool_input":{"command":"eval \"$(unknownenv init bash)\""}} diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-eval-unlisted-init/expected b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-eval-unlisted-init/expected new file mode 100644 index 0000000..9c65d88 --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-eval-unlisted-init/expected @@ -0,0 +1,2 @@ +exit=1 +stderr~=invokes eval diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-force-push-delete-main/event.json b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-force-push-delete-main/event.json new file mode 100644 index 0000000..a030788 --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-force-push-delete-main/event.json @@ -0,0 +1 @@ +{"tool_name":"Bash","tool_input":{"command":"git push --force origin :main"}} diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-force-push-delete-main/expected b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-force-push-delete-main/expected new file mode 100644 index 0000000..4a8029f --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-force-push-delete-main/expected @@ -0,0 +1,2 @@ +exit=1 +stderr~=force push to protected branch diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-force-push-full-ref-main/event.json b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-force-push-full-ref-main/event.json new file mode 100644 index 0000000..4621ca6 --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-force-push-full-ref-main/event.json @@ -0,0 +1 @@ +{"tool_name":"Bash","tool_input":{"command":"git push --force origin refs/heads/main"}} diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-force-push-full-ref-main/expected b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-force-push-full-ref-main/expected new file mode 100644 index 0000000..4a8029f --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-force-push-full-ref-main/expected @@ -0,0 +1,2 @@ +exit=1 +stderr~=force push to protected branch diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-force-push-head-main/event.json b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-force-push-head-main/event.json new file mode 100644 index 0000000..12b9661 --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-force-push-head-main/event.json @@ -0,0 +1 @@ +{"tool_name":"Bash","tool_input":{"command":"git push --force origin HEAD:main"}} diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-force-push-head-main/expected b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-force-push-head-main/expected new file mode 100644 index 0000000..4a8029f --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-force-push-head-main/expected @@ -0,0 +1,2 @@ +exit=1 +stderr~=force push to protected branch diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-force-push-src-main/event.json b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-force-push-src-main/event.json new file mode 100644 index 0000000..c190caa --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-force-push-src-main/event.json @@ -0,0 +1 @@ +{"tool_name":"Bash","tool_input":{"command":"git push --force origin feature:main"}} diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-force-push-src-main/expected b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-force-push-src-main/expected new file mode 100644 index 0000000..4a8029f --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-force-push-src-main/expected @@ -0,0 +1,2 @@ +exit=1 +stderr~=force push to protected branch diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-force-push-upstream-main/event.json b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-force-push-upstream-main/event.json new file mode 100644 index 0000000..9d6ca00 --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-force-push-upstream-main/event.json @@ -0,0 +1 @@ +{"tool_name":"Bash","tool_input":{"command":"git push --force upstream main"}} diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-force-push-upstream-main/expected b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-force-push-upstream-main/expected new file mode 100644 index 0000000..4a8029f --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/block-force-push-upstream-main/expected @@ -0,0 +1,2 @@ +exit=1 +stderr~=force push to protected branch diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-cat-test-pem/event.json b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-cat-test-pem/event.json new file mode 100644 index 0000000..eefd87d --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-cat-test-pem/event.json @@ -0,0 +1 @@ +{"tool_name":"Bash","tool_input":{"command":"cat test/fixtures/test.pem"}} diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-cat-test-pem/expected b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-cat-test-pem/expected new file mode 100644 index 0000000..9200c2a --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-cat-test-pem/expected @@ -0,0 +1 @@ +exit=0 diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-cat-tests-pem/event.json b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-cat-tests-pem/event.json new file mode 100644 index 0000000..8e1fe23 --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-cat-tests-pem/event.json @@ -0,0 +1 @@ +{"tool_name":"Bash","tool_input":{"command":"cat tests/fixtures/test.pem"}} diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-cat-tests-pem/expected b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-cat-tests-pem/expected new file mode 100644 index 0000000..9200c2a --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-cat-tests-pem/expected @@ -0,0 +1 @@ +exit=0 diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-atuin-init/event.json b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-atuin-init/event.json new file mode 100644 index 0000000..10f14ea --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-atuin-init/event.json @@ -0,0 +1 @@ +{"tool_name":"Bash","tool_input":{"command":"eval \"$(atuin init bash)\""}} diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-atuin-init/expected b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-atuin-init/expected new file mode 100644 index 0000000..9200c2a --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-atuin-init/expected @@ -0,0 +1 @@ +exit=0 diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-direnv-init/event.json b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-direnv-init/event.json new file mode 100644 index 0000000..a28c581 --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-direnv-init/event.json @@ -0,0 +1 @@ +{"tool_name":"Bash","tool_input":{"command":"eval \"$(direnv hook bash)\""}} diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-direnv-init/expected b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-direnv-init/expected new file mode 100644 index 0000000..9200c2a --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-direnv-init/expected @@ -0,0 +1 @@ +exit=0 diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-fnm-init/event.json b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-fnm-init/event.json new file mode 100644 index 0000000..90c7473 --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-fnm-init/event.json @@ -0,0 +1 @@ +{"tool_name":"Bash","tool_input":{"command":"eval \"$(fnm env --use-on-cd)\""}} diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-fnm-init/expected b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-fnm-init/expected new file mode 100644 index 0000000..9200c2a --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-fnm-init/expected @@ -0,0 +1 @@ +exit=0 diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-goenv-init/event.json b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-goenv-init/event.json new file mode 100644 index 0000000..18ce81e --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-goenv-init/event.json @@ -0,0 +1 @@ +{"tool_name":"Bash","tool_input":{"command":"eval \"$(goenv init -)\""}} diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-goenv-init/expected b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-goenv-init/expected new file mode 100644 index 0000000..9200c2a --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-goenv-init/expected @@ -0,0 +1 @@ +exit=0 diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-mise-init/event.json b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-mise-init/event.json new file mode 100644 index 0000000..8ec563f --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-mise-init/event.json @@ -0,0 +1 @@ +{"tool_name":"Bash","tool_input":{"command":"eval \"$(mise activate bash)\""}} diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-mise-init/expected b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-mise-init/expected new file mode 100644 index 0000000..9200c2a --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-mise-init/expected @@ -0,0 +1 @@ +exit=0 diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-nodenv-init/event.json b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-nodenv-init/event.json new file mode 100644 index 0000000..fe35799 --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-nodenv-init/event.json @@ -0,0 +1 @@ +{"tool_name":"Bash","tool_input":{"command":"eval \"$(nodenv init -)\""}} diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-nodenv-init/expected b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-nodenv-init/expected new file mode 100644 index 0000000..9200c2a --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-nodenv-init/expected @@ -0,0 +1 @@ +exit=0 diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-nvm-init/event.json b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-nvm-init/event.json new file mode 100644 index 0000000..eb4d665 --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-nvm-init/event.json @@ -0,0 +1 @@ +{"tool_name":"Bash","tool_input":{"command":"eval \"$(nvm env --shell=bash)\""}} diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-nvm-init/expected b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-nvm-init/expected new file mode 100644 index 0000000..9200c2a --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-nvm-init/expected @@ -0,0 +1 @@ +exit=0 diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-pyenv-init/event.json b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-pyenv-init/event.json new file mode 100644 index 0000000..c6e444a --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-pyenv-init/event.json @@ -0,0 +1 @@ +{"tool_name":"Bash","tool_input":{"command":"eval \"$(pyenv init -)\""}} diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-pyenv-init/expected b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-pyenv-init/expected new file mode 100644 index 0000000..9200c2a --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-pyenv-init/expected @@ -0,0 +1 @@ +exit=0 diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-rbenv-init/event.json b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-rbenv-init/event.json new file mode 100644 index 0000000..e557e30 --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-rbenv-init/event.json @@ -0,0 +1 @@ +{"tool_name":"Bash","tool_input":{"command":"eval \"$(rbenv init - bash)\""}} diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-rbenv-init/expected b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-rbenv-init/expected new file mode 100644 index 0000000..9200c2a --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-rbenv-init/expected @@ -0,0 +1 @@ +exit=0 diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-ssh-agent-init/event.json b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-ssh-agent-init/event.json new file mode 100644 index 0000000..e1ee8ff --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-ssh-agent-init/event.json @@ -0,0 +1 @@ +{"tool_name":"Bash","tool_input":{"command":"eval \"$(ssh-agent -s)\""}} diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-ssh-agent-init/expected b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-ssh-agent-init/expected new file mode 100644 index 0000000..9200c2a --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-ssh-agent-init/expected @@ -0,0 +1 @@ +exit=0 diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-starship-init/event.json b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-starship-init/event.json new file mode 100644 index 0000000..1191418 --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-starship-init/event.json @@ -0,0 +1 @@ +{"tool_name":"Bash","tool_input":{"command":"eval \"$(starship init bash)\""}} diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-starship-init/expected b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-starship-init/expected new file mode 100644 index 0000000..9200c2a --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-starship-init/expected @@ -0,0 +1 @@ +exit=0 diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-zoxide-init/event.json b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-zoxide-init/event.json new file mode 100644 index 0000000..1c31707 --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-zoxide-init/event.json @@ -0,0 +1 @@ +{"tool_name":"Bash","tool_input":{"command":"eval \"$(zoxide init bash)\""}} diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-zoxide-init/expected b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-zoxide-init/expected new file mode 100644 index 0000000..9200c2a --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-eval-zoxide-init/expected @@ -0,0 +1 @@ +exit=0 diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-force-push-feature-release-notes/event.json b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-force-push-feature-release-notes/event.json new file mode 100644 index 0000000..d4c88f1 --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-force-push-feature-release-notes/event.json @@ -0,0 +1 @@ +{"tool_name":"Bash","tool_input":{"command":"git push --force origin feature/release-notes"}} diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-force-push-feature-release-notes/expected b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-force-push-feature-release-notes/expected new file mode 100644 index 0000000..9200c2a --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-force-push-feature-release-notes/expected @@ -0,0 +1 @@ +exit=0 diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-force-push-protected-first-positional/event.json b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-force-push-protected-first-positional/event.json new file mode 100644 index 0000000..2545f16 --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-force-push-protected-first-positional/event.json @@ -0,0 +1 @@ +{"tool_name":"Bash","tool_input":{"command":"git push --force release topic-branch"}} diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-force-push-protected-first-positional/expected b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-force-push-protected-first-positional/expected new file mode 100644 index 0000000..9200c2a --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-force-push-protected-first-positional/expected @@ -0,0 +1 @@ +exit=0 diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-force-push-protected-path-substring/event.json b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-force-push-protected-path-substring/event.json new file mode 100644 index 0000000..854ccd9 --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-force-push-protected-path-substring/event.json @@ -0,0 +1 @@ +{"tool_name":"Bash","tool_input":{"command":"git push --force origin feature/main-cleanup"}} diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-force-push-protected-path-substring/expected b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-force-push-protected-path-substring/expected new file mode 100644 index 0000000..9200c2a --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-force-push-protected-path-substring/expected @@ -0,0 +1 @@ +exit=0 diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-force-push-release-prefixed-branch/event.json b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-force-push-release-prefixed-branch/event.json new file mode 100644 index 0000000..1b77f9b --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-force-push-release-prefixed-branch/event.json @@ -0,0 +1 @@ +{"tool_name":"Bash","tool_input":{"command":"git push --force origin release-1.5"}} diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-force-push-release-prefixed-branch/expected b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-force-push-release-prefixed-branch/expected new file mode 100644 index 0000000..9200c2a --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-force-push-release-prefixed-branch/expected @@ -0,0 +1 @@ +exit=0 diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-psql-select-double-quoted-drop/event.json b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-psql-select-double-quoted-drop/event.json new file mode 100644 index 0000000..cae69b3 --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-psql-select-double-quoted-drop/event.json @@ -0,0 +1 @@ +{"tool_name":"Bash","tool_input":{"command":"psql -c 'SELECT \"DROP TABLE x\" AS warning'"}} diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-psql-select-double-quoted-drop/expected b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-psql-select-double-quoted-drop/expected new file mode 100644 index 0000000..9200c2a --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-psql-select-double-quoted-drop/expected @@ -0,0 +1 @@ +exit=0 diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-psql-select-single-quoted-drop/event.json b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-psql-select-single-quoted-drop/event.json new file mode 100644 index 0000000..23d58c1 --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-psql-select-single-quoted-drop/event.json @@ -0,0 +1 @@ +{"tool_name":"Bash","tool_input":{"command":"psql -c \"SELECT 'DROP TABLE x' AS warning\""}} diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-psql-select-single-quoted-drop/expected b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-psql-select-single-quoted-drop/expected new file mode 100644 index 0000000..9200c2a --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-bash-block/pass-psql-select-single-quoted-drop/expected @@ -0,0 +1 @@ +exit=0 diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-mcp-block/pass-mcp-sql-select-double-quoted-drop/event.json b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-mcp-block/pass-mcp-sql-select-double-quoted-drop/event.json new file mode 100644 index 0000000..f24cc60 --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-mcp-block/pass-mcp-sql-select-double-quoted-drop/event.json @@ -0,0 +1 @@ +{"tool_name":"mcp__db__query","tool_input":{"sql":"SELECT \" DROP TABLE x\" AS warning"}} diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-mcp-block/pass-mcp-sql-select-double-quoted-drop/expected b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-mcp-block/pass-mcp-sql-select-double-quoted-drop/expected new file mode 100644 index 0000000..9200c2a --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-mcp-block/pass-mcp-sql-select-double-quoted-drop/expected @@ -0,0 +1 @@ +exit=0 diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-mcp-block/pass-mcp-sql-select-single-quoted-drop/event.json b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-mcp-block/pass-mcp-sql-select-single-quoted-drop/event.json new file mode 100644 index 0000000..1d39c53 --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-mcp-block/pass-mcp-sql-select-single-quoted-drop/event.json @@ -0,0 +1 @@ +{"tool_name":"mcp__db__query","tool_input":{"sql":"SELECT ' DROP TABLE x' AS warning"}} diff --git a/reference-implementations/hooks-claude-code/selftests/fixtures/risky-mcp-block/pass-mcp-sql-select-single-quoted-drop/expected b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-mcp-block/pass-mcp-sql-select-single-quoted-drop/expected new file mode 100644 index 0000000..9200c2a --- /dev/null +++ b/reference-implementations/hooks-claude-code/selftests/fixtures/risky-mcp-block/pass-mcp-sql-select-single-quoted-drop/expected @@ -0,0 +1 @@ +exit=0 diff --git a/reference-implementations/section-indexes/indexes/docs-sections.json b/reference-implementations/section-indexes/indexes/docs-sections.json index 5946664..5491eee 100644 --- a/reference-implementations/section-indexes/indexes/docs-sections.json +++ b/reference-implementations/section-indexes/indexes/docs-sections.json @@ -2,7 +2,7 @@ "version": 1, "kind": "agent-protocol-section-index", "source": "docs/*.md", - "entry_count": 1110, + "entry_count": 1111, "entries": [ { "path": "docs/README.md", @@ -7317,7 +7317,15 @@ "heading": "Reference hook inventory", "summary": "The live Claude Code hook bundle ships at [`reference-implementations/hooks-claude-code/hooks/`](../reference-implementations/hooks-claude-code/hooks/), with adapter bundles under `reference-implementations/hooks-{cursor,gemini-...", "load_when": [], - "hash": "643d4acc5d926c24f18bc72eb0319724daaab54452c4c5773b2f709d6d41245d" + "hash": "d1f8c4d0fb15f0c452c196d81960ae66db456cf275aae3dd988f7007e9b45808" + }, + { + "path": "docs/runtime-hooks-in-practice.md", + "anchor": "#wiring-layers", + "heading": "Wiring layers", + "summary": "\"Wired\" is layer-specific:", + "load_when": [], + "hash": "9b5a495d315736d93962b73392faef5edd0037c872786041a430bee9d6183981" }, { "path": "docs/runtime-hooks-in-practice.md", @@ -7325,7 +7333,7 @@ "heading": "Install on Claude Code", "summary": "Prerequisites:", "load_when": [], - "hash": "f7360f81baaae77973d57a99913098c97d1e9466c62e8f37093c5ec4e0bfadd0" + "hash": "e2fbfe5eff2b1bee7cfac33573e704908530a7008e9d50fa17d315fd4f2fbd87" }, { "path": "docs/runtime-hooks-in-practice.md", @@ -7347,25 +7355,25 @@ "path": "docs/runtime-hooks-in-practice.md", "anchor": "#configuration-knobs-environment-variables", "heading": "Configuration knobs (environment variables)", - "summary": "| Variable | Default | Effect | |---|---|---| | `AGENT_PROTOCOL_MANIFEST_PATH` | `git ls-files change-manifest*.yaml \\| head -1` | Point hooks at a specific manifest path; useful when multiple manifests coexist in a monorepo. | |...", + "summary": "| Variable | Default | Effect | |---|---|---| | `AGENT_PROTOCOL_MANIFEST_PATH` | `git ls-files change-manifest*.yaml \\| head -1` | Point hooks at a specific manifest path; useful when multiple manifests coexist in a monorepo. The...", "load_when": [], - "hash": "f551eebeebeeba8bc07ece9530cb347d1cce45aa817c42634140530a6ebc8d52" + "hash": "85667ea9f45ed025db4e81bd8e73a25a9ba57861e29da076743dfdb31f0075f9" }, { "path": "docs/runtime-hooks-in-practice.md", "anchor": "#adoption-ramp", "heading": "Adoption ramp", - "summary": "Don't enable all four hooks on day one. Common staging:", + "summary": "Don't enable every hook on day one. Common staging:", "load_when": [], - "hash": "c65db6ae8a169d641bdf297fab15617c286466778a270553eaa70d43f3e869ea" + "hash": "23286add3792775a97620018022d47b22a04863463b52b57f04bce4803acae06" }, { "path": "docs/runtime-hooks-in-practice.md", "anchor": "#writing-your-own-hook", "heading": "Writing your own hook", - "summary": "The four shipped hooks are ~50–80 lines of POSIX sh each; read them as templates. A custom hook must:", + "summary": "The shipped hooks are small POSIX-sh entrypoints; read them as templates. A custom hook must:", "load_when": [], - "hash": "461aec8b3e9081da33a4fbc9ef028227d705af40bc6020c810dcd7b3641ee86e" + "hash": "e04bf5ae2377d410348e9130a5feaeb5b34e4fd2126c4478a8a0db963bfc32b1" }, { "path": "docs/runtime-hooks-in-practice.md", @@ -7373,7 +7381,7 @@ "heading": "See also", "summary": "[`runtime-hook-contract.md`](./runtime-hook-contract.md) — normative capability contract (event schema, latency budgets, category definitions). [`runtime-hook-threat-model.md`](./runtime-hook-threat-model.md) — security-boundary...", "load_when": [], - "hash": "10e83f00561c19d5414451172be082b2b03ad39f936e05fed922091d79222d78" + "hash": "9eb1217f70e51ed406129bdc14b726f1f43f785d62ba031f810f3fb96ab4ab26" }, { "path": "docs/security-supply-chain-disciplines.md",