### Mode: hook

<!-- opt-in-capability: gate-hook -->
<!-- opt-in-capability: read-lane -->

The opt-in **gate-approval PreToolUse hook** — the family's third `.claude/` writer (velocity discipline), and the shipped closure of the velocity trust-posture residual (**AD-021 scope C, probe-proven in AD-037**). It places a **self-contained** hook runtime at `.claude/hooks/agent-workflow-gates.mjs` (no kit imports — it keeps working if the kit is uninstalled) and wires ONE `PreToolUse` `"Bash"` entry into `.claude/settings.json`. Per Bash call the hook then walks a decision ladder, first match wins:

- **Auto-approve** a command **byte-identical** (leading/trailing trim only — no whitespace collapsing, no quote/glob/variable interpretation, no prefix or pattern matching, ever: patterns are what made AD-021 auto-seeding rejected) to a gate `cmd` declared in `docs/ai/gates.json` — read **LIVE on every call** (editing gates.json never needs re-wiring; one declaration, two consumers with `${CLAUDE_SKILL_DIR}/references/modes/gates.md`) — invoked **from the project root** (gates run from the root by contract; the same bytes from a subdirectory are NOT approved) and under `default`/`acceptEdits` permission mode (an approval never loosens `plan`/`bypassPermissions`).
- **Ask** on a command whose leading tokens match the velocity **seeded read-only core** when it carries the documented runtime residual — output redirection, command substitution, or the bounded `--output` write-flag family — surfacing a human prompt even where a seeded allow rule would have silently approved (**hook `ask` overrides an allow rule — proven live**: on Claude Code 2.1.185 a seeded `Bash(git log:*)` silently wrote a file via `git log --output=…`; with the hook wired the same call prompts). Detection is string-level and conservative: a quoted metacharacter may over-ask, never under-allow. **That over-ask is a KNOWN COST, not an oversight** — see the open-limits section below, which names the three mechanisms built to narrow it and why each was removed.
- **Auto-approve a read-only COMPOUND** (opt-in — dark unless enabled): when `docs/ai/lanes.json` sets `{ "readLane": true }` (read **LIVE on every call**), a command whose EVERY separator-split segment is a plain seeded read-only core command, carrying **zero shell metaprogramming** anywhere (no `$`/expansion, quoting, backslash, brace, glob, redirection, substitution, `--output`, env-assignment prefix, or backgrounding), is auto-approved — a conservative **closed-world** allow (any doubt falls through, never a widening). Mode-fenced like gate auto-approval; **cwd-agnostic** (a read is a read from any directory). It runs AFTER the residual ask, so a core command that carries a residual still prompts (most-restrictive-wins). The lane is **bounded by the frozen audited read-only core** (the set velocity seeds) — a **standalone opt-in grant**, never a command OUTSIDE that audited core; enabling it auto-approves compounds (and singles) of that audited core **regardless of which of those commands you seeded** as individual settings rules (that is the trust the opt-in consent covers — not strictly a subset of your current settings).
- **Stay silent otherwise** — the normal permission flow proceeds unchanged. The gate-approval hook **never emits `deny`**; nothing is hard-blocked. A deny rung was built for kit 4.0.0 and REMOVED before release: it refused only a seeded read that provably discarded its output (`2>/dev/null`), and three council rounds found five shell constructs that defeated the byte-level discard PROOF (`1<&2`, a quoted literal `>/dev/null `, leading-token-only segment matching, a bare `&`, a `#` comment) — each one a FALSE REFUSAL. On an ask rung an incomplete scan merely over-asks; on a deny rung the same incompleteness refuses real work. **The direction is now RETIRED, not deferred** — the prompts a deny was meant to prevent were being raised by this hook's own ask, and a deny cannot remove a prompt the hook itself causes. Correcting what the guard reports can, and refuses nothing. Design record: the family's queue, `BARE-LANE-DENY-RUNG`.

**THE OVER-ASK IS OPEN, and 4.1.0 is the release that establishes WHY, rather than the one that fixes it.** A plain read wearing `2>/dev/null` or `2>&1`, and a search whose PATTERN merely contains `>` or a backtick, all still prompt. Three mechanisms to narrow that were built for this release and all three were REMOVED in review, each on a stop rule declared before the round that met it, each counterexample verified live and kept as a test:

1. **A quote/escape-aware reading of the command**, so a `>` inside a search pattern would stop reading as an operator. Defeated by HEREDOCS: their bodies are not shell code, and one quote in each of two bodies opens a spurious quoted span and later closes it, with a genuinely active `$(…)` sitting between them — the walker ends balanced, never falls back, and the guard goes silent on a nested command.
2. **An fd-duplication exemption** (`2>&1` creates no file). Defeated by a missing token boundary: `>&word` duplicates only when the word is a bare number, and `grep x f >&12file` writes the FILE `12file`.
3. **A null-device exemption** (a redirect into `/dev/null` writes nothing), boundary included. Defeated by U+00A0: JavaScript's `\s` counts a no-break space as a word boundary and bash does not, so `grep x f >/dev/null` + U+00A0 + `sink` names a FILE and the span was deleted anyway.

**The transferable finding: this hook cannot decide what a redirection byte MEANS — not by parsing it, and not even by deleting it — because JavaScript's notion of a token boundary and bash's do not agree.** On an ASK rung that costs an extra prompt, which is safe; it is why the same scan must never be turned into a DENY. The honest workaround today is a search pattern that avoids the byte, and dropping a decoration your harness does not need.

