You wrote a PreToolUse hook, you tested it, and it denies what it's supposed to deny. Then you wired it into settings.json, ran the agent, and watched it write to a file it should have refused.
Now what? There are two possibilities and they look exactly the same from where you're sitting. Either the hook ran and decided to allow the write, or the hook never ran at all. Nothing in the transcript distinguishes them, because a hook that allows is a hook that says nothing, and on tool events a hook's stdout on exit 0 goes to the debug log rather than to you. Silence is the success case and silence is also every failure case.
This is the gap the previous piece ended on. Testing your hook proves the hook is correct. It says nothing about whether Claude Code is calling it.
First, the three checks that cost nothing
Before writing any code, rule out the boring answers. In rough order of how often they're the actual problem:
The settings file isn't the one being read. Hooks live under hooks in settings.json, and there are several of those: .claude/settings.json in the project, .claude/settings.local.json beside it, and ~/.claude/settings.json for you personally. Editing the wrong one produces a config that is completely valid and completely inert. Check which file you actually opened, not which one you meant to.
The config was edited while the session was running. Hook configuration is captured when the session starts. A hook you added ten minutes ago, in a session you started an hour ago, is not running — and this one is especially convincing, because the file on disk is correct and you can see it right there.
The matcher doesn't match. A matcher filters on tool name, and it has to match the name the tool is actually called. Write|Edit never fires for a file created by Bash, because as far as the matcher is concerned Bash is a shell and what it does with a redirect is its own business. If you want to know what's firing, claude --debug prints hook activity as it happens.
If none of those is it, stop guessing and make the hook write down what happened.
A wrapper that records every invocation
The idea is small: don't change your hook, wrap it. Put a process in front that appends a line to a file, runs the real hook with the same bytes on stdin, and forwards its stdout, stderr and exit code untouched.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit|MultiEdit|NotebookEdit",
"hooks": [
{ "type": "command", "command": "node .claude/trace-hook.mjs node .claude/guard.mjs" }
]
}
]
}
}
And the wrapper itself:
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { appendFileSync, mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
const TRACE = process.env.HOOK_TRACE ?? resolve(process.cwd(), ".claude/hook-trace.jsonl");
function record(entry) {
try {
mkdirSync(dirname(TRACE), { recursive: true });
appendFileSync(TRACE, JSON.stringify({ at: new Date().toISOString(), ...entry }) + "\n");
} catch {
// Tracing must never change the outcome of the hook it is watching.
}
}
async function readStdin(stream = process.stdin) {
const chunks = [];
for await (const chunk of stream) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
return Buffer.concat(chunks);
}
const [command, ...args] = process.argv.slice(2);
const raw = await readStdin();
let payload = null;
try {
payload = JSON.parse(raw.toString("utf8"));
} catch {
// A payload we cannot parse is still an invocation worth recording.
}
record({
phase: "invoked",
event: payload?.hook_event_name ?? null,
tool: payload?.tool_name ?? null,
file: payload?.tool_input?.file_path ?? null,
bytes: raw.length,
});
const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] });
let out = "", err = "";
child.stdout.on("data", (d) => { out += d; process.stdout.write(d); });
child.stderr.on("data", (d) => { err += d; process.stderr.write(d); });
child.on("error", (e) => {
record({ phase: "spawn-failed", error: e.message });
process.exit(1);
});
child.stdin.on("error", () => {});
child.stdin.end(raw);
child.on("close", (code) => {
let decision = null;
try {
decision = JSON.parse(out)?.hookSpecificOutput?.permissionDecision ?? null;
} catch {
decision = out.trim() === "" ? null : "unparseable";
}
record({
phase: "returned",
exit: code,
decision,
blocked: code === 2 || decision === "deny",
stderr: err.trim().slice(0, 2000) || null,
});
process.exit(code ?? 1);
});
Three details in there are load-bearing, and all three are places where a careless wrapper silently changes the answer it was installed to find.
process.stdout.write(d) forwards the child's bytes as they arrive rather than reprinting a parsed copy. The hook protocol is "whatever is on stdout, parsed as JSON" — re-serialising it means the thing Claude Code reads is the wrapper's opinion of the hook's output, not the output.
process.exit(code ?? 1) preserves the child's exit code, and exit 2 is the whole ballgame: it's the only exit status that blocks. A wrapper that ends with a bare process.exit(0) converts every block into an allow, which is the exact failure it exists to detect.
child.stdin.end(raw) sends the original bytes, not a re-encoded object. Whatever the payload contained — fields you don't know about, whitespace, unicode — reaches the real hook unchanged.
Reading the trace
.claude/hook-trace.jsonl now answers the question directly. There are four shapes, and each points somewhere different:
No file, or no line for the call you're investigating. The hook was never invoked. Nothing in your hook is the problem; the problem is upstream — the matcher, the settings file, or the session predating the config.
An invoked line with no returned line. The hook was called and never finished. A hang, a timeout, or a process killed underneath it. A timeout is a non-blocking error, so the call proceeded.
Both lines, blocked: false. The hook ran, saw the call, and had no opinion. Now your hook is genuinely the problem and the test harness from the previous article is the right tool — feed it that exact payload and see what it says.
Both lines, blocked: true, and the write still happened. Rare, and worth reading twice, because it usually means the write you're looking at came from somewhere the hook never saw — a shell redirect, a subagent, a build step — and you're chasing the wrong tool call entirely.
The file field is what makes the last one findable. Grep the trace for the path that changed. If it isn't there, no hook was ever offered the chance to refuse it.
The diagnostic had the bug
I wrote a test harness for the wrapper — eight scenarios: a denying hook, a silent hook, a crashing hook, one exiting 2, one that echoes stdin back so the bytes can be compared, a hook that doesn't exist, a garbage payload, and the case where nothing runs at all. Twenty-one assertions. Twenty passed.
The failure was this line, in its original form:
stderr: err.trim().slice(0, 200) || null,
The crashing-hook case asserted that the recorded stderr contains the error message. It didn't. When Node dies on an uncaught exception it prints the offending file's absolute URL, then a code frame, then a caret, and only then Error: boom. In my test that banner was 251 characters wide before the message started, so a 200-character cap stored the file path and threw away the only part anyone wanted. The trace line looked fine. It had a stderr field, it was populated, it was truthful — and it was useless, in precisely the case you install a tracer to investigate.
That's a small bug with a general shape. A diagnostic tool fails the same way a guard does: quietly, by recording something that looks like an answer. Two articles ago it was a guard that permitted every write; last time it was a hook that died before executing a line while its tests passed. This time the thing that lied was the instrument. Run it against a case you know the answer to, or you're trusting a witness you've never questioned.
What it still can't see
The wrapper inherits every blind spot of the hook it wraps. It only records calls the matcher matched, so it cannot tell you about a file written by Bash, by a subagent, or by a build step your agent kicked off. An empty trace means "no matched tool call touched this", not "nothing touched this" — and mistaking the first for the second is how you conclude your agent is well-behaved on the strength of a file you misread.
Which is why the after-the-fact check stays. A git status diff at the end of a run sees the working tree as it actually is, regardless of which tool got there. The hook refuses what it can see; the diff catches what it couldn't. The trace tells you which of the two you're relying on — and until you have it, that's a question you've been answering by assumption.
Agent Guardrails Kit is the free, assembled version of this code — same modules, wired together, with the tests.
Top comments (0)