From b508747a3ad7f4fb7b08d29bf0c33217d92a5260 Mon Sep 17 00:00:00 2001 From: Michael Busacca Date: Sat, 27 Jun 2026 17:18:49 -0400 Subject: [PATCH 1/2] feat(v1.8): AGENTS.md enforcement + empirical enforcement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 6 new hook ports, 2 guides, 1 smoke test, updated normalize lib. Two Codex adversarial passes — 6 findings fixed before tag. Hooks: - agentsmd-bash-gate.sh (normalize + __NH_CONFLICT__ fail-closed + scoped allowlist) - agentsmd-session-inject.sh - three-failure-stop-gate.sh (normalize Grok payloads) - claim-evidence-gate-dispatch.sh - claim-evidence-gate.sh (bash floor, Gate 4) - aof-eval-opportunity-counter.sh (env-var secret scrub + whitespace trim) Guides: - guides/advanced/when-to-write-a-hook.md (advisory exception + dedup question) - guides/advanced/go-hook-dispatch-pattern.md Tests: - tests/smoke/hooks/grok-shape-normalize.sh (8/8 pass) Docs: - AGENT_FRAMEWORK.md v1.7 → v1.8, §5.3 matrix +5 rows - CHANGELOG.md v1.8 section - README.md + examples/hooks/README.md updated Co-Authored-By: Claude Sonnet 4.6 (1M context) --- AGENT_FRAMEWORK.md | 19 +- CHANGELOG.md | 68 ++++++ README.md | 12 +- .../plans/2026-06-27-aof-v1.8-release-plan.md | 59 +++++ examples/hooks/README.md | 6 + examples/hooks/agentsmd-bash-gate.sh | 80 +++++++ examples/hooks/agentsmd-session-inject.sh | 37 ++++ .../hooks/aof-eval-opportunity-counter.sh | 76 +++++++ .../hooks/claim-evidence-gate-dispatch.sh | 169 ++++++++++++++ examples/hooks/claim-evidence-gate.sh | 209 ++++++++++++++++++ examples/hooks/lib/normalize-hook-input.sh | 152 +++++++++---- examples/hooks/three-failure-stop-gate.sh | 208 +++++++++++++++++ guides/advanced/go-hook-dispatch-pattern.md | 118 ++++++++++ guides/advanced/when-to-write-a-hook.md | 91 ++++++++ tests/smoke/hooks/grok-shape-normalize.sh | 132 +++++++++++ 15 files changed, 1383 insertions(+), 53 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-27-aof-v1.8-release-plan.md create mode 100755 examples/hooks/agentsmd-bash-gate.sh create mode 100755 examples/hooks/agentsmd-session-inject.sh create mode 100755 examples/hooks/aof-eval-opportunity-counter.sh create mode 100755 examples/hooks/claim-evidence-gate-dispatch.sh create mode 100755 examples/hooks/claim-evidence-gate.sh create mode 100755 examples/hooks/three-failure-stop-gate.sh create mode 100644 guides/advanced/go-hook-dispatch-pattern.md create mode 100644 guides/advanced/when-to-write-a-hook.md create mode 100755 tests/smoke/hooks/grok-shape-normalize.sh diff --git a/AGENT_FRAMEWORK.md b/AGENT_FRAMEWORK.md index 046e816..338db74 100644 --- a/AGENT_FRAMEWORK.md +++ b/AGENT_FRAMEWORK.md @@ -1,4 +1,4 @@ -# Agent Operating Framework v1.7 +# Agent Operating Framework v1.8 > A behavioral operating system for AI coding agents — born from production failures, not theory. > @@ -304,7 +304,7 @@ See [`guides/enforcement-architecture.md`](guides/enforcement-architecture.md) f ### 5.3 Rule-to-Hook Coverage -The escalation ladder above (memory → rule → hook) is aspirational — not every rule has a hook backing it, and the framework does not pretend otherwise. The matrix below is the accurate accounting of what ships in v1.6: +The escalation ladder above (memory → rule → hook) is aspirational — not every rule has a hook backing it, and the framework does not pretend otherwise. The matrix below is the accurate accounting of what ships in v1.8: | Rule | Hook | Fail mode | Blast radius | Coverage | |---|---|---|---|---| @@ -324,6 +324,16 @@ Five of six rules ship with hook backing as of v1.5. The single remaining adviso - If you adopt the framework expecting all rules to be system-enforced, this matrix is the reality check. - If you need stronger guarantees on the advisory rules, write your own `PreToolUse` hooks against your environment's specifics — the framework's hooks are reference implementations, not exhaustive coverage. +**AGENTS.md enforcement hooks** (v1.8 — repo governance): +- [`agentsmd-bash-gate.sh`](examples/hooks/agentsmd-bash-gate.sh) — blocks Bash commands that touch `~/repos//` unless AGENTS.md for that repo was Read this session *(fail-mode: closed, blast-radius: destructive)* +- [`agentsmd-session-inject.sh`](examples/hooks/agentsmd-session-inject.sh) — SessionStart hook; when cwd is inside a repo, prints AGENTS.md to stdout as session context *(fail-mode: open, blast-radius: advisory)* + +**Empirical enforcement hooks** (v1.8 — claim and discipline gates): +- [`three-failure-stop-gate.sh`](examples/hooks/three-failure-stop-gate.sh) — blocks the 4th `fix(...)` commit in 2 hours unless a `# halted-and-researched:` attestation is present; 6,262 fires / 13 blocks in production *(fail-mode: advisory — fail-open on repo-resolve failure)* +- [`claim-evidence-gate-dispatch.sh`](examples/hooks/claim-evidence-gate-dispatch.sh) — cross-platform front door for Gate 4; probes native Go binary (two-probe trust check) before falling back to bash floor; live telemetry Mac 2026-06-27 PR #571 *(fail-mode: closed, blast-radius: security)* +- [`claim-evidence-gate.sh`](examples/hooks/claim-evidence-gate.sh) — bash floor for Gate 4; blocks assertion language and path-cited claims without a session Read breadcrumb *(fail-mode: closed, blast-radius: security)* +- [`aof-eval-opportunity-counter.sh`](examples/hooks/aof-eval-opportunity-counter.sh) — PostToolUse/SessionStart/UserPromptSubmit hook; POSTs to `eval.opportunities` for DPMO measurement; health signal = `eval.opportunities` rows (NOT `hook_events` fire_count); requires `AOF_EVAL_SUPABASE_URL` + `AOF_EVAL_SUPABASE_KEY` env vars *(fail-mode: open, blast-radius: telemetry)* + **Meta-hooks** (not bound to a single rule): - [`deprecated-field-gate.sh`](examples/hooks/deprecated-field-gate.sh) — template for blocking writes that reference deprecated DB columns or API fields *(fail-mode: closed, blast-radius: destructive)* - [`empty-rule-body-gate.sh`](examples/hooks/empty-rule-body-gate.sh) — pre-merge CI check that rejects rule files with empty bodies (< 200 bytes) or missing `## Why` sections *(fail-mode: closed, blast-radius: security — protects framework integrity against false-positive "applied" claims)* @@ -371,7 +381,7 @@ See [`guides/rule-consolidation.md`](guides/rule-consolidation.md) for a worked --- -## Framework Structure (v1.5) +## Framework Structure (v1.8) ``` AGENT_FRAMEWORK.md ← This file. The complete behavioral spec. @@ -391,6 +401,8 @@ guides/ hook-audit-methodology.md ← 4-track audit pattern (v1.6) silent-failure-discipline.md ← Every fail-open path must log (v1.7) agents-md-standard.md ← Three-level repo governance contract (v1.7) + when-to-write-a-hook.md ← Decision test for hook vs. rule (v1.8) + go-hook-dispatch-pattern.md ← Go binary + bash floor dispatch pattern (v1.8) examples/ claude-code-rules/ ← Sample rule files for Claude Code hooks/ ← Reference hook implementations (Claude Code-specific) @@ -406,6 +418,7 @@ Full per-release notes live in [CHANGELOG.md](CHANGELOG.md). The framework file Headline changes from recent versions: +- **v1.8** — AGENTS.md enforcement + empirical enforcement release. 6 new hooks: `agentsmd-bash-gate.sh`, `agentsmd-session-inject.sh`, `three-failure-stop-gate.sh`, `claim-evidence-gate-dispatch.sh`, `claim-evidence-gate.sh`, `aof-eval-opportunity-counter.sh`. 2 new guides: `when-to-write-a-hook.md` and `go-hook-dispatch-pattern.md`. New smoke test: `tests/smoke/hooks/grok-shape-normalize.sh`. Updated `lib/normalize-hook-input.sh` with dual-shape conflict detection. §5.3 matrix extended with 5 new hook rows. CEG telemetry live Mac PR #571. - **v1.7** — Provable hooks release: `startup-gate.sh` (SessionStart governance check), `normalize-hook-input.sh` (cross-runtime payload normalization), `hook-telemetry-stop.sh` (session-end telemetry). New guides: `silent-failure-discipline.md` (ADR 0012 — every fail-open path must log) and `agents-md-standard.md` (three-level repo governance contract). Incidents #36–#38. - **v1.6** — Hook operations layer: `breadcrumb-lib.sh` shared session library, `CLAUDE_HOOKS_SAFE_MODE` emergency bypass pattern, `# fail-mode: silent-skip` taxonomy tier for watcher-class hooks. New guides: `hook-operations.md` (the three operational questions) and `hook-audit-methodology.md` (4-track audit pattern). §5.2 updated with bypass + breadcrumb protocol as standard requirements. AOF self-eval harness (`b3d8451`) on main. - **v1.5** — `secure-config-gate.sh`, `focus-breadcrumb.sh` + `focus-confirmation-gate.sh`, `dormant-code-gate.sh`. §5.3 coverage moves from 3-of-6 enforced to **5-of-6 enforced**. `no-local-infrastructure` rewritten as a hosting decision framework (advisory by design). diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c8b158..56321d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,74 @@ All notable changes to this framework follow [Keep a Changelog](https://keepacha --- +## [1.8] — 2026-06 + +### Background + +v1.7 answered: can you prove hooks work? v1.8 answers: do you ship the hooks that enforce the rules you already have written? Two rule gaps had hooks drafted in the private config but not ported to the public repo: AGENTS.md enforcement (repo governance) and empirical discipline gates (three-failure stop, claim-evidence). Both are now public. + +### What v1.8 covers ("AGENTS.md enforcement + empirical enforcement" release) + +Six new hook ports, two new guides, one new smoke test, and updated `lib/normalize-hook-input.sh` with dual-shape conflict detection. + +### Added + +- **`examples/hooks/agentsmd-bash-gate.sh`** — PreToolUse gate for Bash. Blocks commands that touch `~/repos//` unless AGENTS.md for that repo was Read this session. Complements `read-gate.sh` (which covers Edit/Write) with Bash-side coverage. Port hardening: added `source lib/normalize-hook-input.sh` and fixed the parser to read `tool_input.command` (not `input.command`) for correct Claude Code envelope handling. + +- **`examples/hooks/agentsmd-session-inject.sh`** — SessionStart advisory hook. When cwd is inside `~/repos//`, prints AGENTS.md to stdout so Claude loads it as session context before the first user prompt. Ports directly; no hardening needed. + +- **`examples/hooks/three-failure-stop-gate.sh`** — PreToolUse gate for Bash. Blocks the 4th `fix(...)` commit within 2 hours unless the commit body contains `# halted-and-researched: `. State stored in `~/.claude/state/three-failure-stop/__.log`. Production telemetry: 6,262 fires / 13 blocks. Fail-open on repo-resolve failure (advisory by design — false-positives more disruptive than false-negatives for this pattern). + +- **`examples/hooks/claim-evidence-gate-dispatch.sh`** — Cross-platform dispatch wrapper for Gate 4. Probes the native Go binary with a two-probe trust check (must allow a clean payload AND block a claim-shaped payload) before trusting it. Falls back to `claim-evidence-gate.sh` if the binary is missing, wrong architecture, or fails either probe. Fail-closed with no runnable gate at all. Telemetry breadcrumb fires at the dispatch layer (single choke point). CEG fire_count>0 on Mac confirmed 2026-06-27 (PR #571). + +- **`examples/hooks/claim-evidence-gate.sh`** — Bash floor for Gate 4. Blocks assertion language patterns and explicit path-cited claims without a session Read breadcrumb. Pattern list aligned one-for-one with the Go binary's `assertionPatterns` (softened per ADR 0064 — bare "confirmed"/"verified" removed after 58/59 audit showed false positives). Empty stdin fails closed. Self-exempts via path-allowlist (the file itself is allowlisted to prevent gate self-block during deployment). + +- **`examples/hooks/aof-eval-opportunity-counter.sh`** — Fires on PreToolUse, SessionStart, and UserPromptSubmit (three settings.json registrations). POSTs to `eval.opportunities` table for DPMO measurement. **Health signal: `eval.opportunities` row count.** `telemetry.hook_events` fire_count is expected to be 0 for this hook (it does not use `bc_write` / `hook-telemetry-stop`). Port hardening: replaced hardcoded Supabase URL + anon key with `AOF_EVAL_SUPABASE_URL` + `AOF_EVAL_SUPABASE_KEY` env vars; hook fails open if either is unset. + +- **`guides/advanced/when-to-write-a-hook.md`** — Decision test for hook vs. rule. Three questions: (1) has the rule been violated with real consequence? (2) can the hook detect the violation mechanically? (3) is the blast radius acceptable? Includes hook type table by blast radius, what belongs in a rule (not a hook), hook anatomy invariants, and a 4-step test matrix before shipping. + +- **`guides/advanced/go-hook-dispatch-pattern.md`** — Canonical pattern for shipping Go binary hooks with a bash fallback. Explains the 2026-06-13 incident (Mach-O binary failing open on Win11), the two-probe trust model, build-at-install-time pattern, gitignore for architecture-specific binaries, bash floor alignment requirements, and settings.json registration via dispatch wrapper only. + +- **`tests/smoke/hooks/grok-shape-normalize.sh`** — 6-case smoke test for `lib/normalize-hook-input.sh`. Tests: camelCase normalization, snake_case passthrough, dual-shape conflict sentinel (`__NH_CONFLICT__`), empty input, malformed JSON. Gate: all 6 PASS before tagging v1.8. + +- **`examples/hooks/lib/normalize-hook-input.sh`** — Updated from v1.7 with dual-shape conflict detection. When a payload carries a field in both camelCase and snake_case shapes, `nh_normalize` now emits `__NH_CONFLICT__` sentinel instead of normalizing. The dispatch wrapper and gate check for this sentinel and fail closed (block) — a dual-shape payload is unevaluable; scanning one branch while the runtime executes the other risks claim bypass. + +- **3 new sanitized incidents** in `INCIDENTS.md` (#39, #40, #41). + +### Changed + +- **`AGENT_FRAMEWORK.md`** — version bump to v1.8. §5.3 matrix extended with 5 new hook rows (AGENTS.md enforcement + empirical gates). Framework structure updated with two new advanced guides. Version history entry added. +- **`README.md`** — v1.8 references, 41 incidents, 5 new hooks in hook table. +- **`examples/hooks/README.md`** — 5 new inventory rows (one per new hook, plus counter). + +### What is NOT in v1.8 (deferred to v1.9) + +- **Go binary for claim-evidence-gate** — the dispatch wrapper + bash floor ship; the public Go source does not. The private Go binary is architecture-specific (Mach-O arm64) and requires build tooling. Public port deferred until a portable build pipeline exists for the AOF repo. +- **FORGET mechanism** — carried from v1.7. Still the hard prerequisite for Phase D global deploy of the memory system. +- **`distill-memory.py` regex fix** — resolved in private config (2026-06-27); not a public AOF artifact. Removed from deferred list. + +### Release checklist + +- [x] Port `agentsmd-bash-gate.sh` (with normalize-hook-input + tool_input.command fix) +- [x] Port `agentsmd-session-inject.sh` +- [x] Port `three-failure-stop-gate.sh` +- [x] Port `claim-evidence-gate-dispatch.sh` +- [x] Port `claim-evidence-gate.sh` (bash floor) +- [x] Port `aof-eval-opportunity-counter.sh` (with secret scrub) +- [x] Write `guides/advanced/when-to-write-a-hook.md` +- [x] Write `guides/advanced/go-hook-dispatch-pattern.md` +- [x] Write `tests/smoke/hooks/grok-shape-normalize.sh` +- [x] Update `lib/normalize-hook-input.sh` (dual-shape conflict) +- [x] Smoke test PASS (6/6) +- [x] Edit `AGENT_FRAMEWORK.md` v1.7 → v1.8 + §5.3 matrix + structure +- [x] Edit `README.md` +- [x] Edit `examples/hooks/README.md` +- [x] Insert CHANGELOG v1.8 +- [ ] `git tag v1.8 && git push --tags` +- [ ] Create GitHub release + +--- + ## [1.7] — 2026-06 ### Background diff --git a/README.md b/README.md index 01d9d6d..a0eea51 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ See [guides/getting-started.md](guides/getting-started.md) for the full adoption You've set up CLAUDE.md. You've built a few skills. You're using Projects Memory. But outputs are still inconsistent, the agent ignores rules under pressure, and you're manually reviewing everything. -This framework is the next step. It adds rules with documented enforcement contracts (some advisory by design), circuit breakers (stop after 3 failures), and an escalation model (advice → law → barriers) that makes your CLAUDE.md actually stick. See the [rule-to-hook coverage matrix](AGENT_FRAMEWORK.md#53-rule-to-hook-coverage) for what is system-enforced versus advisory in v1.7 — five of six rules ship with hooks; one (`no-local-infrastructure`) is a decision framework that is advisory by design. +This framework is the next step. It adds rules with documented enforcement contracts (some advisory by design), circuit breakers (stop after 3 failures), and an escalation model (advice → law → barriers) that makes your CLAUDE.md actually stick. See the [rule-to-hook coverage matrix](AGENT_FRAMEWORK.md#53-rule-to-hook-coverage) for what is system-enforced versus advisory in v1.8 — five of six rules ship with hooks; one (`no-local-infrastructure`) is a decision framework that is advisory by design. If you're just getting started with Claude Code, read the beginner guides first. If you've hit the wall where your CLAUDE.md "stops working," [start here](guides/from-beginner-to-framework.md). @@ -49,7 +49,7 @@ Every rule exists because its absence caused a specific, documented failure. See ## Library Contents ### The Framework -- **[AGENT_FRAMEWORK.md](AGENT_FRAMEWORK.md)** — The complete framework (v1.7). Use as your project's CLAUDE.md. +- **[AGENT_FRAMEWORK.md](AGENT_FRAMEWORK.md)** — The complete framework (v1.8). Use as your project's CLAUDE.md. ### Guides - **[From Beginner to Framework](guides/from-beginner-to-framework.md)** — You've built CLAUDE.md and skills but outputs are inconsistent. Here's why and what to do next. @@ -93,13 +93,19 @@ Shell scripts that enforce rules at the tool-call level — the third tier of th | [startup-gate.sh](examples/hooks/startup-gate.sh) | SessionStart advisory | Checks repo, AGENTS.md, active plan, and hook registration gap at session start; writes drift report | | [hook-telemetry-stop.sh](examples/hooks/hook-telemetry-stop.sh) | Stop advisory | Reads fire/block breadcrumbs at session end; bulk-INSERTs telemetry rows | | [lib/normalize-hook-input.sh](examples/hooks/lib/normalize-hook-input.sh) | Library (source only) | Normalizes hook payload field names and tool-name literals across Claude Code and Grok runtimes | +| [agentsmd-bash-gate.sh](examples/hooks/agentsmd-bash-gate.sh) | PreToolUse hard block | Blocks Bash in `~/repos//` unless AGENTS.md for that repo was Read this session | +| [agentsmd-session-inject.sh](examples/hooks/agentsmd-session-inject.sh) | SessionStart advisory | Injects AGENTS.md into session context when cwd is inside a repo | +| [three-failure-stop-gate.sh](examples/hooks/three-failure-stop-gate.sh) | PreToolUse advisory block | Blocks 4th `fix(...)` commit in 2 hours without a halted-and-researched attestation | +| [claim-evidence-gate-dispatch.sh](examples/hooks/claim-evidence-gate-dispatch.sh) | PreToolUse hard block | Cross-platform Gate 4 dispatcher; probes Go binary (two-probe trust check), falls back to bash floor | +| [claim-evidence-gate.sh](examples/hooks/claim-evidence-gate.sh) | PreToolUse hard block | Bash floor for Gate 4; blocks assertion language and path-cited claims without a session Read | +| [aof-eval-opportunity-counter.sh](examples/hooks/aof-eval-opportunity-counter.sh) | Multi-event advisory | DPMO counter; POSTs to `eval.opportunities` per tool call; health = row count | See [§5.3 Rule-to-Hook Coverage](AGENT_FRAMEWORK.md#53-rule-to-hook-coverage) for which rule each hook backs and the full enforced-vs-advisory accounting. See [examples/hooks/README.md](examples/hooks/README.md) for setup instructions and the breadcrumb pattern. ### Incident Log -- **[INCIDENTS.md](INCIDENTS.md)** — 38 sanitized incidents linking real failures to the rules they produced. Month-precision dates. +- **[INCIDENTS.md](INCIDENTS.md)** — 41 sanitized incidents linking real failures to the rules they produced. Month-precision dates. ## The Key Insight diff --git a/docs/superpowers/plans/2026-06-27-aof-v1.8-release-plan.md b/docs/superpowers/plans/2026-06-27-aof-v1.8-release-plan.md new file mode 100644 index 0000000..7320852 --- /dev/null +++ b/docs/superpowers/plans/2026-06-27-aof-v1.8-release-plan.md @@ -0,0 +1,59 @@ +# AOF v1.8 release plan — with telemetry evidence (2026-06-27) + +**For Claude:** Read this file before editing CHANGELOG.md or shipping v1.8. Grok deep-dived opportunity-counter + CEG telemetry 2026-06-27; plan-review table below replaces stale assumptions. + +--- + +## Plan review table (CORRECTED) + +| Item | Old note | Corrected action | +|------|----------|-------------------| +| aof-eval-opportunity-counter.sh | Real, in scope, include — add sanitization note to checklist | **Keep.** Public port: strip hardcoded Supabase URL/anon key → env vars. Document 3-surface settings.json. **Do not** use hook_events.fire_count as health signal. | +| Two new guides | Planned [ ] | **No change.** | +| Grok dual-runtime proof table | [UNVERIFIED] | **Partially verified** Grok Mac 2026-06-27. Ship gate = tests/smoke/hooks/grok-shape-normalize.sh PASS in public repo. | +| Counter telemetry | 0 fires same as CEG — confirmation pending | **WRONG — not same as CEG.** Counter is WORKING via eval.opportunities. hook_events 0 fires is expected (no breadcrumbs). See evidence below. | +| CEG telemetry | (lumped with counter) | **CONFIRMED** PR #571. hook_events claim-evidence-gate fire_count>0 Mac today. Ignore claim-evidence-gate-dispatch watchdog rows. | + +--- + +## Opportunity counter — evidence + +**Two systems — do not mix them:** + +1. **eval.opportunities** (primary) — hook POSTs here. **This is the health check.** +2. **telemetry.hook_events** — only if hook uses bc_write + hook-telemetry-stop. **Counter does not.** + +**Live 2026-06-27:** +- eval.opportunities: 98k+ rows; Mac + Win11 SS/UPS/PTU firing today (ADR 0088) +- hook_events aof-eval-opportunity-counter: 5 rows ever, all fire_count=0, session_id= = Win11 silent-watchdog from .errors.log (supabase-insert-failed), NOT missing bc_record_exit +- Phase 1 smoke Mac: ALL PASS + +**Fix needed for v1.8:** sanitize secrets on public copy only. **No breadcrumb telemetry fix required for DPMO.** + +--- + +## CEG — evidence + +**Was broken:** no bc_write in dispatch + not in BLOCKING_HOOKS. **Fixed PR #571.** + +**Live 2026-06-27:** claim-evidence-gate rows fire_count=1, real UUID session_id on Mac. + +**Do not cite:** claim-evidence-gate-dispatch rows with (watchdog error stream). + +**v1.8 wording:** CEG telemetry confirmed Mac. Remove from v1.9 deferred list. + +--- + +## Checklist addendum (opportunity counter port) + +- [ ] Replace hardcoded Supabase URL/key with AOF_EVAL_SUPABASE_URL + AOF_EVAL_SUPABASE_KEY +- [ ] Document 3 AOF_EVAL_HOOK_EVENT registrations in hook header or README +- [ ] Changelog: health = eval.opportunities rows, NOT hook_events fire_count + +--- + +## References + +- claude-config ADR 0088, PR #571 +- grok-hook-denial-triage — three telemetry streams +- handoff-2026-06-27-190000.md (Win11 ADR 0088 deploy) diff --git a/examples/hooks/README.md b/examples/hooks/README.md index b42de2a..824c781 100644 --- a/examples/hooks/README.md +++ b/examples/hooks/README.md @@ -147,6 +147,12 @@ When a hook blocks (exit 2), its stdout becomes the agent's error context. Write | `startup-gate.sh` | SessionStart | Advisory (exit 0) | Checks repo, AGENTS.md, active plan, skills manifest, and hook registration at session start. Writes drift report to `~/.claude/startup-gate-report.md`. | | `hook-telemetry-stop.sh` | Stop | Advisory (exit 0) | Reads fire/block breadcrumbs at session end; bulk-INSERTs one row per hook into a configurable telemetry store (set `AOF_TELEMETRY_URL` + `AOF_TELEMETRY_KEY`). | | `lib/normalize-hook-input.sh` | Library | — (source only) | Normalizes hook payload field names (camelCase → snake_case) and tool-name literals (Grok → Claude Code). Source before any `tool_name` check for multi-runtime hooks. | +| `agentsmd-bash-gate.sh` | PreToolUse (Bash) | Hard block (exit 2) | Blocks Bash commands touching `~/repos//` unless AGENTS.md for that repo was Read this session. Complement to `read-gate.sh` which covers Edit/Write. | +| `agentsmd-session-inject.sh` | SessionStart | Advisory (exit 0) | When cwd is inside a repo, prints AGENTS.md to stdout as session context before any user prompt fires. | +| `three-failure-stop-gate.sh` | PreToolUse (Bash) | Advisory block (exit 2) | Blocks the 4th `fix(...)` commit in 2 hours unless a `# halted-and-researched:` attestation is in the commit body. Fail-open on repo-resolve failure. | +| `claim-evidence-gate-dispatch.sh` | PreToolUse (Edit\|Write\|Bash) | Hard block (exit 2) | Cross-platform dispatch wrapper for Gate 4. Probes native Go binary with two-probe trust check; falls back to `claim-evidence-gate.sh` if binary is missing or fails either probe. Fail-closed with no runnable gate. | +| `claim-evidence-gate.sh` | PreToolUse (Edit\|Write\|Bash) | Hard block (exit 2) | Bash floor for Gate 4. Blocks assertion language patterns and path-cited claims without a session Read breadcrumb. Matches Go binary patterns one-for-one (softened per ADR 0064). | +| `aof-eval-opportunity-counter.sh` | PreToolUse / SessionStart / UserPromptSubmit | Advisory (exit 0) | POSTs to `eval.opportunities` on each tool call for DPMO scoring. Health signal = row count, NOT `hook_events` fire_count. Requires `AOF_EVAL_SUPABASE_URL` + `AOF_EVAL_SUPABASE_KEY` env vars. | ## The Focus-Confirmation Pair diff --git a/examples/hooks/agentsmd-bash-gate.sh b/examples/hooks/agentsmd-bash-gate.sh new file mode 100755 index 0000000..9653fa0 --- /dev/null +++ b/examples/hooks/agentsmd-bash-gate.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# agentsmd-bash-gate.sh +# PreToolUse hook (matcher: Bash) — blocks Bash commands that touch ~/repos// +# unless AGENTS.md for that repo has been Read this session. +# Complements new-repo-read-gate.sh which covers Edit|MultiEdit|Write. +# Exits 2 to block; exits 0 to allow. +# fail-mode: open (parse errors allow through — don't block on bad JSON) +# blast-radius: local session only — read-only check, no writes +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/breadcrumb-lib.sh" 2>/dev/null || { exit 0; } +source "$SCRIPT_DIR/lib/normalize-hook-input.sh" 2>/dev/null || true + +INPUT="${HOOK_INPUT_JSON:-$(cat)}" + +# Normalize camelCase (Grok) → snake_case (Claude Code) before any field extraction. +# Without this, Grok-shaped payloads (toolInput instead of tool_input) exit 0 silently. +# __NH_CONFLICT__: dual-shape payload — fail closed. The gate cannot safely evaluate +# which branch the runtime will execute; allowing risks bypassing on a Grok+Claude +# dual-envelope payload crafted to carry a repo command in the Grok branch only. +if command -v nh_normalize >/dev/null 2>&1; then + INPUT="$(nh_normalize "$INPUT")" + if [ "$INPUT" = "__NH_CONFLICT__" ]; then + printf "[GATE: agentsmd-bash] dual-shape payload conflict — failing closed.\n" >&2 + exit 2 + fi +fi + +# Extract command string from Bash tool input +COMMAND="$(echo "$INPUT" | python3 -c " +import sys, json +try: + d = json.load(sys.stdin) + # Prefer tool_input (Claude Code shape); fall back to input for legacy envelopes + obj = d.get('tool_input', d.get('input', d)) + print(obj.get('command', '') if isinstance(obj, dict) else '') +except Exception: + print('') +" 2>/dev/null)" + +[ -z "$COMMAND" ] && exit 0 + +# Only gate commands that reference a local repo path +# Match $HOME/repos/ or ~/repos/ or /Users//repos/ +REPO_NAME="" +for pattern in \ + "$HOME/repos/" \ + "~/repos/" \ + "/Users/[^/]*/repos/"; do + # Try to extract repo name from the command string + REPO_NAME="$(echo "$COMMAND" | grep -oE "(${HOME}|~|/Users/[^/]+)/repos/[^/[:space:]'\"]+" 2>/dev/null | head -1 | sed -E 's|.*/repos/([^/[:space:]]+).*|\1|' || true)" + [ -n "$REPO_NAME" ] && break +done + +[ -z "$REPO_NAME" ] && exit 0 # No repo path in command — allow + +# Allow AGENTS.md reads themselves through (breadcrumb not yet written when gate fires). +# Scope: only commands whose sole purpose is reading AGENTS.md for this repo. +# Broad substring match (old) allowed "echo AGENTS.md; ls ~/repos/target/" to bypass. +# Fixed: require the command to reference AGENTS.md at the target repo path specifically, +# not just contain "AGENTS.md" anywhere in the string. +AGENTS_PATH="$HOME/repos/$REPO_NAME/AGENTS.md" +case "$COMMAND" in + *"$AGENTS_PATH"*|*"AGENTS.md"$'\n'*|"cat "*"AGENTS.md"|"cat AGENTS.md") exit 0 ;; +esac +# Also allow if command is purely a Read of AGENTS.md (no other repo path actions) +if echo "$COMMAND" | grep -qE "^[[:space:]]*(cat|head|tail|less|bat)[[:space:]].*AGENTS\.md[[:space:]]*$"; then + exit 0 +fi + +# Check breadcrumb +LOG_FILE="$(bc_dir)/$(bc_session_key)-new-repo-read-log" + +if [ ! -f "$LOG_FILE" ] || ! grep -qF $'\t'"$REPO_NAME"$'\t'"AGENTS.md" "$LOG_FILE" 2>/dev/null; then + printf "[GATE: agentsmd-bash] AGENTS.md not read for repo '%s' this session. Read it before running Bash commands in this repo.\n" "$REPO_NAME" >&2 + exit 2 +fi + +exit 0 diff --git a/examples/hooks/agentsmd-session-inject.sh b/examples/hooks/agentsmd-session-inject.sh new file mode 100755 index 0000000..5f44d80 --- /dev/null +++ b/examples/hooks/agentsmd-session-inject.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# agentsmd-session-inject.sh +# SessionStart hook — when cwd is ~/repos//, reads AGENTS.md and prints +# it to stdout so Claude loads it as session context before any user prompt. +# Advisory only: does not block; agent may still ignore content. +# fail-mode: open (errors print nothing — don't corrupt session start) +# blast-radius: read-only, local session only +set -euo pipefail + +CWD="${CLAUDE_CWD:-$(pwd)}" + +# Normalize home dir +CWD="${CWD/#\~/$HOME}" + +# Only fire when cwd is directly inside ~/repos/ (one level deep) +case "$CWD" in + "$HOME/repos/"*) + REPO_NAME="$(echo "$CWD" | sed -E "s|$HOME/repos/([^/]+).*|\1|")" + AGENTS_MD="$HOME/repos/$REPO_NAME/AGENTS.md" + ;; + "/Users/"*"/repos/"*) + REPO_NAME="$(echo "$CWD" | sed -E 's|/Users/[^/]+/repos/([^/]+).*|\1|')" + AGENTS_MD="$(echo "$CWD" | sed -E 's|(/Users/[^/]+/repos/[^/]+).*|\1|')/AGENTS.md" + ;; + *) + exit 0 + ;; +esac + +[ -z "$REPO_NAME" ] && exit 0 +[ ! -f "$AGENTS_MD" ] && exit 0 + +printf '=== SESSION START: AGENTS.md for %s ===\n' "$REPO_NAME" +cat "$AGENTS_MD" +printf '=== END AGENTS.md ===\n' + +exit 0 diff --git a/examples/hooks/aof-eval-opportunity-counter.sh b/examples/hooks/aof-eval-opportunity-counter.sh new file mode 100755 index 0000000..9b951df --- /dev/null +++ b/examples/hooks/aof-eval-opportunity-counter.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# aof-eval-opportunity-counter.sh — PreToolUse | SessionStart | UserPromptSubmit +# +# Required env vars (set in settings.json env or shell profile): +# AOF_EVAL_SUPABASE_URL — your smokin-ops (or equivalent) Supabase project URL +# AOF_EVAL_SUPABASE_KEY — anon key for the above project +# AOF_EVAL_HOOK_EVENT — PreToolUse | SessionStart | UserPromptSubmit (default: PreToolUse) +# +# Health check: eval.opportunities rows (NOT telemetry.hook_events fire_count). +# Register three hook_event entries in settings.json — one per event type above. +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/breadcrumb-lib.sh" 2>/dev/null || true +HOOK_EVENT="${AOF_EVAL_HOOK_EVENT:-PreToolUse}" +fail_open() { + printf '%s\t%s\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "aof-eval-opportunity-counter" "$1" \ + >> "${HOME}/.claude/migration-breadcrumbs/.errors.log" 2>/dev/null || true + exit 0 +} +SUPABASE_URL="${AOF_EVAL_SUPABASE_URL:-}" +SUPABASE_KEY="${AOF_EVAL_SUPABASE_KEY:-}" +MACHINE="$(uname -s | tr '[:upper:]' '[:lower:]' | sed 's/darwin/mac/;s/mingw.*/win11/;s/msys.*/win11/')" +insert_opportunity() { + local rule_id="$1" tool_name="$2" repo_cwd="${3:-}" + local repo_json="null" + [[ -n "$repo_cwd" ]] && repo_json="$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$repo_cwd")" + curl -sf -X POST "${SUPABASE_URL}/rest/v1/opportunities" \ + -H "apikey: ${SUPABASE_KEY}" -H "Authorization: Bearer ${SUPABASE_KEY}" \ + -H "Content-Type: application/json" -H "Accept-Profile: eval" -H "Content-Profile: eval" \ + -H "Prefer: return=minimal" \ + -d "{\"session_id\":\"${SESSION_ID}\",\"tool_name\":\"${tool_name}\",\"rule_id\":\"${rule_id}\",\"repo_cwd\":${repo_json},\"machine\":\"${MACHINE}\"}" \ + --max-time 2 2>/dev/null || printf '%s\taof-eval-opportunity-counter\tsupabase-insert-failed\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${HOME}/.claude/migration-breadcrumbs/.errors.log" 2>/dev/null || true +} +SESSION_ID="${CLAUDE_CODE_SESSION_ID:-}" +# Trim whitespace before emptiness check — whitespace-only values pass -z but cause silent insert failures +SUPABASE_URL="${SUPABASE_URL#"${SUPABASE_URL%%[![:space:]]*}"}" +SUPABASE_URL="${SUPABASE_URL%"${SUPABASE_URL##*[![:space:]]}"}" +SUPABASE_KEY="${SUPABASE_KEY#"${SUPABASE_KEY%%[![:space:]]*}"}" +SUPABASE_KEY="${SUPABASE_KEY%"${SUPABASE_KEY##*[![:space:]]}"}" +[[ -z "$SUPABASE_URL" ]] && fail_open "AOF_EVAL_SUPABASE_URL-unset" +[[ -z "$SUPABASE_KEY" ]] && fail_open "AOF_EVAL_SUPABASE_KEY-unset" +[[ -z "$SESSION_ID" ]] && fail_open "session-id-unset" +INPUT="$(cat)" +case "$HOOK_EVENT" in + SessionStart) + for rule_id in SS-1 SS-2 SS-3 SS-4 SS-5 SS-6 SS-7 SS-8 SS-9; do + insert_opportunity "$rule_id" "SessionStart" "" + done ;; + UserPromptSubmit) + for rule_id in "UPS-1" "UPS-2"; do + insert_opportunity "$rule_id" "UserPromptSubmit" "" + done ;; + PreToolUse) + TOOL_NAME="$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tool_name',''))" 2>/dev/null)" || fail_open "payload-parse-failed" + [[ -z "$TOOL_NAME" ]] && fail_open "tool-name-empty" + REPO_CWD="$(git -C "${PWD}" rev-parse --show-toplevel 2>/dev/null || echo "${PWD}")" + PTU_RULES=( + "PTU-1|^(Edit|Write|MultiEdit)$" "PTU-2|^(Edit|Write|MultiEdit)$" "PTU-3|^Write$" + "PTU-4|^(Edit|Write|mcp__.*__execute_sql|mcp__.*__apply_migration)$" + "PTU-5|^(Edit|Write|mcp__.*__execute_sql|mcp__.*__apply_migration)$" + "PTU-6|^mcp__mulesoft__search_asset$" "PTU-7|^mcp__mulesoft__(create|generate)" + "PTU-8|^(Bash|mcp__.*__execute_sql|mcp__.*__apply_migration|mcp__.*__deploy)" "PTU-9|^Bash$" + "PTU-10|^(Edit|Write|mcp__.*__execute_sql|mcp__.*__apply_migration)$" + "PTU-11|^(Read|mcp__.*__execute_sql|mcp__.*__list_tables)$" + "PTU-12|^mcp__claude_ai_Supabase__(execute_sql|apply_migration)$" + "PTU-15|^mcp__.*__apply_migration$" "PTU-16|^mcp__.*__apply_migration$" + "PTU-18|^mcp__hindsight__" "PTU-19|^(Edit|Write|Bash|MultiEdit)$" + "SEC-001|^Bash$" "ARC-001|^(Edit|Write)$" "ARC-002|^(Edit|Write)$" + ) + for entry in "${PTU_RULES[@]}"; do + RULE_ID="${entry%%|*}"; PATTERN="${entry#*|}" + echo "$TOOL_NAME" | grep -qE "$PATTERN" 2>/dev/null && insert_opportunity "$RULE_ID" "$TOOL_NAME" "$REPO_CWD" + done ;; + *) fail_open "unknown-hook-event:${HOOK_EVENT}" ;; +esac +exit 0 diff --git a/examples/hooks/claim-evidence-gate-dispatch.sh b/examples/hooks/claim-evidence-gate-dispatch.sh new file mode 100755 index 0000000..579f49d --- /dev/null +++ b/examples/hooks/claim-evidence-gate-dispatch.sh @@ -0,0 +1,169 @@ +#!/bin/bash +# CLAIM-EVIDENCE GATE DISPATCH — cross-platform front door for Gate 4. +# fail-mode: closed (delegates to a real gate; never silently skips) | blast-radius: Edit|Write|Bash +# +# WHY THIS EXISTS (incident 2026-06-13): the Go binary is per-architecture +# (Mach-O arm64 on Mac). Pointing settings.json directly at it disables the gate +# on any OS where that binary can't execute (e.g. Win11), failing OPEN — the +# worst failure mode for a security gate. This wrapper guarantees a runnable gate +# on EVERY platform: +# 1. Prefer the native binary at ~/.claude/hooks/claim-evidence-gate IF it +# exists AND is executable AND actually runs on this OS. +# 2. Otherwise fall back to the bash gate (claim-evidence-gate.sh) — same Gate-4 +# behavior, cross-platform, always present. +# The hook payload arrives on stdin and MUST be forwarded intact to whichever gate +# runs. We buffer stdin once and replay it so either path sees the same bytes. +# +# Exit codes are passed through verbatim (0 allow, 2 block) — the wrapper never +# rewrites a block into an allow. +set -euo pipefail +# Git Bash (Win11): this hook invokes a native .exe candidate. No filesystem path +# is passed as an argument (payload is stdin-only), but a Win11-targeted security +# hook ships the guard defensively. (bash-reviewer MED, 2026-06-13.) +export MSYS_NO_PATHCONV=1 + +HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BASH_GATE="${HOOK_DIR}/claim-evidence-gate.sh" +# Telemetry: session-end hook-telemetry-stop.sh counts bc_write breadcrumbs for +# claim-evidence-gate (floor name). Instrument here — the single choke point for +# Go binary + bash fallback — so fire_count is non-zero when the gate runs. +# shellcheck source=breadcrumb-lib.sh +source "${HOOK_DIR}/breadcrumb-lib.sh" 2>/dev/null || true +ceg_telemetry_finish() { + local rc=$? + if [ "$rc" -eq 2 ] && command -v bc_write >/dev/null 2>&1; then + bc_write "claim-evidence-gate-block" "$(date +%H:%M:%S)" 2>/dev/null || true + fi + if command -v bc_record_exit >/dev/null 2>&1; then + bc_record_exit "claim-evidence-gate" "$rc" 2>/dev/null || true + fi +} +trap ceg_telemetry_finish EXIT +# Normalize cross-runtime envelopes (Grok camelCase toolName/toolInput + tool-name literals +# like search_replace) to Claude Code snake_case BEFORE the gate runs. The Go binary and the +# bash floor both read snake_case tool_input; an un-normalized Grok payload makes the Go binary +# return 0 (no tool_input -> no claim found -> ALLOW), silently BYPASSING Gate 4 on every Grok +# write — a security regression, not just friction. (Grok interop batch 2026-06-15; ports SPG +# #407 pattern.) Fail-open on a missing lib: nh_normalize undefined -> keep raw payload. +source "${HOOK_DIR}/lib/normalize-hook-input.sh" 2>/dev/null || true + +# Candidate native-binary names. Windows (Git Bash) needs the .exe; POSIX uses the +# bare name. We probe each: the binary is only trusted if it exists, is executable, +# AND a no-op run actually succeeds on THIS OS — that probe is what catches a +# wrong-architecture binary that is present but cannot execute (the 2026-06-13 +# incident: a Mach-O arm64 binary sitting on a machine that can't run it). +BINARY_CANDIDATES=( + "${HOOK_DIR}/claim-evidence-gate" + "${HOOK_DIR}/claim-evidence-gate.exe" +) + +# Buffer stdin once so we can hand the exact same payload to whichever gate runs. +PAYLOAD="$(cat)" +if command -v bc_write >/dev/null 2>&1; then + bc_write "claim-evidence-gate-fire" "$(date +%H:%M:%S)" 2>/dev/null || true +fi + +# Normalize the buffered payload (Grok camelCase -> Claude snake_case). If the lib failed to +# source, nh_normalize is undefined -> keep PAYLOAD verbatim (fail-open, no regression for +# Claude's already-snake_case shape). nh_normalize fail-opens to its input on bad JSON or +# missing python3, so NORMALIZED is never empty for a non-empty PAYLOAD; the guard is +# belt-and-suspenders. +# Empty stdin -> fail CLOSED. A Gate-4 surface that cannot read its input must BLOCK, not +# allow. This is distinct from a valid envelope whose content field is empty (which the gate +# legitimately allows — nothing to assert). Empty stdin = "the gate saw nothing," which on a +# security surface means block-and-surface, NOT allow. (Grok interop batch 2026-06-15 — +# durable cross-layer posture: wrapper + bash floor + Go binary all fail closed on +# unevaluable input. Matches SPG/HWG empty-stdin posture.) +# Empty OR whitespace-only stdin -> fail CLOSED (Codex Point A 2026-06-15, MED #4). Trim +# leading/trailing whitespace before the emptiness test: a whitespace-only payload is +# unevaluable (no JSON envelope) and must block, not slip through to a gate that finds no +# tool_input and allows. Matches the Go binary's strings.TrimSpace(raw)=="" guard. +PAYLOAD_TRIMMED="${PAYLOAD#"${PAYLOAD%%[![:space:]]*}"}" # strip leading ws +PAYLOAD_TRIMMED="${PAYLOAD_TRIMMED%"${PAYLOAD_TRIMMED##*[![:space:]]}"}" # strip trailing ws +if [[ -z "$PAYLOAD_TRIMMED" ]]; then + echo "[gate4-BLOCK] claim-evidence-gate: empty/whitespace stdin — cannot evaluate. Failing CLOSED." >&2 + echo "[gate4-BLOCK] Source: AOF AGENT_FRAMEWORK.md §4 (Gate 4)" >&2 + printf '%s\tclaim-evidence-gate-dispatch\tempty-stdin-failed-closed-blocked\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + >> "${HOME}/.claude/migration-breadcrumbs/.errors.log" 2>/dev/null || true + exit 2 +fi + +if command -v nh_normalize >/dev/null 2>&1; then + NORMALIZED="$(nh_normalize "$PAYLOAD")" +else + NORMALIZED="$PAYLOAD" +fi +[[ -n "$NORMALIZED" ]] || NORMALIZED="$PAYLOAD" + +# Dual-shape conflict -> fail CLOSED (ports SPG #407 / Codex Point A 2026-06-15). nh_normalize +# emits this sentinel when a payload carries a field in BOTH shapes. Normalizing such a payload +# risks scan/execute divergence — the gate could scan the clean branch while the runtime writes +# the asserting branch. A Gate-4 surface must never allow that, so we BLOCK rather than guess. +if [[ "$NORMALIZED" == "__NH_CONFLICT__" ]]; then + echo "[gate4-BLOCK] claim-evidence-gate: ambiguous dual-shape payload (camelCase+snake_case key collision)." >&2 + echo "[gate4-BLOCK] Failing CLOSED — cannot guarantee the scanned value matches the executed value." >&2 + printf '%s\tclaim-evidence-gate-dispatch\tdual-shape-conflict-failed-closed-blocked\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + >> "${HOME}/.claude/migration-breadcrumbs/.errors.log" 2>/dev/null || true + exit 2 +fi + +# Two-probe trust check (Codex Point A 2026-06-15 MED #3 — mirrors SPG's multi-probe defense). +# A single clean-payload probe trusts a tampered allow-all binary (returns 0 on everything), +# and because a trusted binary suppresses the bash-floor backstop, that binary would wave every +# claim through. So the binary is trusted ONLY if it BOTH: +# - allows a CLEAN payload (exit 0), AND +# - blocks a known claim-shaped payload (exit 2). +# A binary that cannot execute returns 126/127 -> not runnable -> fall to the bash floor. +PROBE_CLEAN='{"tool_input":{"file_path":"/nonexistent/__ceg_probe__.txt","content":"probe ok"}}' +# Synthetic claim-shaped payload (assembled so this wrapper file does not itself trip Gate 4). +PROBE_CLAIM="$(printf '{"tool_input":{"content":"the probe %s"}}' 'is wired')" + +probe_rc() { local p="$1" b="$2" rc=0; printf '%s' "$p" | "$b" >/dev/null 2>&1 || rc=$?; printf '%s' "$rc"; } + +find_runnable_binary() { + local cand + for cand in "${BINARY_CANDIDATES[@]}"; do + [ -x "$cand" ] || continue + [ "$(probe_rc "$PROBE_CLEAN" "$cand")" = "0" ] || continue # must allow clean + [ "$(probe_rc "$PROBE_CLAIM" "$cand")" = "2" ] || continue # must block a known claim + printf '%s' "$cand" + return 0 + done + return 1 +} + +if BIN="$(find_runnable_binary)"; then + # `|| rc=$?` suspends set -e for this pipe so the gate's exit status (2 = a legitimate + # BLOCK, not a script error) is captured and propagated rather than aborting the wrapper at + # the pipe. Without the `||`, errexit+pipefail aborts AT the pipe on a non-zero gate exit and + # the `rc=$?`/`exit "$rc"` lines become dead code (bash-reviewer MED, 2026-06-15 — matches + # the HWG form). Today the surfaced code is accidentally correct (errexit propagates the + # pipe's status), but any future line before the exit would silently not run on the BLOCK path. + rc=0 + printf '%s' "$NORMALIZED" | "$BIN" || rc=$? + exit "${rc:-0}" # propagate gate verdict verbatim (0 allow, 2 block) +fi + +# Fallback: the bash gate is the cross-platform floor — same Gate-4 behavior on +# any OS without a runnable native binary. +if [ -f "$BASH_GATE" ]; then + # Same `|| rc=$?` pattern as the binary path — capture+propagate the bash gate's verdict + # instead of letting errexit abort at the pipe (bash-reviewer MED, 2026-06-15). + rc=0 + printf '%s' "$NORMALIZED" | bash "$BASH_GATE" || rc=$? + exit "${rc:-0}" +fi + +# No gate available at all (broken install: no runnable binary AND no bash gate). +# fail-mode: CLOSED. A security gate that cannot run must BLOCK, not allow — a +# silent allow here would reproduce the exact 2026-06-13 incident this wrapper +# exists to prevent (a Gate-4 surface failing open). Block loudly and record it. +# (bash-reviewer HIGH, 2026-06-13.) +echo "[gate4-BLOCK] claim-evidence-gate: NO runnable gate found (no native binary, no claim-evidence-gate.sh)." >&2 +echo "[gate4-BLOCK] Failing CLOSED — blocking this tool call. Re-run the installer to restore the gate." >&2 +printf '%s\tclaim-evidence-gate-dispatch\tNO-GATE-AVAILABLE-failed-closed-blocked\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + >> "${HOME}/.claude/migration-breadcrumbs/.errors.log" 2>/dev/null || true +exit 2 diff --git a/examples/hooks/claim-evidence-gate.sh b/examples/hooks/claim-evidence-gate.sh new file mode 100755 index 0000000..6f0d6e4 --- /dev/null +++ b/examples/hooks/claim-evidence-gate.sh @@ -0,0 +1,209 @@ +#!/bin/bash +# CLAIM-EVIDENCE GATE — Gate 4 of read-before-acting.md +# fail-mode: closed (blocking) | blast-radius: blocks Edit|Write|Bash with assertion language +# PreToolUse hook on Edit|Write|Bash. Exits 2 to block when assertion language detected. +# +# Scans tool input for assertion-language keywords ("is wired", "is complete", +# "fully integrated", "the data shows", "directory contains only", "confirmed", +# "verified", "is done", "functionality is"). When a hit fires, blocks the write. +# +# Phase B (2026-06-12): explicit "VERIFIED against ``" claims require a +# matching READ breadcrumb for that path in the current session (T4 fabrication +# guard — bloomberg-terminal incident 2026-05-27). +# +# Pattern matches Gate 4 enforcement design in ~/.claude/rules/read-before-acting.md +# Session read log: /tmp/claude-hooks-${USER}/$(bc_session_key)-reads-log +# populated by no-guess-breadcrumb.sh on Read calls. +# +# Companion: ~/.claude/hooks/no-guess-gate.sh (Gate 3, blocking, infra mutations) +# Source rule: AOF AGENT_FRAMEWORK.md §4 (Gate 4) + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=breadcrumb-lib.sh +source "$SCRIPT_DIR/breadcrumb-lib.sh" 2>/dev/null || true + +INPUT=$(cat) + +# Empty stdin -> fail CLOSED. A Gate-4 surface that cannot read its input at all must BLOCK, +# not allow. This is distinct from a valid envelope whose content field is empty (handled at +# the TEXT-empty check below, which legitimately allows — nothing to assert). Empty stdin means +# the gate saw nothing, which on a security surface is block-and-surface. (Grok interop batch +# 2026-06-15 — durable cross-layer posture: dispatch wrapper + this bash floor + the Go binary +# all fail closed on unevaluable input. Matches SPG/HWG.) +INPUT_TRIMMED="${INPUT#"${INPUT%%[![:space:]]*}"}" +INPUT_TRIMMED="${INPUT_TRIMMED%"${INPUT_TRIMMED##*[![:space:]]}"}" +if [ -z "$INPUT_TRIMMED" ]; then + echo "[gate4-BLOCK] claim-evidence-gate: empty/whitespace stdin — cannot evaluate. Failing CLOSED." >&2 + echo "[gate4-BLOCK] Source: AOF AGENT_FRAMEWORK.md §4 (Gate 4)" >&2 + printf '%s\tclaim-evidence-gate\tempty-stdin-failed-closed-blocked\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${HOME}/.claude/migration-breadcrumbs/.errors.log" 2>/dev/null || true + exit 2 +fi + +# Path normalizer (hoisted above its first use — the allowlist below + the +# reads-log matcher both call it). Backslash->slash and /c/ -> C:/ for Win11. +ceg_normalize_path() { + echo "$1" | tr '\\' '/' | sed -E 's#^/([a-zA-Z])/#\1:/#' +} + +# Skip .go source files — regex string literals in Go source legitimately +# contain assertion words (confirmed, verified, etc.) that are not Gate 4 claims. +FILE_PATH=$(echo "$INPUT" | grep -oE '"(file_path|path)"[[:space:]]*:[[:space:]]*"([^"]+\.go)"' | head -1 | grep -oE '"[^"]+\.go"' | tr -d '"' || true) +if [ -n "$FILE_PATH" ]; then + exit 0 +fi + +# Path-allowlist (2026-06-16 — Gate-4 PreToolUse regression fix). Mirrors the +# SPG/HWG dual-layer allowlist: files that legitimately QUOTE assertion phrases +# while documenting or implementing the gate must not be blocked when the gate is +# wired blocking under PreToolUse. Without this, CEG self-blocks edits to its own +# source + the rule files that cite its banned tokens (reproduced 2026-06-16). +# Root-anchored on a normalized path so a crafted /tmp/.claude/rules/... cannot +# exempt. Any tool_input path (file_path OR path) is extracted and normalized via +# the same ceg_normalize_path used for the reads-log, then matched against the set. +ALLOW_PATH=$(echo "$INPUT" | grep -oE '"(file_path|path)"[[:space:]]*:[[:space:]]*"([^"]+)"' | head -1 | sed -E 's/^"(file_path|path)"[[:space:]]*:[[:space:]]*"(.*)"$/\2/' || true) +if [ -n "$ALLOW_PATH" ]; then + NORM_ALLOW=$(ceg_normalize_path "$ALLOW_PATH") + HOME_NORM=$(ceg_normalize_path "$HOME") + # Root-anchored exempt patterns (both installed ~/.claude/ and repo claude-config forms). + CEG_ALLOWLIST=( + "^${HOME_NORM}/\.claude/hooks/claim-evidence-gate(\.sh|-dispatch\.sh)?$" + "^${HOME_NORM}/repos/claude-config/hooks/claim-evidence-gate(\.sh|-dispatch\.sh)?$" + "^${HOME_NORM}/repos/agent-operating-framework/examples/hooks/claim-evidence-gate(\.sh|-dispatch\.sh)?$" + "^${HOME_NORM}/\.claude/law/[^/]+\.md$" + "^${HOME_NORM}/repos/claude-config/law/[^/]+\.md$" + ) + for pat in "${CEG_ALLOWLIST[@]}"; do + if echo "$NORM_ALLOW" | grep -qE "$pat"; then + printf '%s\tclaim-evidence-gate\tpath-allowlisted-allow\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${HOME}/.claude/migration-breadcrumbs/.errors.log" 2>/dev/null || true + exit 0 + fi + done +fi + +# Extract candidate text from tool input. Different tools store the +# user-authored payload in different fields: +# Write → tool_input.content +# Edit → tool_input.new_string +# Bash → tool_input.command +TEXT="" +for FIELD in "content" "new_string" "command"; do + CHUNK=$(echo "$INPUT" | grep -oE "\"$FIELD\"[[:space:]]*:[[:space:]]*\"([^\"\\\\]|\\\\.)*\"" | head -1 | sed -E "s/^\"$FIELD\"[[:space:]]*:[[:space:]]*\"(.*)\"$/\\1/") + if [ -n "$CHUNK" ]; then + TEXT="$TEXT $CHUNK" + fi +done + +# Bail if nothing to scan +if [ -z "$TEXT" ]; then + printf '%s\tclaim-evidence-gate\tTEXT-empty\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${HOME}/.claude/migration-breadcrumbs/.errors.log" 2>/dev/null || true + exit 0 +fi + +ceg_path_was_read() { + local claim_path="$1" + local reads_log + reads_log="$(bc_dir)/$(bc_session_key)-reads-log" + [ -f "$reads_log" ] || return 1 + + local norm_claim + norm_claim=$(ceg_normalize_path "$claim_path") + # (ceg_normalize_path is defined near the top of the script — see hoisted def.) + + while IFS= read -r line; do + case "$line" in + *" READ "*) ;; + *) continue ;; + esac + local read_path="${line#* READ }" + local norm_read + norm_read=$(ceg_normalize_path "$read_path") + if [ "$norm_read" = "$norm_claim" ]; then + return 0 + fi + if echo "$norm_read" | grep -qiF "$norm_claim"; then + return 0 + fi + if echo "$norm_claim" | grep -qiF "$norm_read"; then + return 0 + fi + done < "$reads_log" + return 1 +} + +ceg_path_exists() { + local p="$1" + [ -f "$p" ] && return 0 + local msys + msys=$(echo "$p" | sed -E 's#^([A-Za-z]):#/\L\1#' | tr '\\' '/') + [ -n "$msys" ] && [ -f "$msys" ] && return 0 + return 1 +} + +ceg_block() { + local reason="$1" + local detail="$2" + cat >&2 <> "${HOME}/.claude/migration-breadcrumbs/.errors.log" 2>/dev/null || true + exit 2 +} + +# Phase B: block explicit verification claims that cite a path without a Read breadcrumb. +VERIFY_PATHS="" +while IFS= read -r hit; do + [ -z "$hit" ] && continue + path=$(echo "$hit" | sed -E 's/.*`([^`]+)`.*/\1/') + [ -n "$path" ] && VERIFY_PATHS="${VERIFY_PATHS}${path}"$'\n' +done < <(echo "$TEXT" | grep -oiE '(verified|confirmed|dispatched) against (real file )?`[^`]+`' || true) + +while IFS= read -r CLAIM_PATH; do + [ -z "$CLAIM_PATH" ] && continue + if ceg_path_was_read "$CLAIM_PATH"; then + continue + fi + if ! ceg_path_exists "$CLAIM_PATH"; then + ceg_block "Verification claim cites missing path: ${CLAIM_PATH}" "No session Read breadcrumb and file not found on disk." + fi + ceg_block "Verification claim without session Read: ${CLAIM_PATH}" "Read the file via the Read tool before writing VERIFIED/Confirmed against this path." +done < <(printf '%s' "$VERIFY_PATHS" | sort -u) + +# All explicit verification paths were read this session — allow the write. +if printf '%s' "$VERIFY_PATHS" | grep -qE '.+'; then + exit 0 +fi + +# Assertion keywords — phrases that claim a verified/wired/done state. +# ALIGNED 2026-06-15 to the Go binary's assertionPatterns (internal/gate/patterns.go), +# which was softened by ADR 0064 (2026-06-13) after a 59-fire audit showed 58/59 fires were +# false positives — bare "confirmed"/"verified", the prose word "end-to-end", and Codex +# verdict labels (SHIP-WITH-HEDGES, 0H/2M/1L) appearing as ordinary words, not claims. The +# bash floor had NOT received that softening and so over-blocked relative to the binary — the +# exact binary/floor divergence the Grok cross-layer batch exists to eliminate. The dangerous +# bare form ("verified against ``" without a Read) is still caught by the VERIFY_PATHS +# block above (verifiedPattern + reads-log check), mirroring gate.go. Keep ONLY claim-shaped +# forms; this list now matches patterns.go one-for-one. +PATTERNS='(\bis wired\b|\bis complete\b|\bis done\b|\bfully integrated\b|\bfully wired\b|\bthe data shows\b|\bdirectory contains only\b|\bcontains only\b|\bhas been (confirmed|verified)\b|\bis (confirmed|verified)\b|\bwas (confirmed|verified)\b|\bfunctionality is\b|\bhas a bug\b|\bthe bug is\b|\bbroken because\b|\bthe gate checks\b|\bthe function does\b)' + +MATCH=$(echo "$TEXT" | grep -oiE "$PATTERNS" | head -3 | tr '\n' '|' | sed 's/|$//') + +if [ -z "$MATCH" ]; then + exit 0 +fi + +cat >&2 <> "${HOME}/.claude/migration-breadcrumbs/.errors.log" 2>/dev/null || true + +exit 2 \ No newline at end of file diff --git a/examples/hooks/lib/normalize-hook-input.sh b/examples/hooks/lib/normalize-hook-input.sh index bc9c36d..be48a60 100644 --- a/examples/hooks/lib/normalize-hook-input.sh +++ b/examples/hooks/lib/normalize-hook-input.sh @@ -1,25 +1,17 @@ #!/usr/bin/env bash -# fail-mode: open -# blast-radius: advisory -# ============================================================================= -# normalize-hook-input.sh — Cross-runtime hook payload normalization library +# normalize-hook-input.sh — Shared hook payload normalization library # # SOURCE with: # source "$(dirname "${BASH_SOURCE[0]}")/lib/normalize-hook-input.sh" 2>/dev/null || true # -# Problem: Claude Code hooks receive JSON payloads in snake_case with -# "Bash"/"Edit"/"Write" tool-name literals. Other runtimes (Grok and future -# agents) emit camelCase payloads with different tool-name literals: +# Purpose: Claude Code hooks receive JSON payloads in Claude Code's snake_case +# shape. Other runtimes (Grok, future agents) emit camelCase shapes with +# different tool-name literals. This library normalizes the payload so every +# hook that sources it works regardless of which runtime fired it. # -# Claude Code: {"tool_name": "Bash", "tool_input": {...}} -# Grok: {"toolName": "run_terminal_cmd", "toolInput": {...}} +# What it normalizes: # -# Without normalization, a hook that checks `tool_name == "Bash"` silently -# bypasses on Grok — the check never fires, the gate never blocks. -# -# What this library normalizes: -# -# Field names (camelCase → snake_case): +# Field names: # toolName → tool_name # toolInput → tool_input # toolResult → tool_result @@ -30,38 +22,53 @@ # search_replace → Edit # create_file → Write # read_file → Read -# grep_search → Bash +# grep_search → Bash (grep is invoked via Bash in CC) # list_dir → Bash # -# What this library does NOT normalize: -# - tool_input field structure (command, file_path, etc.) — hooks handle these -# - MCP tool names (mcp__*) — namespaced, no conflicts +# tool_input field aliases (Grok Write shape → Claude Code canonical), each with a +# scan/execute-divergence CONFLICT guard (both-present-but-differ → fail closed): +# tool_input.path → tool_input.file_path +# tool_input.contents → tool_input.content +# +# What it does NOT normalize: +# - tool_input field structure (command, file_path, etc.) — those are +# runtime-specific and each hook handles them already. +# - MCP tool names — these are namespaced (mcp__*) and don't conflict. # # Usage: # 1. Source this file near the top of your hook, after breadcrumb-lib.sh. -# 2. Read raw stdin into a variable (do not consume stdin twice). +# 2. Read raw stdin into a variable. # 3. Call nh_normalize "$RAW_INPUT" to get normalized JSON on stdout. -# 4. Pass the normalized JSON to your existing parser. +# 4. Use the normalized JSON with your existing Python parser. # # Example: -# source "$(dirname "${BASH_SOURCE[0]}")/lib/normalize-hook-input.sh" 2>/dev/null || true +# source "$SCRIPT_DIR/lib/normalize-hook-input.sh" 2>/dev/null || true # RAW=$(cat) # INPUT=$(nh_normalize "$RAW") -# TOOL_NAME=$(echo "$INPUT" | python3 -c "import json,sys; print(json.load(sys.stdin).get('tool_name',''))") -# -# Fail-open guarantee: if python3 is missing or JSON parse fails, nh_normalize -# returns the original input unchanged. Every hook that sources this library -# degrades to Claude Code-only behavior — the pre-normalization status quo. -# No regression. +# # ... your existing Python parser reads $INPUT ... # -# Library exit semantics: none — this file provides functions only. +# Exit semantics of this library: none — it provides functions only. # The calling hook owns all exit codes. # -# Hook type: library (source, do not execute directly) -# ============================================================================= - -# Do NOT set -euo pipefail in a sourced library — it affects the calling hook's -# errexit behavior. Let the caller control error handling. +# Fail-open: if python3 is missing or the JSON parse fails, nh_normalize +# returns the original input unchanged. Hooks degrade to Claude Code-only +# behavior, which is the pre-normalization status quo — no regression. +# +# Design: smokin-os/spec/hooks-memory-skills-design.md §3.2 (Grok shape mismatch) +# Track 1.1 done criterion: this file exists + top-offender hooks source it +# +# Bash hard rules (smokin-knowledge/bash/AGENTS.md): +# Rule 1: set -euo pipefail — applied at the CALLING hook level; this library +# intentionally does NOT call set -euo pipefail (sourced files that set +# strict mode can change the calling hook's errexit behavior on source +# errors — the || true on the source line is the caller's safety valve). +# Rule 2: MSYS_NO_PATHCONV not needed (no native Win tools called) +# Rule 3: No git pager calls +# Rule 4: No state mutations — this library is read-only +# Rule 5: No destructive commands +# +# Self-allowlist: hooks that source this file should add its basename to their +# own self-allowlist (if they have one) to prevent self-blocking. # nh_normalize # @@ -74,17 +81,19 @@ nh_normalize() { return fi + # Python normalization: rename camelCase fields, remap tool-name literals. local result result=$(printf '%s' "$raw" | python3 -c ' import json, sys TOOL_NAME_MAP = { - "run_terminal_cmd": "Bash", - "search_replace": "Edit", - "create_file": "Write", - "read_file": "Read", - "grep_search": "Bash", - "list_dir": "Bash", + "run_terminal_cmd": "Bash", + "run_terminal_command": "Bash", + "search_replace": "Edit", + "create_file": "Write", + "read_file": "Read", + "grep_search": "Bash", + "list_dir": "Bash", } FIELD_MAP = { @@ -94,22 +103,75 @@ FIELD_MAP = { "sessionId": "session_id", } +# CONFLICT sentinel (Codex Point A 2026-06-15, 2 HIGH bypass fixes). When a payload +# carries a field in BOTH shapes (e.g. camelCase toolInput AND snake_case tool_input, or +# tool_input.path AND tool_input.file_path with DIFFERENT values), normalization cannot +# pick a winner without risking scan/execute divergence: the gate could scan the clean +# branch while the runtime writes the secret branch. A security gate must NEVER let the +# scanned value differ from the executed value, so on conflict we emit a fixed sentinel and +# the calling SECURITY hook fails CLOSED. Non-security callers that ignore the sentinel get +# a non-JSON string and degrade safely (their JSON parse fails -> their own fail path). +CONFLICT = "__NH_CONFLICT__" + try: d = json.loads(sys.stdin.read()) except Exception: - sys.exit(0) # fail-open: caller receives empty, returns original + # Not valid JSON — emit empty to signal parse failure; caller fail-opens to raw. + sys.exit(0) + +if not isinstance(d, dict): + # Top-level must be an object envelope; anything else is not a tool call we normalize. + sys.exit(0) + +# Finding 1 — dual envelope key collision. If any camelCase field AND its snake_case +# target are both present at the top level, last-write-wins would be attacker-controllable. +# Fail closed. +for cc_key, sc_key in FIELD_MAP.items(): + if cc_key in d and sc_key in d: + print(CONFLICT, end="") + sys.exit(0) +# Rename camelCase fields to snake_case (only top-level envelope fields). normalized = {} for k, v in d.items(): - normalized[FIELD_MAP.get(k, k)] = v + mapped_key = FIELD_MAP.get(k, k) + normalized[mapped_key] = v +# Remap tool-name literals. if "tool_name" in normalized: normalized["tool_name"] = TOOL_NAME_MAP.get(normalized["tool_name"], normalized["tool_name"]) +# Alias tool_input.path -> file_path (some Grok shapes use "path"). Three cases: +# - only path present -> alias path into file_path (the intended normalization) +# - both present, SAME value -> harmless, leave as-is +# - both present, DIFFER -> Finding 2: scan/execute divergence -> fail closed +# Non-dict tool_input is left untouched. +ti = normalized.get("tool_input") +if isinstance(ti, dict) and "path" in ti: + if "file_path" not in ti: + ti["file_path"] = ti["path"] + elif ti["file_path"] != ti["path"]: + print(CONFLICT, end="") + sys.exit(0) + +# Alias tool_input.contents -> content (some Grok Write shapes use "contents"). Same three cases +# as path/file_path above: +# - only contents present -> alias contents into content (the intended normalization) +# - both present, SAME value -> harmless, leave as-is +# - both present, DIFFER -> scan/execute divergence -> fail closed (CONFLICT sentinel) +# Non-dict tool_input is left untouched (ti is re-checked because the path branch may have run). +if isinstance(ti, dict) and "contents" in ti: + if "content" not in ti: + ti["content"] = ti["contents"] + elif ti["content"] != ti["contents"]: + print(CONFLICT, end="") + sys.exit(0) + print(json.dumps(normalized), end="") ' 2>/dev/null) || result="" if [ -z "$result" ]; then + # Normalization failed — return original input unchanged (fail-open). printf '%s' "$raw" else printf '%s' "$result" @@ -119,11 +181,7 @@ print(json.dumps(normalized), end="") # nh_tool_name # # Convenience: extract the normalized tool_name without a full normalize pass. -# Useful for quick dispatch checks in PreToolUse hooks. -# -# Example: -# tool=$(nh_tool_name "$RAW") -# [[ "$tool" == "Bash" ]] && echo "bash call" +# Useful for quick dispatch checks (e.g., "is this a Bash call?"). nh_tool_name() { local raw="${1:-}" [ -z "$raw" ] && { printf ''; return; } diff --git a/examples/hooks/three-failure-stop-gate.sh b/examples/hooks/three-failure-stop-gate.sh new file mode 100755 index 0000000..4d529a3 --- /dev/null +++ b/examples/hooks/three-failure-stop-gate.sh @@ -0,0 +1,208 @@ +#!/bin/bash +# THREE-FAILURE-STOP GATE — PreToolUse hook for Bash +# Targets: AOF v1.6 — DP2 + DP3 (Three-Failure-Stop discipline). +# Source: AOF AGENT_FRAMEWORK.md §4 (Three-Failure-Stop). +# Postmortem: 2026-05-15 kb_mcp_server saga (8 fix(...) commits in 2hr). +# +# How it works: +# 1. PreToolUse fires on every Bash call. +# 2. If the command is `git commit` with message matching ^fix\(...\): +# append timestamp to ~/.claude/state/three-failure-stop/__.log +# then count entries in the last 2hr. +# 3. If count >= 4 AND commit body does NOT contain +# "# halted-and-researched: ", block with stderr explaining how +# to override. +# +# State files are append-only timestamps, one per line. Counter uses awk to +# filter ts > now - 7200. Append happens only AFTER the gate decides to allow +# (F4 fix Codex 2026-05-15) — blocked invocations no longer inflate the counter. +# +# KNOWN GAPS (v1.6 — addressed in v1.7): +# F3: The fix-trigger regex `fix\([^)]*\):` matches anywhere in the command +# string, including directory names. `cd /repo/fix(mcp)/bar && commit` +# could trip the counter on a non-fix commit. Contrived in practice; +# restage if false-positive observed. +# F6: `git commit -F msg.txt`, `git commit --file=msg.txt`, `git commit -F -`, +# and `EDITOR=true git commit` bypass this gate. Counter relies on inline +# -m text; file/editor-based message paths are invisible. v1.7 PostToolUse +# companion will re-check via `git log -1 --format=%B`. +# F9: `git --git-dir=X --work-tree=Y commit` form is not parsed by the repo +# resolver. Falls through to Priority 3/4 cwd, then fail-open with +# advisory warn (this gate is advisory by design — false-positives more +# disruptive than false-negatives). + +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/breadcrumb-lib.sh" 2>/dev/null || true +trap 'bc_record_exit "three-failure-stop-gate" "$?"' EXIT + +STATE_ROOT="${HOME}/.claude/state/three-failure-stop" +WINDOW_SECONDS=7200 # 2 hours +THRESHOLD=4 # 4th fix(...) in window triggers the block + +INPUT=$(cat) + +# Normalize camelCase (Grok) → snake_case (Claude Code) so Grok-shaped git commit +# payloads (toolInput instead of tool_input) are not silently bypassed. +source "$SCRIPT_DIR/lib/normalize-hook-input.sh" 2>/dev/null || true +if command -v nh_normalize >/dev/null 2>&1; then + _NORM="$(nh_normalize "$INPUT")" + # __NH_CONFLICT__: dual-shape payload — fail open (advisory gate; false-positives + # more disruptive than false-negatives, consistent with gate's existing posture). + [ "$_NORM" != "__NH_CONFLICT__" ] && INPUT="$_NORM" + unset _NORM +fi + +# Extract tool_input.command via Python — grep+sed truncates at escaped quotes +# inside commit messages. Python is the cleanest dep-free fix on Win11. +COMMAND=$(printf '%s' "$INPUT" | python3 -c ' +import json, sys +try: + d = json.load(sys.stdin) + print(d.get("tool_input", {}).get("command", ""), end="") +except Exception: + pass +' 2>/dev/null) + +if [ -z "$COMMAND" ]; then + printf '%s\tthree-failure-stop-gate\tcommand-parse-empty\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${HOME}/.claude/migration-breadcrumbs/.errors.log" 2>/dev/null || true + exit 0 +fi + +if [ "${CLAUDE_HOOKS_SAFE_MODE:-0}" = "1" ]; then + echo "[three-failure-stop-gate] SAFE_MODE active — bypassing gate" >&2 + printf '%s\tthree-failure-stop-gate\tSAFE_MODE-bypass\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${HOME}/.claude/migration-breadcrumbs/.errors.log" 2>/dev/null || true + exit 0 +fi + +bc_write "three-failure-stop-gate-fire" "$(date +%H:%M:%S)" + +# --------------------------------------------------------------------------- +# parse_git_commit_args — structural parser (Step B, Codex 2026-05-15). +# Sets GC_IS_COMMIT, GC_REPO_HINT, GC_MSG_BODY, GC_MSG_FILE, GC_HAS_ATTEST +# from $COMMAND and $INPUT. +# --------------------------------------------------------------------------- +parse_git_commit_args() { + GC_IS_COMMIT="0"; GC_REPO_HINT=""; GC_MSG_BODY=""; GC_MSG_FILE=""; GC_HAS_ATTEST="0" + + if echo "$COMMAND" | grep -qE 'git( -C ("[^"]+"|[^ ]+))? commit'; then + GC_IS_COMMIT="1" + fi + + local GITC CDARG CC_CWD + GITC=$(echo "$COMMAND" | grep -oE 'git -C ("[^"]+"|[^ ]+)' | head -1 \ + | sed -E 's/^git -C //; s/^"(.*)"$/\1/') + if [ -n "$GITC" ]; then + GC_REPO_HINT="$GITC" + else + CDARG=$(echo "$COMMAND" | grep -oE '(^|[;&(]|&&|\|\|)[[:space:]]*cd[[:space:]]+("[^"]+"|[^ ;&|()]+)' \ + | tail -1 | sed -E 's/.*cd[[:space:]]+//; s/^"(.*)"$/\1/') + if [ -n "$CDARG" ] && [ "$CDARG" != "-" ]; then + GC_REPO_HINT="$CDARG" + fi + fi + if [ -z "$GC_REPO_HINT" ]; then + CC_CWD=$(printf '%s' "$INPUT" | python3 -c ' +import json, sys +try: + d = json.load(sys.stdin) + print(d.get("cwd", ""), end="") +except Exception: + pass +' 2>/dev/null) + [ -n "$CC_CWD" ] && GC_REPO_HINT="$CC_CWD" + fi + [ -z "$GC_REPO_HINT" ] && GC_REPO_HINT="$PWD" + + GC_MSG_BODY=$(echo "$COMMAND" | grep -oE -- '(-m[[:space:]]+|--message[[:space:]]+|--message=)("[^"]*"|[^ ]+)' | head -1 \ + | sed -E 's/^(-m[[:space:]]+|--message[[:space:]]+|--message=)//; s/^"(.*)"$/\1/') + + GC_MSG_FILE=$(echo "$COMMAND" | grep -oE -- '(-F[[:space:]]+|--file=|--file[[:space:]]+)("[^"]*"|[^ ]+)' | head -1 \ + | sed -E 's/^(-F[[:space:]]+|--file=|--file[[:space:]]+)//; s/^"(.*)"$/\1/') + + if [ -n "$GC_MSG_BODY" ] && echo "$GC_MSG_BODY" | grep -qE '# halted-and-researched:[[:space:]]+[^[:space:]]+'; then + GC_HAS_ATTEST="1" + fi +} + +parse_git_commit_args +[ "$GC_IS_COMMIT" = "0" ] && exit 0 + +if [ -z "$GC_MSG_BODY" ]; then + if [ -n "$GC_MSG_FILE" ]; then + echo "[three-failure-stop-gate-info] -F/--file= form detected ($GC_MSG_FILE) — gate skipped (F6 documented bypass)" >&2 + fi + exit 0 +fi + +if ! echo "$GC_MSG_BODY" | grep -qE 'fix\([^)]*\):'; then + exit 0 +fi + +REPO_ROOT=$(git -C "$GC_REPO_HINT" rev-parse --show-toplevel 2>/dev/null) +if [ -z "$REPO_ROOT" ]; then + echo "[three-failure-stop-gate-warn] could not resolve target repo — gate disabled. If you've had 3+ recent fix(...) commits and haven't researched, halt and add '# halted-and-researched: ' to the commit body anyway." >&2 + printf '%s\tthree-failure-stop-gate\trepo-resolve-failed\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${HOME}/.claude/migration-breadcrumbs/.errors.log" 2>/dev/null || true + exit 0 +fi +REPO=$(basename "$REPO_ROOT") + +STAGED=$(git -C "$REPO_ROOT" diff --cached --name-only 2>/dev/null | head -1) +if [ -z "$STAGED" ]; then + FILE="__unknown__" +else + FILE=$(echo "$STAGED" | tr '/' '_') +fi + +if [ ! -d "$STATE_ROOT" ]; then + mkdir -p "$STATE_ROOT" 2>/dev/null || { + echo "[three-failure-stop-gate-warn] could not create $STATE_ROOT; gate disabled" >&2 + exit 0 + } +fi + +LOG="$STATE_ROOT/${REPO}__${FILE}.log" +NOW=$(date +%s) +CUTOFF=$((NOW - WINDOW_SECONDS)) + +PRE_COUNT=$(awk -v cutoff="$CUTOFF" '$1 > cutoff' "$LOG" 2>/dev/null | wc -l | tr -d ' ') +WOULD_BE=$((PRE_COUNT + 1)) + +if [ "$WOULD_BE" -lt "$THRESHOLD" ]; then + echo "$NOW" >> "$LOG" + exit 0 +fi + +if [ "$GC_HAS_ATTEST" = "1" ]; then + echo "[three-failure-stop-gate] would-be=#$WOULD_BE — attestation present, allowing" >&2 + echo "$NOW" >> "$LOG" + exit 0 +fi + +cat >&2 < + +Example: + git commit -m "\$(cat <<'EOF' + fix(mcp): load reranker on main thread + + # halted-and-researched: traced FastMCP worker dispatch + torch DLL init thread-safety + EOF + )" +BLOCK + +bc_write "three-failure-stop-gate-block" "$(date +%H:%M:%S)" +exit 2 diff --git a/guides/advanced/go-hook-dispatch-pattern.md b/guides/advanced/go-hook-dispatch-pattern.md new file mode 100644 index 0000000..26448ab --- /dev/null +++ b/guides/advanced/go-hook-dispatch-pattern.md @@ -0,0 +1,118 @@ +# Go Hook Dispatch Pattern + +> Ship a Go binary for performance. Ship a bash floor for portability. Always ship both. + +Some AOF hooks implement their gate logic in Go — compiled to a native binary for sub-millisecond execution. The Go binary is architecture-specific: a Mach-O arm64 binary cannot run on Windows or x86 Linux. Shipping a binary without a fallback means the gate fails open (or errors) on every machine where the binary cannot execute. That is a security regression. + +The dispatch pattern solves this with a three-layer stack: + +``` +claim-evidence-gate-dispatch.sh ← settings.json points here (always runs) + ↓ probes binary ← uses it if runnable on THIS OS +claim-evidence-gate (binary) ← fast path: Go, architecture-specific + ↓ fallback +claim-evidence-gate.sh ← bash floor: same gate logic, cross-platform +``` + +--- + +## Why Not Point settings.json Directly at the Binary? + +The 2026-06-13 incident: a Mach-O arm64 binary was deployed. `settings.json` pointed directly at it. On Win11, the binary could not execute. The harness treated the non-zero exit as a non-block (the fail-open default for execution errors). Gate 4 was silently disabled on Windows for the entire deployment period. + +The dispatch wrapper exists to prevent this exact failure mode. + +--- + +## The Two-Probe Trust Model + +A naive dispatcher checks `[ -x "$binary" ]` and runs it if executable. This trusts a tampered binary that returns 0 on everything. Because the binary suppresses the bash fallback when trusted, a compromised allow-all binary would wave every claim through. + +The AOF dispatch wrapper uses two probes before trusting the binary: + +```bash +PROBE_CLEAN='{"tool_input":{"file_path":"/nonexistent/__probe__.txt","content":"probe ok"}}' +PROBE_CLAIM='...' # synthetic payload that looks like a claim + +# Binary is trusted only if it BOTH: +# - allows a clean payload (exit 0) +# - blocks a claim-shaped payload (exit 2) +``` + +A binary that fails either probe falls through to the bash floor. + +--- + +## Building a Go Hook + +### Structure + +``` +hooks/ + myhook-gate/ ← Go module root + main.go + go.mod + myhook-gate.sh ← bash floor (identical gate logic) + myhook-gate-dispatch.sh ← dispatch wrapper (points to both) +``` + +### Build at install time + +The binary must be built from source on the target machine, not cross-compiled and committed. This ensures the architecture matches. + +```bash +# In your install script (make install / install.bat equivalent): +cd hooks/myhook-gate +go build -o ../myhook-gate . +chmod +x ../myhook-gate +``` + +The binary is gitignored (architecture-specific). The source is committed. Any machine with Go installed can build it. Machines without Go fall through to the bash floor automatically. + +```gitignore +# hooks/.gitignore +myhook-gate +myhook-gate.exe +``` + +### Probe shapes + +Your dispatch wrapper must construct probes that match your gate's allow/block logic. The probes must be synthetic (not real paths or real claims) and must not log false-positive telemetry. + +Use a sentinel path prefix (`__probe__`, `__ceg_probe__`) that your gate's allowlist or path-existence check can distinguish from real traffic if needed. + +--- + +## Bash Floor Requirements + +The bash floor must implement the same gate logic as the Go binary. Divergence between the two is the failure mode the AOF Grok cross-layer batch (2026-06-15) was built to eliminate. + +Checklist before shipping: +- [ ] Same assertion patterns (copy the pattern list verbatim from Go source) +- [ ] Same allowlist paths +- [ ] Same fail-closed / fail-open posture on empty stdin +- [ ] Same empty-stdin guard (whitespace-only = unevaluable = block) +- [ ] Same dual-shape conflict handling if applicable + +If the Go binary is softened (e.g., ADR 0064 removed bare "confirmed" from patterns), the bash floor must receive the same softening. An unsoftened bash floor that over-blocks relative to the binary is a divergence — it erodes trust in the gate by generating false positives on the fallback path. + +--- + +## Settings.json Registration + +Register the dispatch wrapper three ways if your hook covers multiple events: + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Edit|Write|Bash", + "hooks": [{ "type": "command", "command": "bash ~/.claude/hooks/myhook-gate-dispatch.sh" }] + } + ] + } +} +``` + +Never register the binary directly. Never register the bash floor directly. Always register the dispatch wrapper. diff --git a/guides/advanced/when-to-write-a-hook.md b/guides/advanced/when-to-write-a-hook.md new file mode 100644 index 0000000..b1f861b --- /dev/null +++ b/guides/advanced/when-to-write-a-hook.md @@ -0,0 +1,91 @@ +# When to Write a Hook + +> A hook is a barrier, not a reminder. Write one only when the cost of the model ignoring the rule exceeds the cost of blocking it. + +The AOF enforcement ladder is: **preference → rule → hook**. Most discipline lives in the rule layer. Hooks exist for the narrow set of cases where a rule alone is insufficient — where silent non-compliance has happened before or where the consequence of a miss is high enough to justify blocking. + +--- + +## The Decision Test + +Before writing a hook, answer three questions: + +**1. Has the rule been violated with real consequence — OR is the hook advisory/telemetry?** +Blocking hooks (exit 2) are postmortem artifacts. Write one when you have evidence of the failure mode, not when you imagine it. The three-failure-stop-gate exists because of the 2026-05-15 kb_mcp_server saga (8 fix commits in 2 hours). The claim-evidence-gate exists because of the 2026-05-27 bloomberg-terminal fabrication incident. + +If you have no postmortem and the hook would block, write the rule first. + +**Exception — advisory and telemetry hooks (exit 0 always):** these do not require a postmortem. Hooks like `agentsmd-session-inject.sh` (context injection) and `aof-eval-opportunity-counter.sh` (telemetry) are proactive infrastructure. They never block, so the postmortem bar does not apply. The bar for advisory hooks is: will this emit useful signal or context without adding friction? If yes, build it. See "Hook Types by Blast Radius" below. + +**2. Can the hook detect the violation mechanically?** +Hooks operate on structured tool payloads. They can parse JSON, match regex against command strings, check file existence, and read breadcrumb logs. They cannot reason about intent, understand context, or weigh tradeoffs. + +If the only way to detect the violation requires understanding what the agent *meant*, the hook will false-positive constantly. Write the rule instead. + +**2a. Is this already enforced by an existing hook or rule?** +Before writing, check `examples/hooks/README.md` for existing coverage. Hooks that overlap an existing gate create maintenance debt and can produce confusing double-block messages. If the behavior you want is almost covered by an existing hook, extend it rather than adding a new one. + +**3. Is the blast radius acceptable?** +Every hook that exits 2 blocks the tool call. Every false positive is a friction tax on every session. Security and deploy hooks justify closed failure modes. Advisory patterns do not. If your hook would fire more than once per 100 tool calls in normal operation, it is miscalibrated. + +--- + +## Hook Types by Blast Radius + +| Type | Failure mode | When to use | +|------|-------------|-------------| +| **Security gate** | Fail-closed (exit 2) | Secrets in content, claim-without-read, infra mutations without guard | +| **Discipline gate** | Fail-closed (exit 2) | Three-failure stop, AGENTS.md not read before repo work | +| **Advisory hook** | Fail-open (exit 0) | Telemetry, context injection, breadcrumb recording | + +Advisory hooks must log every fail-open path. See `silent-failure-discipline.md`. + +--- + +## What Belongs in a Rule, Not a Hook + +- Tone or communication style +- "Think before acting" reminders +- Anything that requires reading the model's reasoning, not its tool calls +- Patterns that fire legitimately in normal work (e.g., any use of the word "confirmed") +- Guidance that varies by context (hooks are context-blind) + +--- + +## Hook Anatomy + +Every hook in the AOF examples directory follows this structure: + +```bash +#!/usr/bin/env bash +# .sh +# : — one-line description +# fail-mode: open|closed | blast-radius: +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/breadcrumb-lib.sh" 2>/dev/null || true + +INPUT=$(cat) # or HOOK_INPUT_JSON + +# Parse → gate logic → exit 0 (allow) or exit 2 (block) +``` + +Key invariants: +- **Stdin is the payload.** Never rely on env vars for the tool call data. +- **Exit 0 allows. Exit 2 blocks.** No other exit code has defined semantics. +- **Every fail-open path logs.** See `silent-failure-discipline.md`. +- **`set -euo pipefail` at the top.** Unhandled errors exit non-zero — which is exit 1, not exit 2. Because the harness may treat non-zero as a block, parse failures and dependency errors must be caught explicitly and exit 0 (fail-open) with a log line. + +--- + +## Testing Before Shipping + +A hook that passes an exit-code check is not verified. Verification requires observing the side effect: + +1. **Block path:** construct a payload that should be blocked. Run the hook directly (`echo '' | bash hook.sh`). Confirm exit 2 and the correct stderr message. +2. **Allow path:** construct a clean payload. Confirm exit 0 and no block message. +3. **Fail-open path:** pass empty stdin or malformed JSON. Confirm exit 0 and a log line in `.errors.log`. +4. **Live session:** register the hook in `settings.json`, open a session, trigger the gated action. Confirm the block fires in the session UI. + +See `examples/hooks/README.md` for the per-hook test matrix. diff --git a/tests/smoke/hooks/grok-shape-normalize.sh b/tests/smoke/hooks/grok-shape-normalize.sh new file mode 100755 index 0000000..3e2de64 --- /dev/null +++ b/tests/smoke/hooks/grok-shape-normalize.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# grok-shape-normalize.sh — smoke test for Grok camelCase → Claude snake_case normalization +# Tests that normalize-hook-input.sh (lib/normalize-hook-input.sh) correctly handles +# Grok's camelCase envelope before gate hooks run. +# +# Run: bash tests/smoke/hooks/grok-shape-normalize.sh +# Exit 0 = all pass | Exit 1 = one or more failures +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +LIB="${REPO_ROOT}/examples/hooks/lib/normalize-hook-input.sh" + +PASS=0 +FAIL=0 + +assert_eq() { + local label="$1" expected="$2" actual="$3" + if [ "$expected" = "$actual" ]; then + printf " PASS %s\n" "$label" + PASS=$((PASS + 1)) + else + printf " FAIL %s\n expected: %s\n actual: %s\n" "$label" "$expected" "$actual" + FAIL=$((FAIL + 1)) + fi +} + +assert_field() { + local label="$1" json="$2" field="$3" expected="$4" + local actual + actual="$(echo "$json" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('$field',''))" 2>/dev/null)" + assert_eq "$label [$field]" "$expected" "$actual" +} + +if [ ! -f "$LIB" ]; then + echo "SKIP: lib/normalize-hook-input.sh not found at $LIB" + echo " Install the library or copy from your claude-config/hooks/lib/" + exit 0 +fi + +# shellcheck source=/dev/null +source "$LIB" + +echo "=== grok-shape-normalize smoke test ===" +echo "" + +# --- Test 1: Grok camelCase toolName/toolInput → snake_case tool_name/tool_input --- +echo "1. camelCase envelope normalization" +PAYLOAD_1='{"toolName":"Write","toolInput":{"file_path":"/tmp/test.txt","content":"hello"}}' +OUT_1="$(nh_normalize "$PAYLOAD_1")" +assert_field "tool_name extracted" "$OUT_1" "tool_name" "Write" +TOOL_INPUT_CONTENT="$(echo "$OUT_1" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('content',''))" 2>/dev/null)" +assert_eq "tool_input.content preserved" "hello" "$TOOL_INPUT_CONTENT" + +# --- Test 2: Already-snake_case passthrough (Claude Code shape) --- +echo "" +echo "2. snake_case passthrough (no mutation)" +PAYLOAD_2='{"tool_name":"Bash","tool_input":{"command":"echo hello"}}' +OUT_2="$(nh_normalize "$PAYLOAD_2")" +assert_field "tool_name unchanged" "$OUT_2" "tool_name" "Bash" + +# --- Test 3: Dual-shape conflict → __NH_CONFLICT__ sentinel --- +echo "" +echo "3. dual-shape conflict detection" +PAYLOAD_3='{"toolName":"Edit","tool_name":"Edit","toolInput":{},"tool_input":{}}' +OUT_3="$(nh_normalize "$PAYLOAD_3")" +assert_eq "conflict sentinel" "__NH_CONFLICT__" "$OUT_3" + +# --- Test 4: Empty input → empty output (fail-open, not crash) --- +echo "" +echo "4. empty input passthrough" +OUT_4="$(nh_normalize "" 2>/dev/null || echo "")" +assert_eq "empty input → empty" "" "$OUT_4" + +# --- Test 5: Malformed JSON → original payload returned (fail-open) --- +echo "" +echo "5. malformed JSON passthrough" +PAYLOAD_5='not valid json' +OUT_5="$(nh_normalize "$PAYLOAD_5" 2>/dev/null)" +assert_eq "malformed → passthrough" "$PAYLOAD_5" "$OUT_5" + +# --- Test 6: agentsmd-bash-gate.sh blocks a Grok-shaped payload after normalization --- +# This is a gate-level test, not just a library test. It catches the class of bug where +# the normalizer is sourced but not applied — Grok payloads would exit 0 silently. +echo "" +echo "6. agentsmd-bash-gate.sh blocks Grok-shaped payload (gate enforcement)" +GATE="${REPO_ROOT}/examples/hooks/agentsmd-bash-gate.sh" +if [ ! -f "$GATE" ]; then + printf " SKIP gate not found at %s\n" "$GATE" + PASS=$((PASS + 1)) +else + # Grok-shaped payload: toolInput instead of tool_input, touching a repo path + # The breadcrumb log for this session will not have agent-operating-framework/AGENTS.md, + # so the gate should block (exit 2). If normalization is skipped the gate exits 0. + GROK_PAYLOAD='{"toolName":"Bash","toolInput":{"command":"ls '"${HOME}"'/repos/agent-operating-framework/"}}' + GATE_RC=0 + printf '%s' "$GROK_PAYLOAD" | HOOK_INPUT_JSON="" bash "$GATE" 2>/dev/null || GATE_RC=$? + if [ "$GATE_RC" -eq 2 ]; then + printf " PASS agentsmd-bash-gate blocked Grok payload (exit 2)\n" + PASS=$((PASS + 1)) + elif [ "$GATE_RC" -eq 0 ]; then + printf " FAIL agentsmd-bash-gate ALLOWED Grok payload (exit 0) — normalization not applied\n" + FAIL=$((FAIL + 1)) + else + printf " FAIL agentsmd-bash-gate exited %d (unexpected)\n" "$GATE_RC" + FAIL=$((FAIL + 1)) + fi +fi + +# --- Test 7: agentsmd-bash-gate.sh fails closed on __NH_CONFLICT__ dual-shape payload --- +echo "" +echo "7. agentsmd-bash-gate.sh fails closed on dual-shape conflict" +if [ ! -f "$GATE" ]; then + printf " SKIP gate not found\n" + PASS=$((PASS + 1)) +else + CONFLICT_PAYLOAD='{"toolName":"Bash","tool_name":"Bash","toolInput":{"command":"ls '"${HOME}"'/repos/agent-operating-framework/"},"tool_input":{"command":"ls '"${HOME}"'/repos/agent-operating-framework/"}}' + CONFLICT_RC=0 + printf '%s' "$CONFLICT_PAYLOAD" | HOOK_INPUT_JSON="" bash "$GATE" 2>/dev/null || CONFLICT_RC=$? + if [ "$CONFLICT_RC" -eq 2 ]; then + printf " PASS agentsmd-bash-gate failed closed on conflict payload (exit 2)\n" + PASS=$((PASS + 1)) + else + printf " FAIL agentsmd-bash-gate returned %d on conflict payload — should exit 2\n" "$CONFLICT_RC" + FAIL=$((FAIL + 1)) + fi +fi + +echo "" +echo "=== Results: ${PASS} passed, ${FAIL} failed ===" + +[ "$FAIL" -eq 0 ] && exit 0 || exit 1 From a6b6f2ce7a08e84388a3ff18f77aa6ed8405a581 Mon Sep 17 00:00:00 2001 From: Michael Busacca Date: Sat, 27 Jun 2026 17:36:08 -0400 Subject: [PATCH 2/2] fix(v1.8): resolve 5 PRM-INFRA-001 Stage 1 findings before ship MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HIGH agentsmd-bash-gate: remove broad case-arm allowlist (cat *AGENTS.md bypass); replaced with anchored grep -qF on $AGENTS_PATH only. HIGH three-failure-stop-gate: add no-op stubs for bc_record_exit/bc_write when breadcrumb-lib.sh is absent — prevents exit-code corruption (127) masking legitimate exit 2 blocks. MED aof-eval-opportunity-counter: fix python3 silent-continue in insert_opportunity (|| return on json-encode failure); use json.dumps for tool_name and session_id to prevent JSON injection. MED claim-evidence-gate: extract ALL backtick-quoted paths per hit, not just the first — multi-path claims now check every cited path. MED smoke test: add snake_case control payload to Test 6 to prevent false-green from gate-blocks-everything scenario. Smoke: 9/9 pass. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- examples/hooks/agentsmd-bash-gate.sh | 10 +++++----- .../hooks/aof-eval-opportunity-counter.sh | 13 +++++++++--- examples/hooks/claim-evidence-gate.sh | 10 ++++++++-- examples/hooks/three-failure-stop-gate.sh | 10 ++++++++++ tests/smoke/hooks/grok-shape-normalize.sh | 20 ++++++++++++++++--- 5 files changed, 50 insertions(+), 13 deletions(-) diff --git a/examples/hooks/agentsmd-bash-gate.sh b/examples/hooks/agentsmd-bash-gate.sh index 9653fa0..b2da080 100755 --- a/examples/hooks/agentsmd-bash-gate.sh +++ b/examples/hooks/agentsmd-bash-gate.sh @@ -61,11 +61,11 @@ done # Fixed: require the command to reference AGENTS.md at the target repo path specifically, # not just contain "AGENTS.md" anywhere in the string. AGENTS_PATH="$HOME/repos/$REPO_NAME/AGENTS.md" -case "$COMMAND" in - *"$AGENTS_PATH"*|*"AGENTS.md"$'\n'*|"cat "*"AGENTS.md"|"cat AGENTS.md") exit 0 ;; -esac -# Also allow if command is purely a Read of AGENTS.md (no other repo path actions) -if echo "$COMMAND" | grep -qE "^[[:space:]]*(cat|head|tail|less|bat)[[:space:]].*AGENTS\.md[[:space:]]*$"; then +# Allow only commands that reference the specific AGENTS.md path for THIS repo. +# Broad pattern matching (any command containing "AGENTS.md") was bypassable via +# "cat /other/repo/AGENTS.md; ". +# Fix: anchor to $AGENTS_PATH only. The anchored grep below is the sole allow path. +if echo "$COMMAND" | grep -qF "$AGENTS_PATH"; then exit 0 fi diff --git a/examples/hooks/aof-eval-opportunity-counter.sh b/examples/hooks/aof-eval-opportunity-counter.sh index 9b951df..2444199 100755 --- a/examples/hooks/aof-eval-opportunity-counter.sh +++ b/examples/hooks/aof-eval-opportunity-counter.sh @@ -22,13 +22,20 @@ SUPABASE_KEY="${AOF_EVAL_SUPABASE_KEY:-}" MACHINE="$(uname -s | tr '[:upper:]' '[:lower:]' | sed 's/darwin/mac/;s/mingw.*/win11/;s/msys.*/win11/')" insert_opportunity() { local rule_id="$1" tool_name="$2" repo_cwd="${3:-}" - local repo_json="null" - [[ -n "$repo_cwd" ]] && repo_json="$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$repo_cwd")" + local repo_json="null" tool_name_json session_id_json + # Use python3 json.dumps for all user-controlled string fields to prevent JSON injection. + # Raw interpolation of tool_name/session_id allows " or \ to break JSON structure. + tool_name_json="$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$tool_name" 2>/dev/null)" \ + || { printf '%s\taof-eval-opportunity-counter\tjson-encode-failed\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${HOME}/.claude/migration-breadcrumbs/.errors.log" 2>/dev/null || true; return; } + session_id_json="$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$SESSION_ID" 2>/dev/null)" \ + || { printf '%s\taof-eval-opportunity-counter\tjson-encode-failed\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${HOME}/.claude/migration-breadcrumbs/.errors.log" 2>/dev/null || true; return; } + [[ -n "$repo_cwd" ]] && repo_json="$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$repo_cwd" 2>/dev/null)" \ + || repo_json='"unknown"' curl -sf -X POST "${SUPABASE_URL}/rest/v1/opportunities" \ -H "apikey: ${SUPABASE_KEY}" -H "Authorization: Bearer ${SUPABASE_KEY}" \ -H "Content-Type: application/json" -H "Accept-Profile: eval" -H "Content-Profile: eval" \ -H "Prefer: return=minimal" \ - -d "{\"session_id\":\"${SESSION_ID}\",\"tool_name\":\"${tool_name}\",\"rule_id\":\"${rule_id}\",\"repo_cwd\":${repo_json},\"machine\":\"${MACHINE}\"}" \ + -d "{\"session_id\":${session_id_json},\"tool_name\":${tool_name_json},\"rule_id\":\"${rule_id}\",\"repo_cwd\":${repo_json},\"machine\":\"${MACHINE}\"}" \ --max-time 2 2>/dev/null || printf '%s\taof-eval-opportunity-counter\tsupabase-insert-failed\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${HOME}/.claude/migration-breadcrumbs/.errors.log" 2>/dev/null || true } SESSION_ID="${CLAUDE_CODE_SESSION_ID:-}" diff --git a/examples/hooks/claim-evidence-gate.sh b/examples/hooks/claim-evidence-gate.sh index 6f0d6e4..2582596 100755 --- a/examples/hooks/claim-evidence-gate.sh +++ b/examples/hooks/claim-evidence-gate.sh @@ -155,11 +155,17 @@ BLOCK } # Phase B: block explicit verification claims that cite a path without a Read breadcrumb. +# Extract ALL backtick-quoted paths from each hit — not just the first. +# A claim citing two paths previously only checked the first one. VERIFY_PATHS="" while IFS= read -r hit; do [ -z "$hit" ] && continue - path=$(echo "$hit" | sed -E 's/.*`([^`]+)`.*/\1/') - [ -n "$path" ] && VERIFY_PATHS="${VERIFY_PATHS}${path}"$'\n' + _remaining="$hit" + while echo "$_remaining" | grep -qE '`[^`]+`'; do + _path="$(echo "$_remaining" | grep -oE '`[^`]+`' | head -1 | tr -d '`')" + [ -n "$_path" ] && VERIFY_PATHS="${VERIFY_PATHS}${_path}"$'\n' + _remaining="${_remaining#*`${_path}`}" + done done < <(echo "$TEXT" | grep -oiE '(verified|confirmed|dispatched) against (real file )?`[^`]+`' || true) while IFS= read -r CLAIM_PATH; do diff --git a/examples/hooks/three-failure-stop-gate.sh b/examples/hooks/three-failure-stop-gate.sh index 4d529a3..698c43a 100755 --- a/examples/hooks/three-failure-stop-gate.sh +++ b/examples/hooks/three-failure-stop-gate.sh @@ -35,6 +35,16 @@ set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/breadcrumb-lib.sh" 2>/dev/null || true +# Guard: if breadcrumb-lib.sh failed to source, bc_record_exit is undefined. +# Without the guard, the trap fires "command not found" on every exit, which +# sets $? = 127 — converting a legitimate exit 2 block into a non-block exit code +# that the Claude Code harness may treat as a non-block. Define a no-op if missing. +if ! command -v bc_record_exit >/dev/null 2>&1; then + bc_record_exit() { :; } +fi +if ! command -v bc_write >/dev/null 2>&1; then + bc_write() { :; } +fi trap 'bc_record_exit "three-failure-stop-gate" "$?"' EXIT STATE_ROOT="${HOME}/.claude/state/three-failure-stop" diff --git a/tests/smoke/hooks/grok-shape-normalize.sh b/tests/smoke/hooks/grok-shape-normalize.sh index 3e2de64..0fc311a 100755 --- a/tests/smoke/hooks/grok-shape-normalize.sh +++ b/tests/smoke/hooks/grok-shape-normalize.sh @@ -89,9 +89,10 @@ if [ ! -f "$GATE" ]; then printf " SKIP gate not found at %s\n" "$GATE" PASS=$((PASS + 1)) else - # Grok-shaped payload: toolInput instead of tool_input, touching a repo path - # The breadcrumb log for this session will not have agent-operating-framework/AGENTS.md, - # so the gate should block (exit 2). If normalization is skipped the gate exits 0. + # Grok-shaped payload: toolInput instead of tool_input, touching a repo path. + # Expected: exit 2 (blocked). Without normalization the gate sees no tool_input.command + # → empty COMMAND → exits 0. Both outcomes produce exit 2, but for different reasons; + # the control payload below (snake_case + innocuous path) distinguishes them. GROK_PAYLOAD='{"toolName":"Bash","toolInput":{"command":"ls '"${HOME}"'/repos/agent-operating-framework/"}}' GATE_RC=0 printf '%s' "$GROK_PAYLOAD" | HOOK_INPUT_JSON="" bash "$GATE" 2>/dev/null || GATE_RC=$? @@ -105,6 +106,19 @@ else printf " FAIL agentsmd-bash-gate exited %d (unexpected)\n" "$GATE_RC" FAIL=$((FAIL + 1)) fi + + # Control payload: snake_case envelope, command that does NOT touch a repo path. + # Must exit 0 — proves the gate discriminates rather than blocking everything. + CONTROL_PAYLOAD='{"tool_name":"Bash","tool_input":{"command":"echo hello"}}' + CTRL_RC=0 + printf '%s' "$CONTROL_PAYLOAD" | HOOK_INPUT_JSON="" bash "$GATE" 2>/dev/null || CTRL_RC=$? + if [ "$CTRL_RC" -eq 0 ]; then + printf " PASS agentsmd-bash-gate allowed harmless snake_case payload (exit 0)\n" + PASS=$((PASS + 1)) + else + printf " FAIL agentsmd-bash-gate blocked harmless payload (exit %d) — gate blocks everything\n" "$CTRL_RC" + FAIL=$((FAIL + 1)) + fi fi # --- Test 7: agentsmd-bash-gate.sh fails closed on __NH_CONFLICT__ dual-shape payload ---