Check a Claude Code hooks config
A hook that never runs and a hook that ran and approved the call look exactly the same from the outside: nothing happens, and nothing is logged. So the useful question is not "did it block?" but "is there anything here that could have blocked?" — and most of the ways the answer is no are visible in the configuration itself.
Paste your settings file, your hook script, or both. The checks run in your browser as you type. Nothing is uploaded, and there is nothing to install.
The hooks and permissions blocks of .claude/settings.json, or the whole file.
The file the hook command runs. Checked as text; no parser, no execution.
Paste a settings file, a hook script, or both. Or load an example.
Start from the symptom
The checks are grouped below by what you are actually seeing, because that is how people arrive here. Every one of these produces silence rather than an error, which is the reason they are worth a tool at all.
The hook never runs, and nothing says so
The matcher is an exact string, and write is not Write. How a matcher is evaluated depends on what characters are in it. One made only of letters, digits, _, -, spaces, , and | is compared as an exact string, with | or , separating alternatives. Anything else — a ., a *, a ^ — puts it on the regular-expression path, tested unanchored. So write|edit matches nothing, ever. No error, no warning, no log line: an event fires, no matcher matches, and Claude Code moves on.
A bare MCP server prefix matches no tool. mcp__memory contains only exact-match characters, so it is compared as a whole string against names like mcp__memory__create_entities — and never matches. The .* is what makes it a prefix: mcp__memory__.*. This one is easy to write and impossible to notice.
The command points at a file that is not there. The single most common cause of a guard everyone believes is running. A missing script fails, and a failed hook is a non-blocking error — the tool call proceeds, in every session, silently.
An entry that matches something and runs nothing. An empty hooks array reads as configured and behaves as absent.
An if field outside the tool events. if is only evaluated on PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest and PermissionDenied. On any other event a handler that sets it does not run at all — it is not ignored, and the handler does not fire without it.
The hook runs, and never refuses anything
There is no way for it to say no. A guard with no exit(2) and no deny envelope is a logger that people believe is a gate. Blocking means exiting 2, or exiting 0 with permissionDecision: "deny" in hookSpecificOutput. Nothing else refuses.
It dies before it executes a line. new URL(import.meta.url).pathname used as a filesystem path yields /C:/Users/... on Windows, and Node cannot resolve it — the hook exits with MODULE_NOT_FOUND, which is a non-blocking error. The same code is correct on macOS and Linux, which is why it survives review. Use fileURLToPath.
Buffer.concat on chunks that may be strings. It throws on real payloads. If the throw lands in a catch that returns 0, the guard permits everything while looking fine.
A catch that swallows the error and says nothing. Failing open is a defensible policy — a guard that blocks on its own bugs stops work at 3am over a typo. It is only defensible when something else catches what gets through, and when it was a decision rather than an inheritance.
It blocks, and things still get written
A shell command writes where the matcher cannot see. A hook matched on Write|Edit never fires for > file, sed -i, cp, mv, git checkout, or a build step. Neither do subagent writes. This is not a bug in your hook; it is the boundary of what a tool-call hook can observe. The remedy is a check that runs afterwards and looks at what actually changed — git status --porcelain against your allow-list, before committing.
A symlink walks out of the allow-list. Path containment decided with path.resolve and path.relative is a string comparison. It never opens anything, so a symlink inside the allowed directory still reads as inside it and the bytes land wherever the link points. On Windows a directory junction does this and needs no privileges at all. The fix is realpath on both sides — resolving only the target denies every legitimate write in a project reached through a link, which is the ordinary case.
String-prefix containment. "/app/src-secret".startsWith("/app/src") is true. That is how an allow-list quietly stops being one.
The config looks right and behaves wrong
A catch-all deny beside narrower allow rules. Deny outranks allow, so denying Write(**) and allowing two directories back does not scope writes — it blocks all of them, the listed ones included. Permission rules are deny-shaped and cannot express an allow-list.
A handler with nothing to run. There are five handler types — command, http, mcp_tool, prompt and agent — and each reads a different field. A prompt hook with no prompt, or an http hook with no url, is skipped as silently as a missing command.
once: true in a settings file. Honoured only in skill frontmatter. In a settings file it is dropped without a warning, so a hook written to run one time runs every time.
Hooks spread across several settings files. The user, project and local files layer rather than override, so all of them run. "I edited the config and nothing changed" is usually the edit landing in a file this session is not reading.
The check no tool can do for you
None of the above tells you whether your hook fired. Nothing static can. The only reliable way is to make the hook say so: append a line to a file on every invocation, then look at the file after a run that should have triggered it. If it is empty, the hook never ran — and every conclusion you drew from "it didn't block anything" was about the wrong thing.
What this page cannot see
Worth knowing before a clean result reassures you, because the most common cause of a dead hook is not in the text you pasted:
- Whether the script exists. A command pointing at a path that isn't there fails, and a failed hook is a non-blocking error — the tool call proceeds, in every session, silently. This is the single most common way a guard everyone believes is running turns out never to have run.
- Which settings file this is. Hooks in
~/.claude/settings.json,.claude/settings.jsonand.claude/settings.local.jsonlayer rather than override, so all of them run. "I edited the config and nothing changed" is usually the edit landing in a file this session isn't running under./hookslists every hook and the file it came from. - Anything that happens at runtime. These are text checks, not a parser and not an execution. Treat each finding as "look at this line", not "this is proven broken".
The command-line version does the first two, because it can look at your disk. It runs the same checks this page does — literally the same functions, read out of the tool's source at build time, so the two cannot drift apart.
The reasoning behind each check, at length: Claude Code hook not firing: four reasons it never reaches your script, and what happens when a hook crashes.
If what you actually want is the guard rather than a diagnosis of one, Agent Guardrails Kit is five wired-together modules with a test suite that asserts each one refuses something — £0.00.