Skip to content

hooks: make the pre-push gate refuse inside its own budget instead of being cancelled open - #61

Merged
webjema merged 4 commits into
mainfrom
fm/hook-gate-timeout-fails-open-p9
Aug 16, 2026
Merged

webjema merged 4 commits into
mainfrom
fm/hook-gate-timeout-fails-open-p9

Conversation

@webjema

@webjema webjema commented Aug 16, 2026

Copy link
Copy Markdown
Owner

The starter bundle's pre-push gate fails open on timeout. bin/fm-hooks-install.sh emits the hook with "timeout": 300; when the project's checks outrun that, Claude Code cancels the hook, and a cancelled hook does not block - the tool call carries on through the normal permission flow. The harness does not read the cancellation as approval, it simply never hears a verdict, but the push proceeds ungated either way.

That is measured, not inferred. On one project seven pushes completed after their gate was cancelled without concluding (hook_cancelled, timedOut: true, durationMs: 300481). A warm check there is ~179s and a cold one exceeds 300s, so the gate was absent exactly when the repo was slow.

This is worse than an absent gate. Every project's AGENTS.md tells its crew the quality floor is enforced on push whether or not an agent cooperates, and a cancelled hook produces neither a block nor a warning.

The fix: refuse inside the budget

The gate now watches its own clock rather than waiting to be cancelled.

  • GATE_BUDGET=280, GATE_GRACE=5, GATE_HOOK_TIMEOUT=300, emitted from one place in the installer. budget + grace < timeout is the no-fail-open invariant, and a test asserts it against the emitted artifacts so the three numbers cannot drift apart.
  • gate_run gives all checks one shared deadline (SECONDS is the whole hook's age, not each check's), and a check that overruns exits 2 with a named reason.
  • The watchdog is bash, not timeout(1). Where that binary is absent the hook would exit 127, and only exit 2 blocks a tool call - so the dependency would itself be a silent fail-open.
  • Check and watchdog each start under set -m and are signalled by process group. Signalling the check alone is not enough: npm run test is a shell that spawns the real runner, and a surviving grandchild keeps holding the hook's stdout after the hook is done.
  • TERM, then KILL after the grace. The budget has to be a deadline and not a request: a check that traps TERM and cleans up slowly, ignores it, or is stopped rather than killed would otherwise carry the hook past the ceiling and back into fail-open.
  • Deadline is checked before status, because a check killed at the deadline may still exit 0 on its way out, and that is not a verdict.

Raising timeout to 900 was considered and rejected: it moves the ceiling without removing it, and keeps fail-open semantics.

The price, stated rather than hidden

A push against a cold cache is now refused instead of allowed. The remedy is the workflow crews already follow - run the check once by hand, which warms the cache, then push. That price is named in the header, in the installer's summary output, and in the refusal message the agent actually sees.

How this sits with the core.hooksPath work

These are complementary, not competing. This PR fixes the starter bundle that every project inherits. thecompany is separately moving its own gate into git via core.hooksPath (PR kunchenguid#181). A project that has not made that move still gets a gate that refuses rather than one that quietly disappears.

Whether the bundle itself should eventually emit the core.hooksPath shape is an open question, and it belongs to hook-matcher-skips-floor, which is also where the matcher half of the original report lands. Neither is touched here.

Tests

tests/fm-hooks-install.test.sh grows from (a)-(f) to (a)-(j):

  • (g) the emitted budget plus grace stays under the emitted hook timeout
  • (h) a check that outruns the budget refuses the push, by name
  • (i) a check that ignores TERM is ended anyway, inside the budget
  • (j) a check that simply fails still blocks, and one that passes still lets the push through

(h) through (j) drive the real emitted hook end to end against a committed fixture project with an npm shim, with the deadline shrunk to 3s so an overrun is measured in seconds. (i) hangs without the KILL escalation, verified by stripping it from a scratch copy.

bin/fm-lint.sh clean (ShellCheck 0.11.0). Full suite: 80 passed, exit 0.

The behaviour was also exercised by hand against a real emitted hook: budget overrun, TERM-ignoring check, ordinary failure, typecheck failing before tests run, budget exhausted before the last check, both passing, non-push command ignored, no controlling terminal, grandchild survival, and an orphan sweep afterwards.

🤖 Generated with Claude Code

Nick and others added 4 commits August 16, 2026 02:18
The emitted pre-push gate carried `"timeout": 300` and nothing else. When a
project's checks outran it, Claude Code recorded `hook_cancelled` and ran the
push anyway: measured on one project, seven pushes completed after their gate
was cancelled without ever reaching a verdict, and a warm check there already
costs ~179s of the 300s. So the floor was present when the repo was fast and
absent when it was slow, which is the wrong way round.

The gate now watches its own clock. GATE_BUDGET (280s) is the deadline it
enforces on itself, GATE_HOOK_TIMEOUT (300s) is what settings.json allows it,
and the gap is what turns a slow check into a named refusal instead of a silent
pass. Both numbers are emitted from one place so they cannot drift apart, and
the tests assert the ordering.

Raising the ceiling to 900 was the alternative and is rejected: it moves the
number without removing the fail-open, and it is wrong again the next time the
repo grows.

The watchdog is bash rather than timeout(1). A missing timeout binary exits
127, and only exit 2 blocks a tool call, so the dependency would itself be a
silent fail-open. It signals the check's whole process group: killing `npm run
test` alone leaves the real runner holding the hook's stdout, which the new
test caught by taking 60s to run.

The price is real and stated rather than hidden - a push against a cold cache
is now refused instead of allowed, and the remedy is the workflow crews already
follow: run the check once by hand, which warms the cache, then push.

Nothing is weakened on the way. A check that fails still blocks by name, a
check that passes inside the budget still lets the push through, and the
uncommitted-tree refusal is untouched.
Review found the first cut narrowed the fail-open instead of closing it. The
watchdog only sent TERM and then waited, so the hook returned when the check
chose to die rather than when the budget said. Measured against the shipped
code with the budget at 3s: a check that traps TERM and cleans up for 8s ran
11s, and a check that ignores TERM never returned at all. In production the
slack is 280 to 300, so either shape carries the hook past the harness timeout,
which cancels it and lets the push through - the exact failure this is for.

The watchdog now escalates to KILL after GATE_GRACE, which also ends a check
that is stopped rather than killed. The test that proves it is the one shape
none of the others covered: a check that ignores TERM. Without the escalation
that test hangs; with it the gate refuses in four seconds.

Two smaller things from the same review. The watchdog is now started under
set -m and signalled by group, so it stops leaking an orphan sleep per check
per push. And the refusal wording no longer claims a check "did not reach a
verdict" when the budget was spent before it ever started, or when it landed
exactly on the boundary.

The invariant the tests assert is now budget + grace < hook timeout, since the
kill is what bounds the hook rather than the deadline alone.
The emitted header said an overrun is "read as consent". That is an
interpretation, not a measurement: what was measured on one project is
hook_cancelled with timedOut true, and seven pushes completing afterwards.
The harness does not treat the cancellation as approval - it simply never
hears a verdict - and the push proceeds ungated either way.

This file is firstmate's starter bundle, so the sentence ships to every
project it touches; two documents in one fleet should not describe the same
mechanism two ways. Adopt the measured phrasing here and in the test matrix's
rationale. Comments only; no behavior change.
@webjema
webjema merged commit 9b7c9d3 into main Aug 16, 2026
3 checks passed
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.

1 participant