fix(hooks): PreToolUse guard remediation — scratch-root default, kill-switch hoist, MCP coverage, two conventions - #3727
Conversation
The 17 PreToolUse validating and blocking guards across guardrails, source-control, disk-hygiene, context-guard and context-budget, audited against the Classifying a hook rubric in PLUGIN-PHILOSOPHY.md and the current hooks reference. Carries the ablation verdicts, the schema-alignment gaps, the fail-fast and visibility findings, the plugin-split proposal costed against the existing layout, and new-hook candidates at the audit-automation-gaps REJECT bar. Contract tier per docs/conventions/topic-docs: committed on the task branch, pruned before merge. Read-only audit; no hook or plugin code is touched by this commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TY2eXP6jpKBfH4XSg2b5mj
Three interview rounds, five registered questions, all answered; the open-question register grades clean against both the ledger and this Brief. Scope: one draft PR carrying the scratch-root default, the kill-switch hoist across 17 guards plus a CI gate, the two visibility one-liners, MCP coverage parity for the two content guards, an ADR recording the A/B/C hook-packaging taxonomy, and a convention for the updatedInput posture. Out of scope and filed separately: executing any Class C plugin split, and the ADR 0003 corpus sweep on block-hook-bypass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TY2eXP6jpKBfH4XSg2b5mj
`block_hook_bypass_scratch_roots` was opt-in and shipped empty, which left two write targets blocked that no Write|Edit gate would ever have processed: the host temp trees, which the harness's own per-session scratchpad sits under, and the memory tier `<memory_dir>/` (default `.work/`). The measured cost was five blocks in one day across four sessions with zero true positives; the fifth refused `printf '*' >> .work/.gitignore`, the exact command this marketplace's own session-flow save-point procedure prescribes. ADR 0003 clause 4 calls that a wrong SCOPE rather than a wrong oracle, so the oracle is untouched and the scope is narrowed by two default roots. Exempting them gives up no protection, which is the only reason a default is defensible here: hook::read_file_path, the library entry every Write|Edit content guard reads its file through, already declines a temp-tree file when the project root lies outside that tree. The exemption's width is exactly the width of the protection it is scoped out of. Both defaults are gated on CLAUDE_PROJECT_DIR naming a project root outside the temp tree. With no project root neither fires; when the project root is itself temp-rooted — the shape this repo's own hook fixtures take via `mktemp -d` — a temp file is project content and the temp default stands down. Neither is spelled as a static plugin.json default, because neither has a fixed spelling: the scratchpad path carries a session id and the memory tier hangs off the consuming project. Both resolve at run time. The option's own list stays empty and now adds to the shipped roots rather than being their only source. Also resolves a relative redirect target against the payload's `.cwd`, which the axis previously refused outright. `.cwd` is already in run-guards.sh's PRIME_FILTERS, so it costs a cache lookup rather than a jq process. A relative target is still refused when the command carries a cd/pushd/popd — which moves the directory the redirect resolves against, and whose target this guard does not evaluate — or when the payload names no absolute cwd, so the refusal set only shrinks, by targets proven placeable. Repro-first: the seven MUST-stay-quiet assertions were written against the unmodified hook and failed there (expected 0, got 2) before any hook change; the fifteen adversarial-floor assertions beside them passed before and after. The `run` helper now pins CLAUDE_PROJECT_DIR empty ahead of each caller's own env words, so the 591 assertions written before these defaults existed keep measuring the no-project state they were authored against from any environment. Verified: block-hook-bypass suite 598 pass / 0 fail, identical with CLAUDE_PROJECT_DIR set in the ambient environment; scripts/affected-tests.sh --run selects 28 suites, all pass; shellcheck clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TY2eXP6jpKBfH4XSg2b5mj
…brary All fifteen shell-implemented PreToolUse guards read their `<name>_enabled` switch through `hook::check_enabled`, which only exists once the 2,684-line hook-utils.sh has been sourced — so a DISABLED guard parsed the whole library before discovering it had nothing to do. The switch now sits above every source line, and scripts/check-killswitch-hoist.sh fails a guard that reverses it. Measured on this runner, not asserted: `bash -c 'exit 0'` costs 1.8 ms and the same process with hook-utils sourced costs 5.3 ms, so the library is ~3.5 ms of a disabled standalone guard's ~5.3 ms. The saving is uneven and the split is stated rather than averaged away. Four guards run as their own process (source-control's three, context-guard's zone-gate) and recover the full ~3.5 ms. The other eleven are sourced into one process by run-guards.sh, which has already loaded the library; hook-utils.sh carries an include guard, so their source line costs ~0.05 ms (measured over 600 re-sources) and the hoist buys them almost nothing. They are held to the same rule anyway: one shape is what makes this mechanically checkable, and each is also invoked standalone by its own contract test. Inlining the predicate rather than calling the helper is the substance of the change, since the helper IS the cost being avoided. That trades one definition for fifteen copies, so the gate pins the copies to `hook::is_enabled`'s own reading of the env var and fails if that helper changes — the copies get revisited together instead of drifting. Two guards keep a deliberate shape. flag-commit-pr-skill-bypass and workflow-resilience-check already inlined an opt-in switch (`:-false`, matching their plugin.json default); the hoist only moves those lines. block-hook-bypass is strict-and-loud (#3130 F7): a value that is neither `true` nor `false` keeps the guard on and says so through hook::emit_channels, a library function, so only its exact-`false` arm is hoisted and the loud arm stays below the source. Scope: PreToolUse only. The PostToolUse half is the sibling lane's, per the split both lanes agreed and the operator authorized. lib/hook-utils.sh and its seventeen vendored copies are untouched, so the sync gate is unaffected. disk-hygiene's Python guard and context-budget's node handler source no shell library; the gate reports them as NOT SCANNED rather than passing them silently, which is how all seventeen PreToolUse guards are accounted for. Verified: 15 changed guard suites pass (2,349 assertions, exit 0 each); the gate's own suite passes 14/14, including a reversed-order fixture that fails it and a mutated hook::is_enabled that trips the semantic pin; shellcheck clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TY2eXP6jpKBfH4XSg2b5mj
…hygiene is doing
Two one-line visibility fields the hooks reference documents and this
marketplace used nowhere.
`statusMessage` on both disk-hygiene PreToolUse rows. Its Stop row already
carried one, so a silent destructive-guard failure was legible while the guard
itself ran unnamed — the spinner said nothing for up to 60 s on a Bash call that
could be blocked.
A top-level `description` on 19 of the 20 hooks.json files. All 20 omitted it.
The field is optional, so this was never a defect, but a hook set is the surface
an operator reads when deciding what a plugin does to their session, and every
one of them was blank. Each line describes that plugin's HOOK SET, not the
plugin: what it runs, on which events, and what it will do about what it finds.
Both fields verified against code.claude.com/docs/en/hooks rather than taken
from the audit: `description` is documented as an optional top-level field with
a worked example, and `statusMessage` as an optional common field on a hook
object ("Custom spinner message displayed while the hook runs").
plugins/context-guard/hooks/hooks.json is deliberately NOT in this sweep — it is
assigned to the prompt-hooks lane, and it defers to a follow-up rather than
stalling this change. No other lane's pushed state touches any hooks.json (four
active peer branches checked against origin/main), so the remaining 19 were
uncontended.
Verified: all 20 files parse; scripts/validate-plugin-contracts.mjs checks 3,123
plugin files clean. context-budget's `args` array is re-collapsed to one line
after the JSON round-trip, so the diff is the added keys and nothing else.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TY2eXP6jpKBfH4XSg2b5mj
…g rule Two policy questions the PreToolUse guard audit had to settle before it could act, written down so the next audit does not re-litigate them. ADR 0028 — classify a plugin's hooks by packaging before proposing a split. A fleet-wide "split the hooks out of every plugin that has them" was described as existing policy and turned out to be unwritten. It cannot survive contact with the fleet: of the twenty hook-carrying plugins, twelve carry exactly one skill and that skill is `setup`, so splitting their hooks out leaves an empty shell (Class A, verified by inventory, not from recollection). Three more deliver their advertised feature BY the hook — autonomy's Ralph-loop Stop gate, context-guard's zone mechanism, disk-hygiene's guard over its own delete engine, which split apart could be installed without each other (Class B). Five have a real skill surface the hooks are adjunct to (Class C: claude-ops 12 skills, session-flow 14, source-control 7, instruction-placement 5, context-budget 2), and those are candidates — a candidate still has to argue its own case. No split is executed here. The ADR gates the PROPOSAL; the five Class C candidates are filed separately. docs/conventions/hook-input-rewriting/ — a PreToolUse hook denies or asks, and never pairs `allow` with `updatedInput`. Three reasons, in order: a silent rewrite means the command that ran is not the command anyone wrote, and nothing in the transcript says so; `updatedInput` replaces the ENTIRE input object, so a field the hook did not carry forward is a field the tool no longer receives; and permission rules are re-evaluated against the hook's version, so a rewrite that widens a call can launder it past rules the original would have tripped. The sanctioned escape hatch is `updatedInput` paired with `"ask"`, which surfaces the substitution for confirmation. This forecloses the auto-canonicalizing commit rewrite that motivated it; block-noncanonical-commit denies with instructions instead, and that works because a denial's reason reaches Claude. Both docs' load-bearing claims were checked against the live hooks reference rather than carried over from the audit notes, and that caught two: the audit's note that `permissionDecisionReason` is hidden from Claude on an `ask` is contradicted by the current reference, and `defer` is not among the permissionDecision values it lists. Neither claim is made here — the doc states the three verified values and drops the asymmetry, which the rule never rested on. Conformance is verified by inspection: no hook in this marketplace emits `updatedInput` at all, and the two that emit `"ask"` carry no rewrite. PLUGIN-PHILOSOPHY.md gains a two-row table under "Classifying a hook" pointing at ADR 0003 (does it fire by default) and ADR 0028 (does it belong in this plugin), plus a convention-registry row for the rewriting doc. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TY2eXP6jpKBfH4XSg2b5mj
… tools secret-pattern-detection and hardcoded-path-check matched Write|Edit|NotebookEdit only, so a session could be cleared by both guards and still push the same secret to a repository through mcp__github__push_files or mcp__github__create_or_update_file — a route with no local file to fix afterwards and no pre-commit content-invariants layer on it. Both guards now inspect that content, push_files per entry of its files array rather than only the first. mcp__github__delete_file is deliberately not covered. Its schema (read from the live tool definition, not assumed) carries owner, repo, path, message and branch, and no content: there is nothing for a content guard to scan, and a delete cannot introduce a secret or a hardcoded path. Naming it would claim coverage that consists of skipping every call. Registered as a NEW hooks.json row rather than by widening the Write|Edit|MultiEdit|NotebookEdit matcher, so the always-on write path pays nothing for tools it will never see. Three local-only gates are not applied on the new lane, each because it is a statement about a LOCAL file that an MCP write does not have: the project-scope guard, the git-working-tree requirement, and `git check-ignore`. The scope guard matters most — a repo-relative path is never under CLAUDE_PROJECT_DIR, so reusing it would have skipped every MCP write, which is a silent hole rather than a scope. Both directions are pinned by tests. The path allowlist IS reused, as one function with two callers: an .env.example or a test fixture tree is the same false positive whichever route writes it. hardcoded-path-check still resolves its scan root, which is what catches this machine's own checkout path appearing verbatim in pushed content. A regression this introduced and then fixed: both guards ask for .tool_input.path, and run-guards.sh's cached hook::jq_fields is all-or-nothing per call — one filter it cannot serve sends the whole call to an uncached jq. Measured at two extra spawns on EVERY Write/Edit, 50 ms to 60 ms, before it shipped. The field is now in PRIME_FILTERS with a comment saying why any new field must go there: 51 ms before, 52 ms after, median of three 30-run batches per tree. Budget: the new lane costs 47 ms for a single-file write and 72 ms for a two-file push on this host, stated in the plugin README's accounting section per .claude/rules/hook-budget.md. The per-file cost is one jq, on a lane that fires only on a GitHub MCP write. Verified: secret-pattern-detection 68 to 86 assertions, hardcoded-path-check 100 to 118, both 0 fail; one case per payload shape, including a secret in the LAST file of a push (which a files[0]-only implementation would pass and read as covered) and the kill switch still governing the new lane; shellcheck clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TY2eXP6jpKBfH4XSg2b5mj
…tion-hooks-84d7ka # Conflicts: # plugins/guardrails/.claude-plugin/plugin.json # plugins/guardrails/CHANGELOG.md
Fourteen tracker references across ten files cited #3712, which is a pull request for an unrelated repo-wide simplification sweep, not this work. A reader following any of them would land somewhere that says nothing about the guard they were reading. Filed #3719 as the actual tracking issue for this remediation and repointed all fourteen. Verified by reading #3712 rather than assuming the number was right; it was carried over from the audit notes and was wrong from the first commit. No behavior change: every occurrence is inside a comment or a doc sentence. Suites re-run to confirm, since two of the ten files are test files whose comments the sed also touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TY2eXP6jpKBfH4XSg2b5mj
Each row names the question that likely decides that candidate, and three of the five turn out to share one: a hook that WRITES what a sibling skill READS is one feature with two surfaces, which is Class B reasoning arriving by another route. Recording that here means the next reader sees the shared test rather than rediscovering it once per candidate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TY2eXP6jpKBfH4XSg2b5mj
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Claude finished @kyle-sexton's task in 2m 28s —— View job Code review in progress
|
|
Claude finished @kyle-sexton's task in 7m 16s —— View job Security review complete
Reviewed the diff against One finding posted inline (IMPORTANT): the memory-tier ( Everything else reviewed clean:
|
|
Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2df0d85439
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Last security-reviewed head: |
…nk resolution The scratch-root compare is lexical, which the CONFIGURED-root axis documents as a residual on an explicit ground: "an operator naming a root is accepting that root's contents". Making the temp tree and the memory tier exempt BY DEFAULT removed that ground without replacing it, and the hole is real: a symlink under an exempt root pointing into the repository (`/tmp/to-repo -> <repo>`) let `echo <secret> > /tmp/to-repo/tracked.py` return 0 while the identical direct path blocked. Reported as a P1 by an automated reviewer on the pull request, then reproduced before anything was written: direct write exit 2, the same file through a temp-root symlink exit 0. The two shipped defaults now resolve the target — or its nearest existing ancestor, since a redirect target usually does not exist yet — and re-check containment before granting. The configured roots keep their documented lexical residual, because the operator ground that justifies it still holds there; this is scoped to the defaults that lack it. Two design points worth stating, because the obvious implementations are wrong: A path with NO existing component resolves to itself, and is exempted on its spelling. That is not a shortcut: a symlink is a filesystem object, so a path where nothing exists holds none, and the lexical answer IS the physical one. Failing closed there would make the verdict depend on whether a directory happens to exist on the host rather than on the command. Cost stays on the GRANT path. The lexical test runs first and resolution only runs when the guard is about to exempt, so a command that was going to block spends no resolver process — and resolving every target would put a subprocess on the hottest guard in the fleet for nothing. Residual, recorded rather than papered over: the check inherits the axis's case-folding. The target arrives from the lowercased segment scan, so on a case-sensitive filesystem a symlink whose real spelling carries capitals never resolves and stays exempt. Closing it needs the target in its original case, which the folded scan does not carry. `/tmp/torepo -> <repo>` is now blocked; `/tmp/ToRepo -> <repo>` is not. Verified: suite 598 -> 601, 0 fail. The escape case is discriminating — with the confirmation neutered it FAILS (expected 2, got 0) and the two non-regression cases beside it stay green both ways, so it pins the fix rather than the environment. Its fixture is spelled all-lowercase by hand rather than taken from mktemp, whose mixed-case names would silently exercise the case-folding residual instead of the symlink one. shellcheck, markdownlint and the em-dash gate clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TY2eXP6jpKBfH4XSg2b5mj
The block-hook-bypass exemption for `<memory_dir>/` (default `.work/`) is removed; the temp-tree default stays. A reviewer on the pull request showed the "gives up no protection" argument does not carry to the memory tier, and it is right: hook::read_file_path has no `.work/` decline, so secret-pattern-detection scans a Write to `.work/notes.md` today (verified — exit 2 on a PAT-shaped token). Exempting Bash redirects there would have let `printf '<secret>' >> .work/notes.md` reach disk unscanned while the identical Write stayed blocked, which is the same content-guard bypass this branch closes for the GitHub MCP write tools. The reviewer offered two remedies: drop the memory-tier default, or allowlist the tier in secret-pattern-detection so the two guards agree. The second is a widening of a default-on security guard on an argument from convention alone — docs/conventions/topic-docs/ does state, as normative, that raw output including credentials belongs in the tier — and ADR 0003 wants firing evidence before one of those moves. The first is the safer fix, and it costs less than it looks: the reproduced false positive, `printf '*' >> .work/.gitignore`, still blocks, but a Write of `*` to that path is scanned, passes, and does the same job. The conflict routes back to session-flow's procedure (#3720, whose first option was exactly that), and the asymmetry between the two content guards is filed on its own (#3730) rather than resolved here without evidence. This departs from the topic's locked Brief, whose first criterion named `.work/` alongside the scratchpad. That criterion was written before the consequence was known; a criterion that turns out to introduce a bypass is not one to satisfy by the letter. Recorded in the hook's own comment block so the obvious second default is not re-added without meeting the argument. The symlink confirmation from the previous commit now serves the one remaining default. The relative-cwd resolution stays, now pinned against a configured root. The suite inverts the seven memory-tier grants into blocks, with the reason at the site, and the symlink case moves to the temp default with a victim project built under the repo's own gitignored `.work/` — the one writable non-temp location the suite can rely on. A checkout path carrying capitals prints an explicit SKIP for that case rather than silently exercising the case-folding residual; this checkout is lowercase and the case ran. Verified: block-hook-bypass suite 602/0, the escape case discriminating (fails with the confirmation neutered, passes with it); scripts/affected-tests.sh --run 146 shell suites 0 failures; shellcheck, markdownlint, em-dash gate clean; options table regenerated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TY2eXP6jpKBfH4XSg2b5mj
The `lint` job failed on every head of this branch, and the six sub-gates it
named reduce to three causes.
Nineteen plugins modified without a version bump. The changelog-parity bump gate
refuses a change set that touches a plugin's shipped files while reusing its
published version, and the hooks.json `description` sweep touched sixteen
plugins for exactly one line each, disk-hygiene for that line plus two
`statusMessage`s, source-control for that line plus three hoisted kill switches,
and context-guard for one hoisted kill switch. Each gets a patch bump and a
release entry that says which of those it was — the entry text is per-plugin,
not one paragraph pasted nineteen times, because a reader of context-guard's
changelog should not learn about a hooks.json field that plugin did not receive.
Five shellcheck notes at the repo's rcfile severity, which is stricter than the
`-S warning` I had been running locally. All SC2016 sites are single-quoted text
that is meant literally — fixture bodies carrying `${CLAUDE_PLUGIN_ROOT}`, jq
`--arg` variables, the hoist gate's verbatim match against `hook::is_enabled`'s
own source, and a paste-ready template in an error message — and one SC2310 on
a pure-lookup predicate used in an `&&` chain. Each carries a directive with the
reason at the site; the hoist test suite takes one file-level directive, the
shape its sibling `check-hook-exec-form.test.sh` already uses.
One GNU-only `sed -i` in the hoist suite's semantic-pin case, rewritten through
a sibling file and `mv`. The gate that caught it exists because no CI runner here
covers BSD userland, so the regression would have shipped undetected.
The remaining three sub-gates (markdown, typos, contract-slice-diff) all point at
`docs/topics/claude-pretool-validation-hooks-84d7ka/`, which the topic-docs
convention prunes before merge; that is the next commit, kept separate so this
one is the pre-prune SHA the PR body names.
Verified: all four changelog-parity modes, shell-portability against
origin/main, shellcheck at rcfile severity over every changed script,
markdownlint and typos over every changed non-topic document, and the hoist
gate plus its 14-case suite — all clean.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TY2eXP6jpKBfH4XSg2b5mj
`docs/topics/claude-pretool-validation-hooks-84d7ka/` is Contract tier under docs/conventions/topic-docs/: committed on the task branch only, pruned before merge, with the durable outcomes graduated first and context pointers left in its place. The contract-slice-diff gate enforces exactly this on the net PR diff, and it is also where the branch's `typos` and `markdown` failures lived — both were in FINDINGS.md, a file that was never going to reach main. Graduation is complete. The two policy decisions the slice locked live in ADR 0028 (`docs/adr/`) and `docs/conventions/hook-input-rewriting/`; the seven follow-ups the audit produced are #3720 through #3726 and #3730; the umbrella is #3719. The PR body carries the locked Brief verbatim in a details block and names the pre-prune commit — the parent of this one — in the Contents API form the convention prescribes for best-effort retrieval after squash-merge. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TY2eXP6jpKBfH4XSg2b5mj
Twenty version-bump collisions: main's simplification sweep and the session-flow handoff feature bumped many of the same plugins this branch bumped, to identical version numbers with different release notes. Resolution per plugin, so both sides' notes survive and every bump stays monotonic against main: where both sides chose the same version, main keeps it and this branch's entry moves to the next patch; guardrails keeps its 0.32.0 minor over main's 0.31.7; session-flow moves to 0.35.1 above main's 0.35.0. All four changelog-parity modes pass against origin/main, every heading main carried is preserved, and no manifest disagrees with its newest heading. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TY2eXP6jpKBfH4XSg2b5mj
Second lap: main moved again after the previous merge (the claude-ops audit-performance kernel-object census, then a standards sync). One collision, claude-ops: main shipped 0.42.0 as a minor for the census, so this branch's description entry moves to 0.42.1 above it and the manifest takes main's copy, which carries the newer description, with only the version changed. All four changelog-parity modes pass against origin/main. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TY2eXP6jpKBfH4XSg2b5mj
…er (#3736) No related issue: the defect was found by an in-session fleet audit that had no tracking issue; the audit's findings on other lanes are tracked in #3685, #3721 and #3727. ## Summary `zone-crossing-inject.sh` fires once per `UserPromptSubmit` and once per `PostToolBatch`, so a three-batch turn that stays in one zone fired four times and rewrote both state markers four times to the values they already held. The armed-rank gate already suppressed the extra notices, so this was wasted I/O rather than duplicate injection. Alongside it, `hooks.json` lacked its documented top-level `description`. ## Fix - Each marker is written only when its raw on-disk bytes differ from the new value. The comparand is the read before normalization, so a legacy-format marker is still rewritten in the current format and an absent marker still latches on the first fire. Write ordering, the fail-open posture and the `.zone` rollback on a failed `.armed` write are unchanged; the rollback now fires only when this call was the one that moved `.zone`. - A new contract-test case (12c) pins the skip. Every existing assertion reads stdout, which is identical whether the markers were rewritten or skipped, so a revert to unconditional writes would have passed all 58. The new case backdates both markers to 2000-01-01 and a reference file to 2000-01-02, fires on an unchanged zone, and asserts neither marker is newer than the reference, with a positive control that a mismatched marker is still rewritten. `bash -nt` keeps it free of any `stat(1)` dialect, and mtimes are not subject to root's permission bypass, which is why it is not a chmod test. - `hooks.json` gains a top-level `description`. - `context-guard` bumps to 0.7.42 with a CHANGELOG entry. Main shipped 0.7.39 in #3718, 0.7.40 in #3727 and 0.7.41 in #3732 while this branch was open; each merge placed this entry above the newer one under its own version, so consumers on any of them still receive this change. **Withdrawn in review.** The draft also dropped the four `hooks.json` timeouts from 60 to 10, reasoning from the hooks reference's 30-second `UserPromptSubmit` default and a 30 ms quiet-host measurement. The Codex review on `5acee106` pointed at the 0.4.8 CHANGELOG entry and the README paragraph that own that value: on Windows 11 / Git Bash with Defender real-time protection, `zone-crossing-inject.sh` measured 22.0 s, and a 10-second cap was the exact defect 0.4.8 fixed, cancelling the advisory on essentially every fire while still charging the full wall time. Nothing on this branch re-measured that profile, so `3db2c457` restores all four rows to 60 and the CHANGELOG entry says why they stay. Five files: `zone-crossing-inject.sh`, `zone-crossing-inject.test.sh`, `hooks.json`, `plugin.json`, `CHANGELOG.md`, all under `plugins/context-guard/`. The interview Brief that scoped this lived in `docs/topics/prompt-hooks-review/` on the branch and is pruned before merge per the topic-docs convention; its substance is this body, and the audit's measured baseline is routed to #3685. ## Verification - `shellcheck` clean on the changed script and the test, plain and with the repo `.shellcheckrc`; `check-shell-portability.sh origin/main` clean on both. - `plugins/context-guard/hooks/zone-crossing-inject.test.sh`: 58 pass, 0 fail at every head through `3db2c457`; 63 pass, 0 fail on `860d66d6` with case 12c. Against `origin/main`'s hook the suite reads 61 pass, 2 fail, the two failures being exactly the skip assertions. The first attempt at the fix failed one case (`0.7.1-format marker not normalized`): comparing against the normalized read skipped the rewrite of a legacy marker. Fixed by comparing against the raw read. - `scripts/affected-tests.sh --run`: all selected suites pass at every head (26 before the first merge, 27 on `f7cb502f`, 2 on the pruned head `511b549f` once the Brief stopped driving reference-based selection, 2 on `fcc3f8cd`, all selected on `5acee106`, 2 on `3db2c457`, 3 on `860d66d6`), zero `FAIL` lines. A mid-merge run had reported three failures in guardrails, session-flow and source-control; on a clean `origin/main` worktree the guardrails suite passes 213/0 and the session-flow failure reproduces identically, and this branch touches none of those plugins. - On `fcc3f8cd`, after merging the #3727 squash: the three context-guard suites pass 18/58/25, and main's new `scripts/check-killswitch-hoist.sh` reports 15 of 15 guards clean. - On `5acee106`, after merging the #3732 squash (resolved by the babysit lane, see its comment on this PR): the three context-guard suites pass 18/58/25 again, `scripts/sync-hook-utils.sh --check` reports all 17 copies matching, and the diff against main is still exactly the files above. - On `3db2c457`: the three context-guard suites pass 18/58/25, markdownlint clean on the CHANGELOG, `hooks.json` parses and every `timeout` reads 60 again. - `scripts/check-changelog-parity.sh` `--check-bump origin/main`, `--check-order`, `--check-preserved origin/main`: all exit 0 on every head. - `scripts/check-contract-slice-prune.sh --check-diff origin/main`: passes on `511b549f`. This was the one failing sub-gate of 56 on the lint lane at `f7cb502f` (`contract-slice-diff`); every other hygiene sub-gate was already green there. - Review lanes on `5acee106`: security-review reported no findings; the Claude review lane found no blocking issue and raised the missing skip test, addressed in `860d66d6`; the Codex review raised the timeout finding above, addressed in `3db2c457`. ## Related - #3685: per-tool-call overhead thread; the audit's measurement caveats on the hook-budget convention are posted there as evidence. - #3718: shipped context-guard 0.7.39 (zone-gate deny-reason text). - #3721: `block-hook-bypass` corpus sweep (PreToolUse lane). - #3727: kill-switch hoist, PreToolUse half, with `scripts/check-killswitch-hoist.sh`. Landed first and took context-guard 0.7.40. - #3732: vendored `hook-utils.sh` startup-subshell trim. Landed second and took context-guard 0.7.41, so this PR's entry sits above it as 0.7.42. - Interview ledger: `.work/prompt-hooks-review/interview-checklist.md` (untracked, memory tier). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01ECoW7mi7Zod61utjdyWtRE --------- Co-authored-by: Claude <noreply@anthropic.com>
… cache hits in-process (#3747) No related issue: the verifier-lane performance fix was routed to this lane by the operator through the prompt-hooks session (relayed 2026-09-04, confirmed 2026-09-05) and tracked in the topic's contract slice rather than an issue. ## Summary The three guardrails PostToolUse verifiers were the most expensive hooks on a markdown write. `skill-reference-verify` spent 468 of its 643 ms in a loop that spawned two `jq` and two `tr` per plugin manifest (74 manifests, 296 processes) to build an index one process builds in 5 ms. `cli-flag-verify` spawned the bundled verifier for every candidate even on a warm 24 h cache, where the verifier's whole job is to read one file and match one pattern; each spawn also paid the `#!/usr/bin/env` PATH walk for `bash`. This PR is the first of three from the hook-logging-pipeline topic (the PostToolUse kill-switch hoist and the per-session pipeline follow as separate PRs, each opened after the previous merges). ## Fix 1. **`skill-reference-verify.sh`**: `build_plugin_index` reads every manifest in one `jq -r` emitting `<manifest>\x1e<name>\x1e<paths>` (raw fields on non-whitespace separators, so a nameless manifest keeps its empty field in place and a declared path arrives byte-identical); manifests jq could not parse, and any behind a parse error in the batch, take the per-manifest read the batch replaced, so a malformed sibling changes nothing for the others. 2. **`cli-flag-verify.sh`**: a second pre-gate exits before the fragment pipeline when the content names none of the scanned bins (the `-` gate alone passed every hyphenated paragraph); cache hits are answered in-process (one lazy `find` indexes the fresh cache files on the first candidate whose bin is installed; the cached `--help` text is matched with `=~`), and the verifier is spawned only on a miss. 3. **`lib/verification/cli-flag-cache.sh`** (new): the cache directory, key, freshness window and flag-match pattern, defined once and sourced by both the hook and the verifier. Review of the first cut found the hook's hand-copied pattern had dropped the `[` terminator, so `--flag[=VALUE]` was known cold and unknown warm; one definition is the fix for that class. 4. **`verify-cli-flag.sh`**: `[[ -d ]]` before `mkdir -p`; the 24 h freshness test compares against a reference file touched to that timestamp (one POSIX `touch -t` in place of `find | grep`, `find` kept for Bash before 5.0); the flag match is `=~` over the cached text instead of `printf | grep -E`. 5. guardrails 0.32.1 to 0.32.2 with the CHANGELOG entry carrying the before and after numbers (ADR 0003 rule 2). ## Verification Measured on this Linux host with the topic's harness (N = 15) and direct probes; the raw tables live in the topic's memory slice, the distilled rows in FINDINGS.md on the branch: | Row | Before | After | | --- | --- | --- | | `skill-reference-verify`, `.md` citing a skill | 642.9 ms (334.8 S) | 63.8 ms (33.8 S) | | index loop, isolated (74 manifests) | 468.5 ms | 5.2 ms | | `cli-flag-verify`, warm cache | 118.9 to 135.8 ms | 55.1 to 79.2 ms | | `execve` per warm run (strace) | 152, 88 failed | 34, 11 failed | Every new suite case was written first and run against the unmodified hooks (`git stash` of the three scripts): 2 failures in the skill-reference suite and 3 in the cli-flag suite on the committed hooks, 0 after; the review-round cases (`--color[=WHEN]` on a warm cache, a nameless manifest, a backslash in a declared path) likewise fail on the first cut and pass on the head. Suites: `skill-reference-verify.test.sh` 133/133, `cli-flag-verify.test.sh` 92/92 (was 123 and 70). `scripts/affected-tests.sh --run`: all 26 selected suites pass. `shellcheck -x` clean on the four scripts and both suites. `scripts/check-changelog-parity.sh --check-bump origin/main` exit 0. A fresh-context code review at high effort ran on the first cut; all six of its findings were verified by reproduction and are fixed in the head commit. The topic's contract slice (`docs/topics/hook-logging-pipeline/`) rides this branch and is pruned in the final commit before the ready flip, per the topic-docs convention; the Brief and the phases this PR ships are below. <details> <summary>Brief (TLDR, Goal, corrections) and Phases 0 and 1 of the plan</summary> ### TLDR Design the logging and telemetry pipeline for the marketplace's hooks, and settle the upstream decisions that determine what it instruments. Evidence base is FINDINGS.md, a measured read-only audit of the 26 wired `PostToolUse` rows plus a doc-alignment pass. Interview complete: five rounds, 21 questions, 19 answered and 2 deferred with named arbiters. Register gate clean. ### Goal Observability across every hook event, defaulting to off, costing effectively nothing when off and as close to nothing as measurable when on, with no surface left as a black box and every toggle reachable by Claude on the operator's behalf rather than by hand. ### The verifier-lane allocation (confirmed after the interview closed) The three PostToolUse verifiers on a markdown write citing a skill and CLI commands cost about 825 ms (peer measurement, corroborated at 601 to 622 ms for the dominant guard alone). Four patch shapes, each measured by the prompt-hooks lane and re-measured here before landing: the batched index; the bin-name pre-gate; the in-parent cache read; the three cache-path spawns in the verifier. Read ADR-0003 before touching any of the three; it was written about exactly these guards. ### Phase 0: Baseline measurements [DONE] Re-measure before touching anything (ADR-0003 rule 1). PR #3732 changed the library's startup path after the handoff figures were taken, so every number is re-captured. Harness at N = 15; cold cache for `cli-flag-verify` with the directory cleared; PATH tax by `strace -f -e trace=execve`; distilled figures into FINDINGS.md under "Re-measurement 2026-09-05". ### Phase 1: Verifier-lane fix (PR A) [DOING] Four patch shapes, each with a repro-first test per `hook-precision`. Timing assertions live in the harness rows recorded in FINDINGS.md; the suites assert structure (spawn counts through PATH shims, equivalence), which is stable on shared CI runners. 1a the batched index with a fixture of three manifest shapes and a one-invocation assertion; 1b the bin-name pre-gate with a zero-`sed` case on hyphenated prose; 1c the in-parent cache read with a zero-verifier-spawn case on a warm cache and identical findings; 1d the verifier's own cache path with the `--save-dev` versus `--save-developer` boundary cold and warm. Harness re-run; before and after in FINDINGS.md and the CHANGELOG entry; guardrails patch bump above `origin/main`; `scripts/affected-tests.sh --run`; shellcheck; PR as draft with the body contract; prune commit before the ready flip. Sanity check: harness "with refs" row for `skill-reference-verify` under 100 ms; the guardrails suites exit 0 with the shim-counter cases; `scripts/affected-tests.sh --run` exit 0; `scripts/check-changelog-parity.sh --check-bump origin/main` exit 0. </details> ## Related - Refs #3685, the hook-budget convention's spawn-dominated versus work-dominated split, which this topic's measurements bear on (`markdown-format` at 343 ms is Node startup, not spawns) and which this PR does not change - Refs #3719 and PR #3727, the PreToolUse half of the kill-switch hoist; the PostToolUse half is the next PR from this branch's topic - `docs/adr/0003-verification-guards-earn-default-on-by-measured-precision.md`, rule 1 (measure before shipping) and rule 2 (report the number in the PR) - `docs/conventions/hook-precision/README.md`, the repro-first discipline every new case follows - `lib/hook-utils.sh` and its 17 vendored copies are untouched 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme --- _Generated by [Claude Code](https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
No related issue: this is the PostToolUse half of the kill-switch hoist that #3719 and PR #3727 shipped for PreToolUse, allocated to this lane by the operator through the prompt-hooks session (relayed 2026-09-04, confirmed 2026-09-05) and tracked in the hook-logging-pipeline topic's contract slice rather than an issue. ## Summary Every PostToolUse hook script read its kill switch through `hook::check_enabled`, which only exists once `lib/hook-utils.sh` (2,766 lines) has been sourced, so a hook a consumer had turned OFF still parsed the whole library before learning it had nothing to do. PR #3727 fixed the same shape for the 17 PreToolUse guards and pinned it with `scripts/check-killswitch-hoist.sh`, but that gate scanned PreToolUse rows only, so the 15 PostToolUse scripts stayed in the old shape with nothing to stop them drifting back. This is the second of three PRs from the hook-logging-pipeline topic (the verifier-lane fix, #3747, merged first; the per-session pipeline follows). ## Fix 1. **`scripts/check-killswitch-hoist.sh`** discovers `.hooks.PreToolUse[]?, .hooks.PostToolUse[]?` instead of PreToolUse alone; the refusal, count and NOT SCANNED lines say so. Extended, never recreated: the same script, the same `hook::is_enabled` predicate pin. The test's "a PostToolUse guard is out of scope" case inverts into "a reversed PostToolUse guard FAILS", a hoisted PostToolUse fixture passes, and a launcher-shaped row is covered (16 cases, was 15). 2. **15 scripts hoisted**, one line each, verbatim `[[ "${CLAUDE_PLUGIN_OPTION_<NAME>_ENABLED:-true}" == "true" ]] || exit 0` above the first `source`, with the `hook::check_enabled "<NAME>"` line deleted: `actionlint-check`, `bash-format`, `biome-format`, `eol-normalizer`, `go-format`, `index-drift` (switch above its `hook_dir=` line), `markdown-format`, `powershell-format`, `ruff-format`, `typos-format`, `cli-flag-verify`, `skill-reference-verify`, `stale-path-verify`, `worktree-add-claim-gate`, `skill-usage-audit`. Enabled behavior is unchanged in every one. 3. **13 plugin patch bumps** above `origin/main` with a CHANGELOG entry each stating the measured disabled-cost recovery for its shape: actionlint 0.8.35, bash-format 0.7.36, biome-format 0.6.34, eol-normalizer 0.6.35, go-format 0.3.38, instruction-placement 0.11.26, markdown-format 0.11.44, powershell-format 0.7.37, ruff-format 0.6.35, typos-format 0.6.42, guardrails 0.32.3, source-control 0.55.51, claude-ops 0.42.4 (moved from 0.42.3 when main took that number). The three guardrails verifiers' entry says plainly that they run sourced under `run-guards.sh` in production, where the library is already loaded and the hoist recovers almost nothing; they are held to the rule so the gate stays mechanical, and their standalone contract-test path does recover the parse. ## Verification Measured on the Linux CI host, N = 15, standalone hook with the switch set to `false`: | Row | before | after | | --- | --- | --- | | `markdown-format` disabled | 6.2 ms | 3.20 ms | | `typos-format` disabled | 6.1 ms | 3.19 ms | | `eol-normalizer` disabled | 6.5 ms | 3.10 ms | | `skill-usage-audit` disabled | (same shape) | 3.09 ms | | bare `bash -c :` spawn floor | 1.9 ms | 1.84 ms | The disabled path drops from about 3.3 to about 1.7 spawn-equivalents, which is the library parse; nothing else changed on that path. Gates on the merged head (after `origin/main` at #3747 was merged in and the hoist re-applied on top of main's `skill-reference-verify`): `scripts/check-killswitch-hoist.sh` exit 0 reporting 30 PreToolUse and PostToolUse scripts (15 more than main); `bash scripts/check-killswitch-hoist.test.sh` 16/16; every hoisted script greps 1 for the inlined predicate and 0 for `hook::check_enabled`; `scripts/check-changelog-parity.sh --check-bump origin/main` exit 0; `scripts/affected-tests.sh --run`: 145 shell suites pass, and the 12 Python suites it defers ran under pytest with 762 passed and one pre-existing failure (`session-flow` `test_new_origin_falls_back_to_directory_name`, a directory-name assertion in a suite this branch does not touch; it fails identically on `origin/main` in this container). The topic's contract slice (`docs/topics/hook-logging-pipeline/`) rides this branch and is pruned in the final commit before the ready flip, per the topic-docs convention; the Brief summary and the phase this PR ships are below. <details> <summary>Brief (TLDR, Goal) and Phase 2 of the plan</summary> ### TLDR Design the logging and telemetry pipeline for the marketplace's hooks, and settle the upstream decisions that determine what it instruments. Evidence base is FINDINGS.md, a measured read-only audit of the 26 wired `PostToolUse` rows plus a doc-alignment pass. Interview complete: five rounds, 21 questions, 19 answered and 2 deferred with named arbiters. ### Goal Observability across every hook event, defaulting to off, costing effectively nothing when off and as close to nothing as measurable when on, with no surface left as a black box and every toggle reachable by Claude on the operator's behalf rather than by hand. Prerequisite (Brief Q10): the kill switch is read before any library load, which measurement shows recovers over half of the disabled cost. ### Phase 2: PostToolUse kill-switch hoist (PR B) Extend, never recreate: `scripts/check-killswitch-hoist.sh` is on `main`. Cut the branch from A's head; merge `origin/main` once A has merged. Widen discovery to `.hooks.PreToolUse[]?, .hooks.PostToolUse[]?`; invert the out-of-scope test case; hoist the 15 PostToolUse-registered scripts one line each; each touched plugin gets a patch bump above `origin/main` plus a CHANGELOG entry stating the measured disabled-cost recovery for its shape; measure one standalone formatter disabled before and after. Sanity check: `scripts/check-killswitch-hoist.sh` exit 0 with a count 15 higher than `main`'s; `bash scripts/check-killswitch-hoist.test.sh` exit 0; for each of the 15 scripts the inlined predicate greps 1 and `hook::check_enabled` greps 0; `scripts/affected-tests.sh --run` exit 0; `scripts/check-changelog-parity.sh --check-bump origin/main` exit 0. </details> ## Related - Refs #3719 and PR #3727, the PreToolUse half; this PR widens the gate those shipped - #3747, the verifier-lane fix this branch was cut from and merged back in; three of the 15 hoisted scripts are the ones it sped up - `docs/conventions/hook-budget/README.md`: the disabled path is the always-on cost every consumer pays for a hook they turned off - `lib/hook-utils.sh` and its 17 vendored copies are untouched; `hook::check_enabled` stays for the events the gate does not scan 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme --- _Generated by [Claude Code](https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Closes #3752 ## Summary The plugins reference gives `hooks/hooks.json` an optional top-level `description`, the one place a plugin labels its hooks as a set. The issue counted it absent in 19 of 20 plugins, but #3727 landed a description in every file after the issue was filed, so the descriptions half is already on `main`. What was still missing is the gate that stops the count drifting back, and this PR adds it. No plugin file changes and no version bumps. ## Fix - `scripts/check-hooks-description.sh`: scans `plugins/*/hooks/hooks.json`, skips a plugin with no hooks, and fails a file whose top-level `description` is absent, not a string, blank, or multi-line. A file that does not parse fails closed. Basis and recheck trigger are in the script header. - `scripts/check-hooks-description.test.sh`: one fixture per shape (missing, blank, whitespace, number, null, multi-line, unparsable), a CRLF-terminated file, a hook-less plugin, a mixed tree naming every failing file with the summary count, and the live tree. - `.github/workflows/ci.yml`: the lint lane runs the suite, then the gate, and reports it into `ci-status` beside the sibling hook gates. ## Verification - `bash scripts/check-hooks-description.test.sh`: PASS=12 FAIL=0. `scripts/check-hooks-description.sh` on the live tree: every hooks.json (20) carries a one-line description. - `scripts/affected-tests.sh --run` (the ci.yml change selects the whole `scripts/*.test.sh` set): 149 shell suites passed or were skipped, exit 0. - actionlint, `scripts/check-lane-coverage.sh --check`, `scripts/check-discriminating-test-skips.sh`, shellcheck, shfmt, typos: clean. ## Related - Refs #3727 (the descriptions) and #3750 (the audit that counted them). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme --- _Generated by [Claude Code](https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme)_ --------- Co-authored-by: Claude <noreply@anthropic.com>

Closes #3719
Summary
The 17 PreToolUse guards had never been audited against the current model generation or the current
hooks reference. This ships the actionable half of that audit: the one guard measurably misfiring,
the kill switches that were not saving what they promised, two missing one-line visibility fields, a
content-guard blind spot on the GitHub MCP write tools, and the two policy questions the work had to
settle in order to proceed.
The audit's remaining ablation verdicts are deliberately not acted on here, because they rest on
class reasoning with no firing data. The audit itself lived in the topic's contract slice, pruned
before merge per the topic-docs convention — see Contract slice under Related for the pointer.
One deliberate departure from the topic's locked Brief, stated up front. Its first criterion named
the memory tier (
.work/) alongside the harness scratchpad as a shippedblock-hook-bypassexemption. Review showed that exempting the memory tier reopens a content-guard bypass — see Fix 1 —
so it is withdrawn and the criterion is not met by the letter. A criterion that turns out to
introduce a bypass is not one to satisfy as written; the consequence and the routing are recorded
below and in the hook itself.
Fix
1.
block-hook-bypassships one scratch root exempt instead of none (guardrails 0.32.0).block_hook_bypass_scratch_rootswas opt-in and shipped empty, which left the host temp trees —where the harness's own per-session scratchpad sits — blocked. Measured at five blocks in one day
across four sessions with zero true positives. ADR 0003 clause 4 makes that a wrong SCOPE rather than
a wrong oracle, so the oracle is untouched.
Exempting the temp tree gives up no protection, which is the only reason a default is defensible:
hook::read_file_path, the entry every content guard reads its file through, already declines atemp-tree file when the project root lies outside that tree. The exemption's width is exactly the
width of the protection it is scoped out of. It is gated on
CLAUDE_PROJECT_DIRnaming a projectroot outside the temp tree, so a temp-rooted project (this repo's own
mktemp -dfixtures) stillblocks, and it resolves at run time because the scratchpad path carries a session id.
The memory tier is deliberately NOT a second default. It was, and two reviewers between them
showed why it cannot be: the "gives up no protection" argument does not carry, because
secret-pattern-detectionscans aWriteto.work/notes.mdtoday (verified: exit 2 on aPAT-shaped token). Exempting Bash redirects there would let
printf '<secret>' >> .work/notes.mdreach disk unscanned while the identical
Writestayed blocked — the same bypass Fix 4 closes on theMCP lane. The safer remedy was taken: withdraw the default rather than widen
secret-pattern-detectionon an argument from convention with no firing evidence (ADR 0003). Consequence:
printf '*' >> .work/.gitignorestill blocks; that issession-flow's own documented procedure, sothe conflict routes to the skill (#3720 — use
Write, which is scanned). The pre-existing asymmetrybetween the two content guards on the tier is filed as #3730.
The default is confirmed through symlink resolution before it grants. The lexical compare
alone would exempt a redirect on its spelling, so a symlink under a temp root pointing into the
repository let
echo <secret> > /tmp/to-repo/tracked.pythrough while the direct path blocked(reproduced, then fixed). Configured roots keep their documented lexical residual because the
"operator naming a root accepts its contents" ground still holds there; a shipped default has no
operator, so it resolves the target or its nearest existing ancestor and re-checks containment. Cost
stays on the grant path. Residual recorded: the check inherits the axis's case-folding, so a symlink
whose real spelling carries capitals is not resolved on a case-sensitive filesystem.
Also resolves a relative redirect target against the payload's
.cwd, which the axis previouslyrefused outright. Still refused when the command carries a
cd/pushd/popdor the payload namesno absolute cwd, so the refusal set only shrinks.
2. Every PreToolUse kill switch is read before the library it costs. All 15 shell-implemented
guards read
<name>_enabledthroughhook::check_enabled, which only exists once the 2,684-linehook-utils.shis sourced, so a disabled guard parsed the whole library before finding out it hadnothing to do.
scripts/check-killswitch-hoist.shis a new CI gate that fails a guard reversing theorder, with its own suite.
The saving is uneven and the split is stated rather than averaged: four guards run as their own
process and recover ~3.5 ms of ~5.3 ms; the other eleven are sourced into one process by
run-guards.sh, which has already loaded the library, so their hoist buys ~0.05 ms. They are heldto the same rule anyway — one shape is what makes it checkable, and each is invoked standalone by
its own contract test. Inlining trades one definition for fifteen copies, so the gate pins them to
hook::is_enabled's own reading of the env var and fails if that helper changes.3. Two one-line visibility fields.
statusMessageon bothdisk-hygienePreToolUse rows (itsStop row already had one), and a top-level
descriptionon 19 of 20hooks.jsonfiles, which allomitted it.
plugins/context-guard/hooks/hooks.jsonis deliberately excluded — assigned to theprompt-hooks lane, deferred rather than stalling this PR.
4. The content guards cover the GitHub MCP write lane.
secret-pattern-detectionandhardcoded-path-checkmatchedWrite|Edit|NotebookEditonly, so a session could be cleared by bothand still push the same secret through
mcp__github__push_filesormcp__github__create_or_update_file— a route with no local file to fix afterwards and nopre-commitlayer. Registered as a NEWhooks.jsonrow rather than by widening the always-onmatcher, so the write path pays nothing for tools it will never see.
mcp__github__delete_fileis deliberately not covered: its schema carries no content, so there isnothing for a content guard to scan and a delete cannot introduce a secret. Three local-only gates
are not applied on the new lane (project scope, git-working-tree,
git check-ignore), each becauseit is a statement about a local file; applying the scope guard in particular would have skipped
every MCP write, which is a silent hole rather than a scope. Both directions are pinned by tests.
5. Two conventions. ADR 0028 records the A/B/C hook-packaging taxonomy, which forecloses a
fleet-wide "split hooks out of every plugin" that was believed to be policy and turns out to be
unwritten — twelve of the twenty hook-carrying plugins would become empty shells.
docs/conventions/hook-input-rewriting/records that a PreToolUse hook denies or asks and neverpairs
allowwithupdatedInput.6. Nineteen plugin patch bumps. The changelog-parity gate refuses a change set that touches a
plugin's shipped files while reusing its published version. The
descriptionsweep touched sixteenplugins for one line each,
disk-hygienefor that plus twostatusMessages,source-controlforthat plus three hoisted kill switches, and
context-guardfor one hoisted kill switch. Each carriesa patch bump and a release entry naming which of those it was.
Verification
scripts/affected-tests.sh --runon the pruned head selects the shell suites and exits 3, thedocumented code for "non-shell suites selected but not run" — the run is recorded in the final
commit-time check below. On the previous head it was 146 shell suites passed, 0 failures. Nine of
the ten non-shell suites were run from their own lanes and pass — eight Python suites and the
autonomy
.mjssuite (544 checks); none of their inputs changed since.One suite could not be run and is not claimed as passing.
plugins/machine-health/.../Test-ClaudeTempRoot.Tests.ps1needs Pester, which is not installed inthis container, and the repo has no Pester CI lane. It is a transitive selection: this PR touches no
machine-healthfile and no changed file references it.The lint lane. It was red on every earlier head; six sub-gates reduced to three causes, each
reproduced locally and fixed: the nineteen missing bumps (Fix 6), five shellcheck notes at the
rcfile's stricter-than-warning severity (all literal single-quoted text, each now carrying a
directive with the reason at the site), one GNU-only
sed -iin the hoist suite (rewritten through asibling file), and the
docs/topics/slice — which is where thetyposandmarkdownfailureslived and which the contract-slice gate requires pruned. All four changelog-parity modes,
shell-portability against
origin/main, shellcheck at rcfile severity, markdownlint and typos overevery changed document, and
contract-slice-prune --check-diffare clean locally.Per-change evidence:
block-hook-bypassassertions were written first and FAILED against the unmodified hook (expected 0, got 2); the
adversarial-floor assertions beside them passed before and after. Both review findings on this
guard were likewise reproduced before being fixed, and each fix is pinned by a discriminating test
that fails with the fix neutered. Suite 591 → 602, identical with
CLAUDE_PROJECT_DIRset in theambient environment, because the
runhelper now pins it..tool_input.path, andrun-guards.sh's cachedhook::jq_fieldsis all-or-nothing per call — oneunserved filter sends the whole call to an uncached
jq. That cost two extra spawns on everyWrite/Edit, 50 ms → 60 ms, and no suite noticed. Fixed by priming the field: 51 ms → 52 ms, median
of three 30-run batches per tree. The new lane's own cost (47 ms single-file, 72 ms two-file push)
is stated in the plugin README per
.claude/rules/hook-budget.md.secret-pattern-detection68 → 86,hardcoded-path-check100 → 118, all 15hoisted guards pass,
check-killswitch-hoist.test.sh14/14 including a reversed-order fixturethat fails the gate and a mutated
hook::is_enabledthat trips its semantic pin.two: the note that
permissionDecisionReasonis hidden from Claude on anaskis contradicted bythe current reference, and
deferis not among the listedpermissionDecisionvalues. Neitherclaim is made.
validate-plugin-contracts.mjschecks 3,123 plugin files clean.origin/mainis merged in. It carried atypos-formatspawn-count fix whose absence made that suitefail at this branch's base — pre-existing, confirmed at the merge-base, and resolved by the merge
(144/0).
The locked Brief (PLAN.md, verbatim at the pre-prune commit)
TLDR
Act on the PreToolUse guard audit recorded in FINDINGS.md: fix the one guard measurably misfiring,
make the kill switches save what they promise, close two visibility gaps, close the MCP coverage
hole, and codify the two conventions the interview established. One draft PR carrying six changes.
Goal
The audit classified 17 PreToolUse guards and found that its ablation verdicts were the least
actionable part of it. The actionable findings were a guard firing four times with zero true
positives, a kill switch recovering 15 percent of a disabled hook's cost instead of 75, two
missing one-line visibility fields, and a content-guard blind spot on the GitHub MCP write
tools. This task ships those four, plus the ADR and convention that stop the next author
re-litigating the two policy questions the interview settled.
Constraints
migration paths. Breaking changes are acceptable and expected.
*_enableduserConfig boolean,read through the
CLAUDE_PLUGIN_OPTION_*hook mirror. The operator runs this marketplaceacross several projects and at work; per-hook control is the reason the hoist matters.
plugins/*/hooks/hooks.jsoniscontended across at least three sessions, and
plugins/context-guard/hooks/hooks.jsonisalready assigned to the prompt-hooks lane. A contested file defers to a follow-up rather than
stalling this PR.
lib/hook-utils.shand its 17 vendored copies stay untouched. Contended, and a possiblesplit of that file is a separate question.
scripts/affected-tests.sh --run, never the full suite. A changed file mappingto zero suites is an error, not "nothing to run".
.claude/rules/pr-body-contract.mdbefore creation, not after.Acceptance criteria
block_hook_bypass_scratch_rootshas a non-empty default covering the harness-designatedscratchpad and
.work/, andblock-hook-bypass.test.shcarries a MUST-stay-quiet case thatFAILS against the unmodified hook and passes after the change.
(Departed from in review:
.work/withdrawn — see Summary.)*_enabledswitch before sourcing any library,and
scripts/check-killswitch-hoist.shfails on a script that reverses that order.plugins/disk-hygiene/hooks/hooks.jsonPreToolUse rows carry astatusMessage, andevery uncontested
plugins/*/hooks/hooks.jsoncarries a one-line top-leveldescription.secret-pattern-detectionandhardcoded-path-checkinspect the content written bymcp__github__push_files,mcp__github__create_or_update_fileandmcp__github__delete_file, with contract tests covering each payload shape.(
delete_filecarries no content; recorded as deliberately not covered.)PLUGIN-PHILOSOPHY.mdcarries apointer row to it.
docs/conventions/hook-input-rewriting/README.mdstates the deny-or-ask posture, namesupdatedInputpaired with"ask"as the sanctioned escape hatch, and gives the principle ofleast astonishment as the basis.
scripts/affected-tests.sh --runpasses and selects a non-empty suite set for changes 1through 4.
Captured assumptions
analogue, and convention docs are owner docs for ongoing disciplines that point up to the
philosophy. So the taxonomy is an ADR and the rewrite posture is a convention.
descriptiontext for eachhooks.jsonis the implementer's to draft, one line perplugin, describing what that plugin's hook set does.
Any guard whose kill switch is read after a library that the switch check itself depends on is
an exception to be surfaced, not silently reordered.
Class B is autonomy, context-guard and disk-hygiene where the hook is the feature mechanism,
Class C is the 5 adjunct-to-skills candidates.
Out of scope
block-hook-bypass(filed as one issue).claude/posttool-hooks-review-ji6rl5).docs/conventions/hook-budget/(routes to guardrails dispatcher is the whole per-tool-call budget miss after the hook-performance program #3685).Related
docs/topics/claude-pretool-validation-hooks-84d7ka/(PLAN.md, the lockedBrief above, and FINDINGS.md, the 484-line audit) was pruned in the final commit per the
topic-docs convention. Pre-prune commit, for best-effort retrieval while GitHub retains the
object:
19318ff1ba9c5af12a93f34332f0468004c12c27—gh api "repos/melodic-software/claude-code-plugins/contents/docs/topics/claude-pretool-validation-hooks-84d7ka/FINDINGS.md?ref=19318ff1ba9c5af12a93f34332f0468004c12c27".Durable outcomes graduated to ADR 0028,
docs/conventions/hook-input-rewriting/, and the issuesbelow.
secret-pattern-detectionscans the memory tier the topic-docs convention designatesfor raw credentials; the asymmetry review surfaced, filed rather than widened without evidence
block-hook-bypass's remaining default-on scope, whichthis PR deliberately does not answer
session-flowprescribes a write commandguardrailsblocks; with the memory-tierdefault withdrawn this is now squarely the skill's to fix
records the rule; no split is executed here
docs/adr/0003-verification-guards-earn-default-on-by-measured-precision.md— clause 4, theauthority for rescoping rather than deleting, and the evidence bar that kept secret-pattern-detection scans the memory tier, which the topic-docs convention designates for raw credentials #3730 a filing
docs/adr/0019-share-code-across-plugins-by-vendoring-with-a-sync-gate.md— the packaginganalogue ADR 0028 is a sibling to
lib/hook-utils.shand its 17 vendored copies are untouched, so the sync gate is unaffected. ThePostToolUse half of the kill-switch hoist belongs to
claude/posttool-hooks-review-ji6rl5under anagreed split
🤖 Generated with Claude Code
https://claude.ai/code/session_01TY2eXP6jpKBfH4XSg2b5mj