Claude Code First, specification version 0.1.0. The plain-language guide
Version 0.1.0. Grade R3/S3.
F301 A guard hook refuses only the calls that reach it, and its recorded gaps failed silently
| Field | Value |
|---|---|
| Status | active |
| Topic | Gates, verification, and hooks against instructions |
| Grade | R3/S3 |
| Cases | roadkeep, Shio, winwright, freewilly |
| Contrary | none |
| Harness facts | H4, H5, H6, H10 |
Claim
Hooks in the corpus are built to fail open, so that their own errors never block a turn, and every recorded gap in a hook (a tool it was not matched on, a session where it was not loaded, a clone where it was not built, a hook that misread its own write) let work through unguarded, with nothing stating that the guard was not guarding, until someone noticed after the fact; the backstop two projects name for what a hook misses is a gate that runs elsewhere, at the commit or in CI.
Mechanism
A PreToolUse hook answers only for the tool calls it is matched on, in sessions where it is installed and able to run (H4, H6). A hook that exits non-zero on its own bug would block every call (H5), so the hooks here allow on any failure. The price of that design is that a hook which is absent, unmatched or broken looks exactly like a hook that approved.
Observations
- roadkeep. The guard is fail-open by design: "Every failure allows." [roadkeep@91754240:src/roadkeep/guarding.py#L28],
because "A guard that denies on its own errors turns one typo" [roadkeep@91754240:src/roadkeep/guarding.py#L29] into a repository nobody can edit, and "the gate is the backstop for the barrier" [roadkeep@91754240:src/roadkeep/guarding.py#L31]. Its first gap was the shell: "The guard denies Edit and Write and answers silence to a shell command writing the same file" [roadkeep@91754240:docs/CHANGELOG.md#L989], closed by matching Bash [roadkeep@e1d8056b]; the next was "An approved shell write leaves a valid line and no record of who wrote it" [roadkeep@91754240:docs/CHANGELOG.md#L993], closed at the Stop hook [roadkeep@3112274d]. A second, non-blocking hook, the version bump in .githooks/pre-commit, "re-armed itself for ever" [roadkeep@91754240:.githooks/pre-commit#L20], "measured over eight commits, the checkout reaching 0.1.392 while HEAD said 0.1.388" [roadkeep@91754240:.githooks/pre-commit#L22], without blocking anything, since "It never blocks a commit." [roadkeep@91754240:.githooks/pre-commit#L12] The fix is [roadkeep@33a9dc29].
- Shio. In Claude Code on the web the plugin did not load, so "the guard that denies a hand-edit of the roadkeep-owned docs is absent" [shio@c215718bb].
The committed launcher that answered it keeps the fail-open rule: it "no-ops silently when no engine is found (never blocks a turn)" [shio@c215718bb].
- winwright. The project's own guard, which denies a hand-written harness, failed on a fresh
clone with no build: a "guard that is not there refuses nothing. The one surface whose whole job is to be in the way was the" [winwright@861b82e:README.md#L113] "one that went quiet. The launcher exits 1 and never 2" [winwright@861b82e:README.md#L114]. The assembly error on each write did not say that nothing was being refused. The fix [winwright@3bb1a26] kept the hook non-blocking but made it name the missing build. The design reason is stated beside it: a hook that denies what it did not understand is one "that gets removed, after which nothing is guarded at all." [winwright@861b82e:README.md#L130]
- freewilly. With the lint workflow deleted [freewilly@cd630d6], the hooks were the only
guard, and CI now records what that cost: the hooks "refuse a hand edit on the machine doing the writing; that workflow is the half that catches" [freewilly@c1c2eaf:.github/workflows/check.yml#L14] "drift arriving by any other route, which for a while reached" [freewilly@c1c2eaf:.github/workflows/check.yml#L15] main. The lint returned ten days later [freewilly@714360e]. The committed launcher's "Never block a turn." [freewilly@c1c2eaf:.claude/hooks/roadkeep-launch.py#L48] is a copy.
- polyweave. The no-clobber hook states that it copies the fail-open rule, "hold here too, and the second is the more important" [polyweave@6d1c136:.claude/hooks/no-clobber.py#L14]:
"A hook that denies on its own bug is worse than" [polyweave@6d1c136:.claude/hooks/no-clobber.py#L17] no hook. No gap of it is recorded at the pin.
Independence
Four independent origins of a gap: roadkeep (the shell gap and the self-re-arming bump hook), Shio (the web session), winwright (its own .NET guard on a fresh clone) and freewilly (drift past the hooks while CI did not lint). The web-session gap was found once, in Shio: the same web session committed Shio's launcher at 10:50 on 2026-08-08 [shio@c215718bb] and, two minutes later, a roadkeep README section that "Points at Viglet Shio's reference copy." [roadkeep@7944b1a7] roadkeep's own committed launcher came four days after [roadkeep@7243dc05], so the launcher originated in Shio, not in roadkeep. The launchers in freewilly and polyweave and the fail-open rule in polyweave's no-clobber hook are copies and count once. The fail-open design has two independent statements, roadkeep's guard and winwright's README; whether winwright's wording was carried from roadkeep cannot be told from the pins.
Counter-evidence
None found at the pins. No source records a hook in the corpus failing closed and blocking work, so the claim that failing open is the better trade rests on the projects' stated reasoning (S1), not on an observed closed failure. The ledgers record no count of how often a guard denied or asked, so the pins cannot say how much the hooks prevented while they did run.
Grade
R3: gaps recorded independently in four projects. S3: roadkeep's bump hook failure is counted (eight commits, versions 0.1.388 against 0.1.392). That hook is a git hook, not a Claude Code hook; for the PreToolUse guards alone the strongest observations are dated incidents (RK128, the Shio web session, WW221), which is S2. Not S4: no check at the pins fails when a guard is absent from a session. freewilly's DD115 test fails if the guard is not wired in the committed settings, but it cannot see a session where the plugin did not load, and no source records it failing.