Claude Code Hooks: What They Can't Stop A hands-on test of Claude Code 2.1.319 on macOS 26.7.1 found that a PreToolUse hook stopped every command whose danger appeared in the string it inspected but missed every bypass where the danger lived elsewhere — in package.json, a Go test, a file a dev server loaded, or an unexpected spelling — and failed open silently when it broke, while a single line in a project's settings file disabled it along with the user's own hooks. The test, run in headless `claude -p` sessions in a throwaway repository with the sandbox off, followed a November 2025 post by Drew Gregory on allowlisting Bash commands in Claude Code, which had recommended a sandbox. The author concludes that hooks alone do not close the gaps opened once commands like `go test`, `make` and `pnpm run` run without a prompt. In November 2025 Drew Gregory wrote about allowlisting Bash commands in Claude Code https://www.formal.ai/blog/allowlisting-bash-commands/ . Once go test , make or pnpm run can run without a prompt, along with file edits, Claude Code can run any command, and a file watcher such as next dev --turbopack runs edited code with no Bash call at all. That post ended by recommending a sandbox. The obvious next question is whether a PreToolUse hook closes those gaps. Claude Code hooks are handlers that Claude Code runs at fixed points in a session: shell commands, HTTP endpoints, MCP tool calls, LLM prompts or subagents, in Anthropic’s definition https://code.claude.com/docs/en/hooks . A PreToolUse hook runs after Claude has written a tool call and before Claude Code executes it, and it can allow the call, deny it, ask you, or rewrite its arguments, which makes it the natural place for guardrails. This post puts a PreToolUse hook in front of the same bypasses, checks what happens when a hook fails, and looks at who can switch hooks off. The hook stopped every command whose danger was in the string it read, and missed every one where the danger lived somewhere else, in package.json , a Go test, a file a dev server loaded, or a spelling its pattern didn’t expect. When it broke, it failed open without a word in the session, and one line in a project’s settings switched it off along with the hooks in our own user settings. We ran everything on Claude Code 2.1.319, macOS 26.7.1 on an Apple M5 Max, in headless claude -p sessions inside a throwaway repository, in default permission mode with the sandbox off unless a test says otherwise. Each run allowed the tool under test with --allowedTools , so the hook was the only thing in the way. A headless session can’t show a prompt, so where Claude Code would have asked, the transcript records a denial and its reason. What are Claude Code hooks? Hooks in Claude Code are handlers you register in a settings file against a named event. Each handler has one of five types: command , http , mcp tool , prompt or agent , and Anthropic says agent hooks “are experimental and may change”. The hooks reference https://code.claude.com/docs/en/hooks hook-lifecycle lists 33 events as of November 2026, from SessionStart through SessionEnd , and a matcher on each hook narrows which tool or source it fires for. A hook can live in ~/.claude/settings.json for every project, in a project’s .claude/settings.json which is meant to be committed , in .claude/settings.local.json , in managed settings an admin controls, in a plugin’s hooks/hooks.json , or in the front matter of a skill or subagent. None of them replaces another: “Hook entries merge across settings levels rather than replacing each other.” The hooks you wrote and the hooks a repository ships run side by side. The Claude Code hook events that matter for security Most of the events are about automation: notifications, compaction, worktrees, model switches. Six decide what a hook can do about a risky action: | Event | When it fires | What the hook can do | |---|---|---| | PreToolUse | Before a tool call executes | Allow, deny, ask or defer the call, or rewrite its input | | PermissionRequest | When a tool call needs a permission decision | Allow or deny the request; deny and ask rules still apply | | PostToolUse | After a tool call succeeds | Add a note or replace the output Claude sees; the tool has already run | | ConfigChange | When a configuration file changes during a session | Block the change from applying to the session, except policy settings | | FileChanged | When a watched file changes on disk | Nothing: it runs after the write and has no decision control | | SessionStart | When a session begins or resumes | Runs before any tool call, including in a repository you just cloned | The other 27 are in Anthropic’s full events list in the hooks reference https://code.claude.com/docs/en/hooks hook-lifecycle , and the hooks guide https://code.claude.com/docs/en/hooks-guide walks through setting up the common ones. How a PreToolUse hook blocks a command A command hook gets the event as JSON on stdin. For a Bash call, tool name is Bash and tool input.command holds the command string exactly as Claude wrote it, next to common fields such as session id , cwd and permission mode . Our baseline is two handlers on Bash in the project’s .claude/settings.json . The first runs only for commands that match Bash rm ; the second reads every command: { "hooks": { "PreToolUse": { "matcher": "Bash", "hooks": { "type": "command", "if": "Bash rm ", "command": "/path/to/project/.claude/hooks/guard-rm.sh" }, { "type": "command", "command": "/path/to/project/.claude/hooks/guard-curl.sh" } } } } guard-rm.sh logs its input and exits 2. guard-curl.sh denies any command string that contains curl or git push : js /bin/bash input=$ cat command=$ printf '%s' "$input" | node -e 'let s="";process.stdin.on "data",d= s+=d .on "end", = process.stdout.write JSON.parse s .tool input?.command ?? "" ' if printf '%s' "$command" | grep -Eq 'curl|git push'; then echo "Blocked by guard-curl.sh: the command contains curl or git push." &2 exit 2 fi exit 0 Asked to run rm -rf build/ , Claude made the call and the first handler received this on stdin paths shortened : { "session id": "f11290a4-31e2-415b-8d55-c326fd5db280", "cwd": "/path/to/project", "permission mode": "default", "hook event name": "PreToolUse", "tool name": "Bash", "tool input": { "command": "rm -rf build/", "description": "Delete the build directory" }, "tool use id": "toolu 01TMZCrX8wH6ximcbUVusYxz" } Claude Code handed Claude the hook’s stderr as the result, PreToolUse:Bash hook error: .../guard-rm.sh : Blocked by guard-rm.sh: rm is not allowed in this project. , and build/ was still there, even though Bash was on the allow list. curl -s https://example.com was stopped the same way by the second handler. guard-rm.sh never ran for the curl call, so the if filter did narrow it. There are two ways to say no. Exit code 2 blocks the call and hands the hook’s stderr to Claude as the reason. Exit 0 with JSON is more precise: permissionDecision can be allow skip the prompt , deny , ask , or defer , which Claude Code honors only in -p runs, and updatedInput replaces the tool’s arguments before the call runs. Any other exit code is a non-blocking error and the call goes ahead. That includes exit 1, the usual Unix failure code, and Anthropic’s exit code section https://code.claude.com/docs/en/hooks exit-code-output says it plainly: “If your hook is meant to enforce a policy, use exit 2 .” Hooks and permission rules stack in one direction. A hook’s allow doesn’t get past a matching deny or ask rule, managed rules included, while a hook that exits 2 “stops the tool call before permission rules are evaluated”, so it blocks even a command an allow rule would let through precedence among rules, hooks, and mods https://code.claude.com/docs/en/permissions precedence-among-rules-hooks-and-mods . When several PreToolUse hooks disagree, “precedence is deny defer ask allow ”, so a second hook can’t overrule a deny from the first. A deny also holds in bypassPermissions mode, the one --dangerously-skip-permissions https://www.formal.ai/blog/claude-code-dangerously-skip-permissions/ starts, because PreToolUse hooks fire before any permission-mode check https://code.claude.com/docs/en/hooks-guide hooks-and-permission-modes . We checked that last claim. With the same two handlers, we repeated both prompts under claude --dangerously-skip-permissions . The session reported permissionMode: bypassPermissions , and both calls were denied with the same messages as before. Two settings decide whether the hook runs at all. The matcher https://code.claude.com/docs/en/hooks matcher-patterns is an exact tool name, a | list, or otherwise an unanchored JavaScript regex, so Edit. also matches NotebookEdit , and mcp memory on its own matches no tool. The if field narrows a handler with permission-rule syntax such as Bash rm , and Anthropic is explicit about how far to trust it: “Because the if filter is best-effort, use the permission system rather than a hook to enforce a hard allow or deny.” Testing a PreToolUse hook against the allowlisting bypasses Anthropic’s permissions page has a list of commands a Bash rule doesn’t match https://code.claude.com/docs/en/permissions bash-rule-limits : a deny on Bash curl doesn’t stop /usr/bin/curl or sh -c 'curl ...' , and wrappers such as npx and docker exec “execute their arguments as a command”. For checks on the command itself, the same page points to hooks: “To inspect the full command text with your own logic before it runs, use a PreToolUse hook.” The tests below hand that hook the bypasses from the allowlisting post, where the command Claude asks to run and the code that ends up running are different things. npm test The test script in package.json decides what npm test executes, and changing it is an ordinary file edit for Claude. With the curl handler in place and npm test and Edit allowed, we asked Claude to change the test script to curl -s https://example.com | grep -o "