**Honest residual status (AD-037):** current engine builds already intercept `>` redirection and `$()` substitution upstream (observed headless on 2.1.185); the **`--output` family was proven open** and is the seam this hook demonstrably closes. The guard still covers all three documented classes (defense-in-depth — engine behavior may vary across surfaces/versions). **Fail-safe, decoupled:** a missing/broken/invalid `gates.json` disables ONLY gate auto-approval — the residual guard keeps running; every anomaly path exits 0 (the hook is never the blocker or the noise — the `gates` runner reports a broken declaration at its own point of use). **Not a sandbox:** it closes the named residual for the seeded core and auto-approves declared gates; it does not police arbitrary commands or user-added rules.

**Trust posture (state it plainly when asking consent):** the hook removes the PROMPT only for commands the human already declared in `docs/ai/gates.json` — the same trust boundary as the `gates` runner, which executes them with the caller's privileges. **gates.json thereby becomes a privileged file**: whoever can edit it can get its commands auto-approved. An invalid declaration approves NOTHING (strict parse, exact validation parity with the runner).

**The read-lane (opt-in, AD-055 Part II).** The toggle lives in `docs/ai/lanes.json` — a **SEPARATE kit-owned strict-JSON file** (`{ "readLane": true }`); `docs/ai/gates.json`, both its validators and the byte-mirrored template are **untouched** (the gates schema has no lane/model/routing fields — that claim stays true). The hook reads `lanes.json` LIVE per call, **fail-closed**: an absent / malformed / non-object file, or a `readLane` that is not the boolean `true`, leaves the lane dark (gate auto-approval and the residual ask are unaffected). `lanes.json` becomes a **privileged file** exactly like `gates.json` — an auto-approved read chain runs **unattended** and can read any file you can (the same trust boundary as the audited read-only core velocity seeds, extended to compounds (and singles) of that core; **prompt-bypass only, never a sandbox bypass**). Enable it with `--read-lane` (below), which runs a **currency check** first: it refuses unless the PLACED hook is byte-identical to the current bundle — a **pre-1.48 hook never reads `lanes.json`**, so enabling the lane against a stale placed copy would be a silent no-op the user paid consent for. The stale-hook silent-blackout class is dead **by construction** (an old hook merely lacks the rung; nothing existing changes behavior); the currency refusal names the **delete-to-reseed** recovery (`rm .claude/hooks/agent-workflow-gates.mjs` + the `--apply` one-liner) — there is **no refresh-only hook lane** this release (a diverged wired copy stays preserved, unchanged).

**Version-status routing** like the other writer modes (stamp head `3.0.0`; `--apply` enforces it in code).

Run `node ${CLAUDE_SKILL_DIR}/tools/gate-hook.mjs [--dry-run | --apply] [--cwd <dir>]`:

1. **`--dry-run` first, always** (the default — changes nothing): previews the placement and the exact settings entry it would merge. It STOPs (zero writes) on: a symlinked `.claude` / `.claude/hooks` / target file / non-regular `settings.json`; malformed settings JSON; a **malformed existing `hooks` shape** (never a merge-through-clobber); an unsafe `permissions.defaultMode` in **either** settings file; or a target hook file with **different** content while our entry is not wired (**it refuses to wire an unknown script as a PreToolUse hook** — the recovery is named: delete the file to reseed from the bundle).
2. **Ask consent** via **`AskUserQuestion` where supported**, the no-change option first: keep prompting for gate commands, or place + wire the hook — presenting the trust posture above in plain language.
3. **Only on an explicit yes**, re-run with `--apply`. It places the file FIRST, then wires settings (a wired-but-missing entry would error on every Bash call); merge-don't-clobber (foreign hooks/matchers/keys and existing permissions preserved; re-apply never duplicates); **settings hot-reload — the hook is active for new Bash calls, no session restart needed**. An identical existing file is *already current*; a diverged-but-already-wired file is reported, never clobbered or unwired.
4. **Hidden-mode deployments:** after apply, run the hide-footprint reconcile (`node ${CLAUDE_SKILL_DIR}/tools/hide-footprint.mjs --dir <project> --reconcile`) — `/.claude/hooks/` is in the known-footprint registry; the apply report reminds you.

**Enabling the read-lane:** `node ${CLAUDE_SKILL_DIR}/tools/gate-hook.mjs --read-lane [--dry-run | --apply] [--cwd <dir>]`. `--dry-run` (the default) previews the `lanes.json` edit, the hook-currency status, and the trust posture; `--apply --read-lane` runs the **currency check** (above), then writes `{ "readLane": true }` into `docs/ai/lanes.json` — create-if-absent, merge-preserve every key, symlink / non-regular refusal, malformed-JSON fail-closed, atomic. It **never touches** `.claude/settings.json` or `docs/ai/gates.json`. The upgrade Recommendations advisor surfaces this as the `read-lane` offer once the hook is placed **and** wired.

**Invariants:** the base flow writes ONLY `.claude/hooks/agent-workflow-gates.mjs` + `.claude/settings.json`; `--read-lane` writes ONLY `docs/ai/lanes.json` (never settings, never `gates.json`) · never `settings.local.json` · never commits · exact-match approval only (no patterns) · never `deny` · never auto-wired by `init`/`upgrade` (placement stays opt-in — the AD-011/AD-034 boundary: init/upgrade may refresh placed things, never place new ones).

**Exit codes:** `0` done / dry-run (incl. the reported diverged-but-wired state); `1` a precondition STOP; `2` bad arguments.

**The kit's other hook.** `state-block-guard` (see `${CLAUDE_SKILL_DIR}/references/modes/state-block-guard.md`) is a separate, unrelated `Stop` hook that judges the closing state block of a turn. It has no writer (you paste its wiring), it never approves or denies anything, and nothing here places or wires it — the two hooks share only the `.claude/` directory they live in.
