A PreToolUse hook is the only thing in Claude Code that can refuse a tool call before it happens. You write a script, it gets the pending call as JSON on stdin, and it answers. That makes it the natural place to put an allow-list: this agent writes to src/ and state/, and nothing else.
The part that catches people is what happens when the script is wrong. Not wrong in its policy — wrong in its plumbing. A typo in a path, an exception on a field that wasn't there, a dependency that failed to resolve. The instinct from every other guard you've written is that a broken check is a loud check. Here it isn't. A broken hook is a silent one, and silence means the write goes through.
Every failure path lands in the same place
This is the exit code table from the hooks reference, and it's worth reading as a whole rather than looking up the row you need:
| Exit code | What happens |
|---|---|
0 |
stdout is parsed for JSON. No JSON, or malformed JSON, and the call proceeds through the normal permission flow. |
2 |
Blocking error. stderr is fed back to Claude as the reason. The call is blocked. |
| anything else | Non-blocking error. The call proceeds. The transcript shows a hook error notice; the rest goes to the debug log. |
One qualifier on that third row, added after checking it against the current reference: stdout is read on every exit code, not only 0, so a hook that exits 1 while printing a valid deny envelope does still deny. That doesn't rescue any of the cases below, because a hook that crashed, timed out or was never matched prints no envelope at all — the exit code is what's left when there is no decision to read. Exit 2 remains the one outcome JSON cannot override.
Only 2 blocks on its own. Exit 1 — the conventional Unix "something went wrong" that Node hands you for free on an uncaught exception — is in the third row. So:
- Your hook throws → Node exits 1 → the write happens.
- Your hook prints JSON with a typo in it → invalid JSON on exit 0 → the write happens.
- Your hook prints nothing because your matcher pattern never fired → the write happens.
- Your hook takes too long and gets cancelled → non-blocking error → the write happens.
- Your hook runs perfectly and returns no decision → the write happens.
Five roads, one destination. There is no failure of a PreToolUse hook that manifests as a blocked tool call, which means there is no failure of a PreToolUse hook that you will notice by using Claude Code normally.
The third row deserves singling out, because it is the one where none of the rest of this page applies: the reasons a hook never reaches your script at all are readable straight off your settings file, and no amount of getting the exit codes right will help if the matcher was never going to fire.
It gets quieter still. On tool events, a hook's stdout on exit 0 goes to the debug log — not the transcript, and not to Claude. console.log("blocked!") in your hook tells nobody anything. A hook that fired and allowed the write, and a hook that never ran at all, look identical from the outside.
The bug I actually shipped
The guard in question reads stdin, parses it, decides, and prints. Reading stdin looked like this:
const chunks = [];
for await (const chunk of stream) chunks.push(chunk);
return Buffer.concat(chunks).toString("utf8");
Buffer.concat throws a TypeError if the array contains strings rather than Buffers, and a stream that has had an encoding set on it — or one you handed a string to in a test — yields strings. So on a real payload it threw, every time.
And the throw was caught, by this:
try {
payload = JSON.parse(await readStdin(stdin));
} catch {
return 0; // no decision
}
Which is a catch I had written on purpose, for a reason I still think is right, and which turned a crash into a shrug. The guard permitted every write in every session and reported nothing. It had been read, reviewed, and reasoned about. What it had never been was run against a payload with an assertion on the other end.
The fix is one line — coerce before concatenating:
for await (const chunk of stream) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
Decode once at the end, not per chunk, so a multi-byte character split across a chunk boundary survives. But the bug isn't the interesting part. The interesting part is that nothing about the system was capable of telling me.
Pick a failure policy on purpose
Your catch block is the one place in this contract where you get to choose, because everything else defaults to permit. There are two defensible answers and you should be able to say which one you picked.
Fail open — swallow the error, return no decision, let the call proceed. The argument for it: a guard that blocks on its own bugs means one typo in an allow-list stops the agent working at all, at 3am, on a scheduled run, with nobody watching. The failure mode of a broken guard shouldn't be "nothing can be done".
Fail closed — write the reason to stderr and process.exit(2). The argument for it: a guard that permits on its own bugs isn't a guard, it's a decoration, and you will find out which one you had at the worst possible moment.
Fail open is only defensible if something else catches what gets through. In my case something does — a check that diffs git status before committing, which sees writes regardless of what produced them, including the shell writes a hook can't see at all. With that backstop, a hook that fails open degrades to "the mistake is caught later" rather than "the mistake is invisible". Without a backstop, choose closed:
try {
// ...decide...
} catch (err) {
process.stderr.write(`scope-guard failed: ${err.message}`);
process.exit(2); // fail closed, deliberately
}
Either way, write the reason down next to the code. A catch {} with no comment is not a policy, it's an accident that hasn't been noticed yet.
The test that would have caught it
Whichever policy you pick, the way to know your hook holds is to run it as a subprocess, feed it a real payload, and assert on what comes back. This is about fifteen lines and it is the entire difference between a guard and a hope:
import { execFileSync } from "node:child_process";
import assert from "node:assert/strict";
import test from "node:test";
import { fileURLToPath } from "node:url";
const HOOK = fileURLToPath(new URL("../.claude/hooks/scope-guard.mjs", import.meta.url));
function run(payload) {
try {
const stdout = execFileSync(process.execPath, [HOOK], {
input: typeof payload === "string" ? payload : JSON.stringify(payload),
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"], // else stderr goes to your terminal, not the result
});
return { code: 0, stdout, stderr: "" };
} catch (err) {
return { code: err.status, stdout: err.stdout ?? "", stderr: err.stderr ?? "" };
}
}
/** A hook denies either by exiting 2, or by exit 0 with a deny decision in its JSON. */
function denied({ code, stdout }) {
if (code === 2) return true;
try {
return JSON.parse(stdout)?.hookSpecificOutput?.permissionDecision === "deny";
} catch {
return false;
}
}
Check both shapes, not just the one you happen to use. They're both legal, and a hook that switches from one to the other later shouldn't quietly break its own tests.
Use fileURLToPath, not url.pathname. I wrote HOOK.pathname first, which on Windows produces /C:/Users/..., which Node resolves as C:\C:\Users\... and cannot find. The hook process died with MODULE_NOT_FOUND before executing a line — exit code 1, no stdout — and the two tests below that assert the hook allows something passed cleanly, because a hook that never ran allows everything. The harness reproduced the exact bug it exists to catch, on its first run. That is the whole article in one stack trace.
Then the cases:
const write = (file_path) => ({
hook_event_name: "PreToolUse",
tool_name: "Write",
tool_input: { file_path, content: "x" },
cwd: process.cwd(),
});
test("denies a write outside the allow-list", () => {
assert.ok(denied(run(write("/etc/passwd"))));
});
test("denies a sibling directory that shares a prefix", () => {
assert.ok(denied(run(write("src-secret/keys.json"))));
});
test("has no opinion about a write inside the allow-list", () => {
assert.ok(!denied(run(write("src/index.mjs"))));
});
test("garbage on stdin follows the declared failure policy", () => {
assert.ok(!denied(run("not json at all"))); // fail-open policy; flip for fail-closed
});
Four assertions, and the first one is the one that matters: a guard whose failure mode is silence needs a test that asserts it actually denies something. Not that it runs, not that it exits 0, not that it doesn't throw — that it says no. Everything I had before this was a test that a broken hook would have passed.
That shape turns up well outside hooks, and it's worth recognising when it does. Anything whose success and failure both look like nothing needs an instrument pointed at it from outside — which is exactly why a Hacker News submission can be dead while showing you a completely normal page, and why the only honest check is a fetch of the resource rather than a look at your own view of it.
The second case is worth keeping too. "/app/src-secret".startsWith("/app/src") is true, so an allow-list built from string prefixes lets a sibling directory straight through. Use path.relative and check the result doesn't start with ...
Both of those are readable off the script without running it, which is why they are two of the checks on the hook check page: paste a hook script in and it reports a guard with no path that can refuse, a catch that swallows the decision, and a prefix comparison like the one above. It runs in your browser, so nothing is uploaded and there is nothing to install — but it is static analysis, and it is not a substitute for the fifteen lines of test above.
The part a test can't reach
None of this tells you the hook is wired up. Matchers filter on tool name, so a matcher of Write|Edit never sees a file created by Bash, because Bash isn't a file-writing tool as far as the matcher is concerned — it's a shell, and what it does with a redirect is its own business. That gap isn't a bug in your hook and no test of your hook will find it. It's the reason the after-the-fact diff exists.
So the honest summary is: a hook is a good check that fails quietly, and the fix for quiet is a test that makes it speak. Run your hook against a payload, assert it denies, and pick the failure policy on purpose rather than by inheritance. Fifteen lines of test, and the thing you left running overnight is a guard instead of a comment.
Originally published at fewparts.co.uk.
Agent Guardrails Kit is the free, assembled version of this code — same modules, wired together, with the tests.
Top comments (0)