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.

Tier
3 Pro
Build position
8
Throughlines
F Determinism wrapping stochasticity · B Instruction packaging · E Tool surface and trust boundary
Verdict
load-bearing
Commit
f0750e4
Demo
scripts/terms/demo-15.ts
Runs in
the course repository

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. evaluate runs all four and denies if any denies, so no rule can shadow another. Short-circuiting would make the reported reason depend on declaration order.
  • stayInsideWorktree compares resolved paths with a separator. /repo-evil must not count as inside /repo; a bare startsWith gets 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.