Five Claude Code hook settings that quietly turn agent enforcement into advice
A Claude Code PreToolUse hook blocks a tool call only when five things line up: the hook exits with code 2 (or returns a deny decision), it finishes inside its timeout or is set to block on failure, its if filter is not the thing doing the enforcing, its matcher names the tool the way Claude Code actually names it, and it is deployed where users cannot remove it. Miss any one and the hook is advisory. Every behaviour below comes from Anthropic's Hooks reference, read on 15 October 2026. The settings are version-specific, so re-check them on each release.
The symptom
The pattern is consistent. A team writes a policy hook, tests it by hand against an obvious bad command, sees the block, and ships it. Weeks later a transcript shows the agent ran something the hook was written to stop. The hook log shows the hook ran. No block was recorded.
The hook did not malfunction. It behaved as documented. The documentation is long, and the fail-open behaviours sit in five different sections of a page of roughly 110,000 characters.
Setting 1: exit code 1 is not a block
The documented exit-code contract is narrow. Exit 2 is a blocking error and, for PreToolUse, "Blocks the tool call." Any other non-zero exit, or a hook that fails to start or prints invalid JSON, is a non-blocking error: "The action goes ahead," and the transcript shows a hook error notice.
The docs add a warning that catches most Unix-trained engineers: "Without valid JSON on stdout, Claude Code treats exit code 1 as a non-blocking error, even though 1 is the conventional Unix failure code. If your hook is meant to enforce a policy, use exit 2."
Two corollaries matter. Exit 0 with no output does not approve anything; the call continues through the normal permission flow. And exit 2 overrides JSON: "Even a permissionDecision of "allow" doesn't let the action through."
Test. Point a throwaway hook at a harmless canary command and have it exit 1. The canary runs. Change it to exit 2. The canary is blocked. Keep both cases in your test suite so a refactor that changes the exit path is caught.
Setting 2: a timed-out hook is not a block
Default timeouts are long: 600 seconds for command, http and mcp_tool hooks, 30 for prompt, 60 for agent. A hook that reaches its timeout is cancelled and its output discarded. The docs are direct: "A timed-out command, http, or mcp_tool hook doesn't block the tool call. The call continues through the normal permission flow, so don't count on a stalled hook to act as a gate."
The same applies to crashes. "On most events, when a hook fails or times out, Claude Code still carries out the action, so a policy hook with a wrong path or a crashing script lets everything through."
The fix is a per-hook field, onFailure, with two documented values: "continue" (the default) and "block". It requires Claude Code v2.1.295 or later and applies to command and http hooks. It has no effect on Stop, SubagentStop, TaskCompleted, TeammateIdle, or background hooks run with async or asyncRewake.
{
"type": "command",
"command": "/opt/policy/check-tool-call",
"timeout": 5,
"onFailure": "block"
}
Set an explicit short timeout as well. A policy lookup against a local file should finish in milliseconds; a 600-second default only delays the fail-open.
Test. Run three canaries: a hook that sleeps past a 2-second timeout, a hook whose command path does not exist, and a hook that exits with an unhandled exception. With onFailure unset, all three canaries run. With "block", none should.
Setting 3: the if filter is best-effort
The if field takes one permission rule, such as "Bash(git *)", to narrow when a hook runs. It is evaluated only on tool events. On other events, a hook with if set never runs. It holds exactly one rule, with no &&, || or list syntax.
The documented failure is about parsing. "When Claude Code can't determine which commands the Bash input runs, it runs your hook regardless of the pattern." That is the safe direction for a security hook, but the docs draw the line clearly: "Because the if filter is best-effort, use the permission system rather than a hook to enforce a hard allow or deny."
The practical risk is design, not runtime. Teams use if to say "only check npm installs," then the agent installs through npx, a script, or a different package manager, and the hook never sees it. Put the narrowing logic inside the hook, where you control it and can log it, and keep hard denies in permission rules.
Test. Write a hook with "if": "Bash(npm install *)" and ask the agent to add the same package through pnpm add and through a shell script. Check the hook log for both. Any miss is a coverage gap.
Setting 4: plugin tools carry prefixed names
MCP tools are named mcp__<server>__<tool>. Tools from a plugin-bundled MCP server use a scoped segment: mcp__plugin_<plugin-name>_<server-name>__<tool>. The docs warn that "a matcher written against the bare server key never fires for these tools."
Matchers have a second trap. A matcher that contains only letters, digits, _, -, spaces, commas and | is compared as an exact string. Anything else makes it an unanchored JavaScript regular expression. So mcp__memory matches no tool at all; mcp__memory__.* matches every tool from that server. For a plugin named my-plugin with a server key db, the docs give mcp__plugin_my-plugin_db__.*.
Matcher What it matches
------------------------------ ---------------------------------------------
mcp__db nothing (exact string, no tool has this name)
mcp__db__.* tools from a directly configured server "db"
mcp__plugin_my-plugin_db__.* tools from server "db" bundled in "my-plugin"
mcp__.* every MCP tool, direct or plugin-bundled
Test. Install a test plugin that bundles an MCP server, call one of its tools, and confirm the hook log records the call. Then list every MCP tool name from a real session and check each against your matchers.
Setting 5: hooks merge unless managed-only mode is on
Hook entries merge across settings levels. User, project and local settings add hooks without removing managed ones. Enabled plugins merge their hooks too. That has two consequences.
First, where the enforcing hook lives decides whether it can be switched off. "disableAllHooks": true in user, project or local settings "can't disable those managed hooks. Only disableAllHooks set at the managed settings level can disable managed hooks." A policy hook shipped in a repository's .claude/settings.json gets no such protection; the documented guarantee covers managed hooks only.
Second, without restriction, anyone can add hooks, and every hook is code that runs on each matching tool call. The managed setting allowManagedHooksOnly blocks user, project, local and plugin hooks; hooks from plugins force-enabled in managed enabledPlugins are exempt. Workspace trust adds a further rule for project-level hooks; the docs note that before v2.1.218, frontmatter hooks in project subagents could run from folders the user had not trusted.
Test. On a managed test machine, set disableAllHooks in user settings and confirm the managed hook still fires. Then add a user-level hook and confirm it does not run when allowManagedHooksOnly is set.
The fail-open checklist
# Check Pass condition Canary test
- ------------------------------------------- -------------------------------------- ------------------------------------
1 Block path uses exit 2 or a deny decision No enforcement path relies on exit 1 exit 1 vs exit 2 on a harmless command
2 Failure blocks onFailure "block", explicit timeout sleep past timeout; missing binary
3 Hard denies live in permission rules if narrows scope only, never enforces same package via npm, pnpm, script
4 Matchers cover real tool names .* suffix; plugin prefixes included call a plugin-bundled MCP tool
5 Enforcing hooks are managed allowManagedHooksOnly; managed deploy user disableAllHooks; user hook added
Run all five canaries in CI against the Claude Code version you deploy. A hook that passes the hand test and fails a canary is advice.
Trade-offs of failing closed
Failing closed moves the cost from security to availability. A policy service that is slow or unreachable now stops work, so the hook has to be fast and local: a signed file on disk read in milliseconds, not a network call with a 600-second timeout. Blocking on every crash also means a bad hook release blocks every developer at once. Ship hook changes through the same staged rollout you use for any managed setting, and keep a managed-level kill switch (disableAllHooks in managed settings) that only administrators can flip.
Reproduction notes
Every behaviour above is taken from the Hooks reference as published on 15 October 2026, including the version gates (v2.1.218 for workspace trust on subagent frontmatter hooks, v2.1.295 for onFailure). This article does not cover Cursor, Copilot or other agents' hook systems. Re-run the five canaries after each Claude Code upgrade before trusting the results here.
If you want a known-bad list for the hook to check, the threat.cafe early access list ships with a local PreToolUse hook designed to exit 2 on a match.