Skip to content

fix(hooks): PreToolUse guard remediation — scratch-root default, kill-switch hoist, MCP coverage, two conventions - #3727

Merged
kyle-sexton merged 16 commits into
mainfrom
claude/pretool-validation-hooks-84d7ka
Sep 5, 2026
Merged

kyle-sexton merged 16 commits into
mainfrom
claude/pretool-validation-hooks-84d7ka

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

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 shipped block-hook-bypass
exemption. 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-bypass ships one scratch root exempt instead of none (guardrails 0.32.0).
block_hook_bypass_scratch_roots was 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 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. It is gated on CLAUDE_PROJECT_DIR naming a project
root outside the temp tree, so a temp-rooted project (this repo's own mktemp -d fixtures) still
blocks, 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-detection scans a Write to .work/notes.md today (verified: exit 2 on a
PAT-shaped token). Exempting Bash redirects there would let printf '<secret>' >> .work/notes.md
reach disk unscanned while the identical Write stayed blocked — the same bypass Fix 4 closes on the
MCP lane. The safer remedy was taken: withdraw the default rather than widen secret-pattern-detection
on an argument from convention with no firing evidence (ADR 0003). Consequence:
printf '*' >> .work/.gitignore still blocks; that is session-flow's own documented procedure, so
the conflict routes to the skill (#3720 — use Write, which is scanned). The pre-existing asymmetry
between 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.py through 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 previously
refused outright. Still refused when the command carries a cd/pushd/popd or the payload names
no 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>_enabled through hook::check_enabled, which only exists once the 2,684-line
hook-utils.sh is sourced, so a disabled guard parsed the whole library before finding out it had
nothing to do. scripts/check-killswitch-hoist.sh is a new CI gate that fails a guard reversing the
order, 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 held
to 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. statusMessage on both disk-hygiene PreToolUse rows (its
Stop row already had one), and a top-level description on 19 of 20 hooks.json files, which all
omitted it. plugins/context-guard/hooks/hooks.json is deliberately excluded — assigned to the
prompt-hooks lane, deferred rather than stalling this PR.

4. The content guards cover the GitHub MCP write lane. secret-pattern-detection and
hardcoded-path-check matched Write|Edit|NotebookEdit only, so a session could be cleared by both
and still push the same secret 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 layer. Registered as a NEW hooks.json row rather than by widening the always-on
matcher, so the write path pays nothing for tools it will never see.

mcp__github__delete_file is deliberately not covered: its schema carries no content, so there is
nothing 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 because
it 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 never
pairs allow with updatedInput.

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 description sweep touched sixteen
plugins for one line each, disk-hygiene for that plus two statusMessages, source-control for
that plus three hoisted kill switches, and context-guard for one hoisted kill switch. Each carries
a patch bump and a release entry naming which of those it was.

Verification

scripts/affected-tests.sh --run on the pruned head selects the shell suites and exits 3, the
documented 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 .mjs suite (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.ps1 needs Pester, which is not installed in
this container, and the repo has no Pester CI lane. It is a transitive selection: this PR touches no
machine-health file 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 -i in the hoist suite (rewritten through a
sibling file), and the docs/topics/ slice — which is where the typos and markdown failures
lived 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 over
every changed document, and contract-slice-prune --check-diff are clean locally.

Per-change evidence:

  • Repro-first, as the hook-precision convention requires. The stay-quiet block-hook-bypass
    assertions 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_DIR set in the
    ambient environment, because the run helper now pins it.
  • A regression caught by measurement, not by tests. Both content guards ask for
    .tool_input.path, and run-guards.sh's cached hook::jq_fields is all-or-nothing per call — one
    unserved filter sends the whole call to an uncached jq. That cost two extra spawns on every
    Write/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.
  • Guard suites: secret-pattern-detection 68 → 86, hardcoded-path-check 100 → 118, all 15
    hoisted guards pass, check-killswitch-hoist.test.sh 14/14 including a reversed-order fixture
    that fails the gate and a mutated hook::is_enabled that trips its semantic pin.
  • Docs claims checked against the live hooks reference rather than the audit notes, which caught
    two: the note that permissionDecisionReason is hidden from Claude on an ask is contradicted by
    the current reference, and defer is not among the listed permissionDecision values. Neither
    claim is made.
  • validate-plugin-contracts.mjs checks 3,123 plugin files clean.

origin/main is merged in. It carried a typos-format spawn-count fix whose absence made that suite
fail 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

  • Alpha repo, single known consumer. No deprecation shims, no compatibility aliases, no
    migration paths. Breaking changes are acceptable and expected.
  • Configurability is non-negotiable. Every guard keeps its *_enabled userConfig boolean,
    read through the CLAUDE_PLUGIN_OPTION_* hook mirror. The operator runs this marketplace
    across several projects and at work; per-hook control is the reason the hoist matters.
  • Cross-lane files are announced before they are touched. plugins/*/hooks/hooks.json is
    contended across at least three sessions, and plugins/context-guard/hooks/hooks.json is
    already assigned to the prompt-hooks lane. A contested file defers to a follow-up rather than
    stalling this PR.
  • lib/hook-utils.sh and its 17 vendored copies stay untouched. Contended, and a possible
    split of that file is a separate question.
  • Validate with scripts/affected-tests.sh --run, never the full suite. A changed file mapping
    to zero suites is an error, not "nothing to run".
  • The PR opens as a draft and flips to ready when the work is done. The body satisfies
    .claude/rules/pr-body-contract.md before creation, not after.

Acceptance criteria

  1. block_hook_bypass_scratch_roots has a non-empty default covering the harness-designated
    scratchpad and .work/, and block-hook-bypass.test.sh carries a MUST-stay-quiet case that
    FAILS against the unmodified hook and passes after the change.
    (Departed from in review: .work/ withdrawn — see Summary.)
  2. All 17 PreToolUse guard scripts read their *_enabled switch before sourcing any library,
    and scripts/check-killswitch-hoist.sh fails on a script that reverses that order.
  3. The two plugins/disk-hygiene/hooks/hooks.json PreToolUse rows carry a statusMessage, and
    every uncontested plugins/*/hooks/hooks.json carries a one-line top-level description.
  4. secret-pattern-detection and hardcoded-path-check inspect the content written by
    mcp__github__push_files, mcp__github__create_or_update_file and
    mcp__github__delete_file, with contract tests covering each payload shape.
    (delete_file carries no content; recorded as deliberately not covered.)
  5. An ADR records the A/B/C hook-packaging taxonomy, and PLUGIN-PHILOSOPHY.md carries a
    pointer row to it.
  6. docs/conventions/hook-input-rewriting/README.md states the deny-or-ask posture, names
    updatedInput paired with "ask" as the sanctioned escape hatch, and gives the principle of
    least astonishment as the basis.
  7. scripts/affected-tests.sh --run passes and selects a non-empty suite set for changes 1
    through 4.

Captured assumptions

  • Placement follows repo precedent, not explicit instruction. ADR 0019 is the packaging
    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.
  • The description text for each hooks.json is the implementer's to draft, one line per
    plugin, describing what that plugin's hook set does.
  • The hoist is behavior-preserving for enabled guards. Only the disabled path changes cost.
    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.
  • The A/B/C classification is settled: Class A is 12 plugins where hooks are the plugin,
    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

Related

🤖 Generated with Claude Code

https://claude.ai/code/session_01TY2eXP6jpKBfH4XSg2b5mj

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
@kyle-sexton
kyle-sexton marked this pull request as ready for review September 4, 2026 20:54
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-04T20:59:11.298460Z 2df0d85 Draft marked ready
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 2m 28s —— View job


Code review in progress

  • Gather context (PR body, changed files, CLAUDE.md/AGENTS.md)
  • Run git diff origin/main...HEAD and review changed files
  • Check skip-gate conditions (not draft, not closed, substantial diff → proceeding)
  • Review scratch-root exemption logic in block-hook-bypass.sh
  • Review MCP write-lane coverage in secret-pattern-detection.sh / hardcoded-path-check.sh
  • Verify kill-switch hoist consistency across all 15 guards + new CI gate
  • Review CI wiring, hooks.json changes, and docs/ADR changes
  • Post findings as inline comments + summary

@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 7m 16s —— View job


Security review complete

  • Gather context (PR diff, CLAUDE.md/AGENTS.md, skill instructions)
  • Skip-gate check (open PR, security-relevant surface, no prior applicable review)
  • Review diff for logic / trust-boundary / injection / secret-handling / Actions security issues
  • Adversarially validate candidate findings
  • Post findings

Reviewed the diff against origin/main (49 files) per /review:security-review. Scope: .github/workflows/ci.yml (Actions), the block-hook-bypass scratch-root default and cwd-relative-redirect resolution, the kill-switch hoist across 15 guards, the new GitHub MCP write lane on secret-pattern-detection/hardcoded-path-check, and the metadata/doc changes.

One finding posted inline (IMPORTANT): the memory-tier (.work/) default added to block-hook-bypass's scratch-root exemption reopens the exact class of secret-content bypass this PR closes for the GitHub MCP lane. The PR's "gives up no protection" claim holds for the temp-tree default (backed by hook::read_file_path's own temp-root decline, so a Write/Edit there was never content-scanned either) but not for .work/secret-pattern-detection.sh has no gitignore or .work/ carve-out and does scan .work/ writes made through Write/Edit today. Since secret-pattern-detection/hardcoded-path-check only match Write|Edit|NotebookEdit (+ the new MCP row), a printf '<secret>' >> .work/notes.md that this PR's own new fixtures confirm is now exempted (block-hook-bypass.test.sh:1519-1528) reaches disk with zero content scanning — previously it was blocked and had to go through the scanned Write path. Details, code citations, and a fix suggestion are in the inline comment on block-hook-bypass.sh.

Everything else reviewed clean:

  • ci.yml: additive steps only, no permission/trigger changes, no injection surface.
  • The 15 kill-switch hoists all mirror hook::is_enabled's exact semantics (correct default per guard); no fail-open regression.
  • The new MCP write lane correctly omits the project-scope/git-working-tree/git check-ignore local-only gates (applying them would have silently skipped every MCP write) and correctly excludes mcp__github__delete_file (no content field).
  • The .cwd-relative redirect resolution in block-hook-bypass fails closed on cd/pushd/popd and on a missing/relative cwd, so the fail-closed set only shrinks by targets it can prove are placeable.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread plugins/guardrails/hooks/block-hook-bypass.sh
Comment thread plugins/guardrails/hooks/block-hook-bypass.sh Outdated
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Last security-reviewed head: 2df0d8543923cb3bf48ce70ef49723bb550f4dc3. On the next push, the relevance gate compares only the commits since this SHA; delete this comment to force a full re-review.

…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
@kyle-sexton
kyle-sexton merged commit 5f5665e into main Sep 5, 2026
12 checks passed
@kyle-sexton
kyle-sexton deleted the claude/pretool-validation-hooks-84d7ka branch September 5, 2026 02:32
kyle-sexton added a commit that referenced this pull request Sep 5, 2026
…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>
kyle-sexton added a commit that referenced this pull request Sep 5, 2026
… 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>
kyle-sexton added a commit that referenced this pull request Sep 5, 2026
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>
kyle-sexton added a commit that referenced this pull request Sep 5, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PreToolUse guard remediation: scratch-root default, kill-switch hoist, MCP coverage, two conventions

2 participants