Course · Term 15 of 30
Hooks
Every lesson in this course builds one system: a stablecoin reserve attestation registry that discovers issuers, reads the PDFs they publish, and refuses to publish a field it could not read. What it is and where this lesson sits.
The term this registry's central promise rests on. "Refuses to publish a field it could not read" is not a policy, a README line, or a review habit. It is code sitting between the extraction and the write that returns deny.
The failure it fixes
An instruction is a request. A hook is a refusal.
Every project has the rule written down somewhere: don't publish a number you couldn't read, don't commit a key, don't write outside your working directory. Written down, it holds while people are paying attention and fails the week someone is not. That is not a discipline problem, it is a category error: prose in a file cannot decline to execute a tool call.
The specific failure here has a shape worth naming. An extractor reads an attestation PDF and gets eight fields cleanly and one not at all. The tempting behaviour is to publish the eight and leave the ninth null. What actually happens under load is that the ninth gets a plausible value from somewhere nearby, and it is published with a page number, and the page number makes it look checked. Absence would have been honest. A cited guess is worse than nothing precisely because the citation is the thing readers trust.
Break it first
Send a payload that publishes a field its own extraction record says it could not read:
$ echo '{"target":"...","cwd":"...","payload":{
"fields":{"reserveTotalUsd":{"value":1,"page":4}},
"extraction":{"unreadable":["reserveTotalUsd"]}}}' \
| npx tsx .claude/hooks/pre-tool-use.ts
DENY unread-fields: field(s) published while listed unreadable: reserveTotalUsd
deny
$ echo $?
2
Now delete the hook and run the same pipeline. It publishes. Nothing in the output distinguishes that record from a correctly read one.
The mechanism
src/hooks.ts is four pure rules over a HookInput, and
.claude/hooks/pre-tool-use.ts is the process the harness invokes: stdin in,
exit code out.
Three decisions carry the unit.
The exit contract is "0 allows, ANY non-zero denies." Not "2 denies". The
first version of the hook said 2, and then crashed on a module resolution error
and exited 1. A caller matching === 2 would have read that crash as permission
to proceed. A gate is at its most dangerous when it is broken, so the safe value
has to be one specific code and every other outcome has to be refusal.
A rule that throws is a deny. evaluate() wraps each rule and converts an
exception into a refusal. A gate that opens when it breaks is worse than no gate,
because it is trusted.
cwd comes from the invocation, never from the repository root. That is term 13's finding, enforced here. The root-relative version is correct with one agent and silently wrong with four.
Build it
Four rules. stayInsideWorktree is term 13's rule made executable.
unreadFieldsDenied is the domain promise: no field published while listed
unreadable, and no field published without a page. noSecretsWritten is term
5's pattern set at the write boundary. withinBudget calls charged(), which
is ruling 5's debiased estimate.
That last dependency is why the estimator fix had to land before this commit.
Before it, charged() ran forty-four per cent high. An inflated estimate inside
a refusal gate declines attestations it could have read, and an issuer the
pipeline declined to read is indistinguishable downstream from an issuer that
published nothing.
The receipt
artifacts/15-hooks.json. Eight paths through the real hook process with real
exit codes, plus the crash case:
ok clean write exit 0 allow
ok field published while listed unreadable exit 2 deny
ok field published with no page citation exit 2 deny
ok payload cannot say what it failed to read exit 2 deny
ok write escapes the worktree exit 2 deny
ok credential in the payload exit 2 deny
ok over declared budget exit 2 deny
ok unparseable input exit 2 deny
ok a crashed hook is a deny exit 1
Twelve tests, and two mutations proving they bite:
unreadFieldsDenied always allows -> 3 failed | 9 passed, demo FAIL
a throwing rule allows instead -> 1 failed | 11 passed
restored -> 12 passed
Where it overlaps
Line F is determinism wrapping stochasticity, and this is its clearest instance in the repo: a deterministic gate in front of a stochastic producer. Line B is instruction packaging escalated past prose into something that executes. Line E is the trust boundary, and a hook is where that boundary is actually drawn rather than described.
Sharp edges
- A hook that only runs interactively is not a control. It has to be in the path the headless run takes too, which is term 18, one commit later.
- Rule order does not matter and must not.
evaluateruns all four and denies if any denies, so no rule can shadow another. Short-circuiting would make the reported reason depend on declaration order. stayInsideWorktreecompares resolved paths with a separator./repo-evilmust not count as inside/repo; a barestartsWithgets that wrong and there is a test for it.- The hook cannot see what it was not sent. It gates the payload handed to it. A tool that writes by some other route is outside its reach entirely, which is an argument for a narrow tool surface rather than for a cleverer hook.
A personal teaching project, in development. The registry it builds is a teaching artifact and is not an assurance opinion about any issuer. Not affiliated with or endorsed by any employer, or by any vendor whose tools it describes. Where a unit depends on a specific flag or path, it names the version it was verified against. All units.