You wrote a PreToolUse hook, pointed it at Write, and asked Claude to write a file somewhere it shouldn't. The file appeared. Now you have to work out which of two things happened: the hook ran and decided to allow it, or the hook never ran at all.
Nothing on your screen distinguishes those. A hook that permits a call produces no transcript entry. A hook whose command doesn't exist produces no transcript entry either — a failed command is a non-blocking error, and every non-blocking failure path ends in "the tool call proceeds". Same silence, same outcome, different bug entirely.
The good news is that most of the reasons are static. They are readable off two files — your settings JSON and your hook script — without running anything. Here are the four that account for nearly all of it, in the order they bite.
1. The matcher is an exact string, and write is not Write
This is the one I'd check first, because it's invisible and it feels like it should work:
{
"hooks": {
"PreToolUse": [
{
"matcher": "write|edit",
"hooks": [{ "type": "command", "command": "node .claude/hooks/guard.mjs" }]
}
]
}
}
How a matcher is evaluated depends on what characters are in it. A matcher made only of letters, digits, _, -, spaces, , and | is compared as exact strings, with | or , separating alternatives. Anything else — a ., a *, a ^ — puts it on the regular-expression path, where it is tested unanchored with RegExp.prototype.test.
write|edit is on the exact path. The tool names are Write and Edit. So this entry matches nothing, ever. There is no error, no warning and no log line: an event fires, no matcher matches, and Claude Code moves on. The hook you wrote is a file on disk that nothing opens.
Two follow-on traps live in the same rule:
-
Unanchored regexes match anywhere.
Edit.*matchesNotebookEditas well asEdit. If you want one tool, write^Edit$. -
A bare MCP server prefix matches nothing.
mcp__memorycontains only exact-match characters, so it is compared as a whole string against tool names likemcp__memory__create_entities, and never matches. You needmcp__memory__.*.
2. The command points at a file that isn't there
"command": "node .claude/hooks/guard.mjs"
That is a relative path, and it resolves against whatever working directory the hook is spawned in. When that isn't the project root, node exits non-zero with Cannot find module, which is a non-blocking error, so the call proceeds. In every session. Silently.
Use the project root variable instead:
"command": "node ${CLAUDE_PROJECT_DIR}/.claude/hooks/guard.mjs"
This is the single most common way a guard everyone believes is running turns out never to have run once. It is also the easiest to check: take the path out of the command, and confirm something is at it.
3. The settings file isn't valid JSON
A trailing comma after the last entry in an array is the usual cause. The file is unreadable, so every hook and every permission rule in it is inert — not partially applied, inert — and nothing in normal use announces it.
node -e "JSON.parse(require('fs').readFileSync('.claude/settings.json','utf8'))"
Silence means it parsed.
4. The hook is in a settings file — just not the one you're running under
Three files can carry hooks: ~/.claude/settings.json, .claude/settings.json in the project, and .claude/settings.local.json. They layer: hooks from all of them run, they do not override one another. That is usually what you want, and it is also why "I edited the config and nothing changed" is so common — the edit landed in a file that isn't in play for the project you are sitting in.
Direct edits to hooks are picked up by a file watcher, so did it need a restart is generally not your problem. Which file did I edit generally is.
/hooks answers that directly. It is a read-only browser over every configured hook, and it shows the settings file each one came from. When an instrument reports the mechanism, don't infer the mechanism from a symptom.
Then there are the ones that run and permit anyway
Past those four, the script is being invoked and still nothing is being refused. Three shapes, all of which I have either shipped or nearly shipped.
Printing a refusal is not making one. This looks like a working guard and does nothing at all:
if (!inside) console.log(`blocked: ${file} is outside the allow-list`);
A PreToolUse hook refuses in exactly two ways: exit code 2, or exit 0 with a decision object on stdout.
if (!inside) {
console.log(JSON.stringify({
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: `${file} is outside the allow-list`,
},
}));
process.exit(0);
}
Anything else on stdout is a log line — and on exit 0, not even a visible one. For tool events a successful hook's stdout goes to the debug log and never appears in the transcript, so your refusal message is being written somewhere you aren't looking.
If you copied a decision: "block" example, that does still work on PreToolUse: the deprecated "block" maps to "deny". But it is the old spelling, and top-level decision is the current format for other events like PostToolUse and Stop. It's worth moving off, because of the way it would fail — the day it stops being honoured, your hook keeps exiting 0 and every call it was refusing starts proceeding.
Dying before line one. My own hook did this for a while:
const root = path.dirname(new URL("../..", import.meta.url).pathname);
On macOS and Linux that is fine, which is why it survives review. On Windows .pathname yields /C:/Users/..., Node resolves that as C:\C:\Users\..., and the process dies with MODULE_NOT_FOUND before executing a line of your logic — exit 1, no stdout, non-blocking, call proceeds. Use fileURLToPath(new URL("…", import.meta.url)) from node:url.
The catch that swallows everything. An empty catch {} around the decision turns any bug — a missing field, a stream that hands you strings — into a permit. Failing open is defensible; a guard that stops the agent at 3am over its own typo is worse than no guard. But it is only defensible when something else catches what gets through, and it should be a decision you wrote down rather than one you inherited from an empty block.
When you'd rather watch than read
Two instruments, answering different questions.
For a session you start yourself, claude --debug-file <path> writes hook execution to a log: which hooks matched, their exit codes, and the full stdout and stderr. That is the direct answer to did it run, and it beats every inference in this article.
For a run nobody is watching — an agent on a schedule, which is my case — there is no --debug to pass and no terminal to read it in. The hook has to record its own invocation, which is a small wrapper around the real hook that appends a line before delegating. The distinction it buys you is the one this whole article circles: an empty log is not evidence of a quiet day, because a hook that never ran writes exactly as much as one that ran and allowed everything.
What none of this can tell you
A matcher filters on tool name, so a hook matched to Write and Edit never sees a file created by a shell redirect — as far as the matcher is concerned, Bash is not a file-writing tool, and what it does with a > is its own business. Subagents and build steps are the same story. That is not a bug in your hook, and no amount of debugging the hook will find it, because the hook is behaving correctly.
The only thing that sees those is a check that runs after the fact and looks at what actually changed — diffing git status --porcelain against your allow-list before you commit. And if your containment test compares resolved paths as strings, both layers share a blind spot that a symlink walks straight through.
The short version
Four things to check before you debug your logic, in order: the matcher is exact and case-sensitive; the command path resolves from wherever it is spawned; the settings file parses; the file you edited is the one in play. Then, in the script: it exits 2 or prints the deny envelope, it doesn't die at import, and its catch blocks aren't quietly approving things.
Every one of those is readable off two files, which is the part I find interesting — none of it needs a running session to find. I have written those checks up as a small read-only tool, and the ones that don't need to look at your disk now run on a page: paste your settings JSON and your hook script into the hook check and it reports what it finds. Nothing is uploaded and nothing is installed — the checks run in your browser. The checks themselves are all above, and they are worth ten minutes with your own config either way.
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 (1)
The relative-path trap caught me too - had what I thought was a running gate that was silently failing for weeks because the working directory in one invocation context wasn't what I expected. The ${CLAUDE_PROJECT_DIR} fix is the right call, and I'd add: if your hook writes to a log file with a relative path, you can fool yourself into thinking it ran because an empty log looks the same as no log. Bit annoying to debug.