Back to Blog

Claude Code hooks: how to set up guardrails that actually block

Alex Kim
13 min read
Claude Code hooks: how to set up guardrails that actually block

Last updated: November 11, 2026

TL;DR

Claude Code hooks are shell commands that Claude Code runs at fixed points in a session, so your formatter or guardrail runs every time instead of whenever the model remembers. You add them to a settings file under an event name like PreToolUse. A hook blocks an action by exiting with code 2. Exit code 1 blocks nothing.

  • ๐ŸŽจ Format on edit. A PostToolUse hook on Edit|Write runs your formatter after every change.
  • ๐Ÿ›‘ Block a file. A PreToolUse hook checks the path and exits 2, and Claude gets your message as feedback.
  • ๐Ÿ”’ Fail closed. Add "onFailure": "block", because a guardrail that crashes lets the action through by default.
  • โœ… Finish on green. A Stop hook sends Claude back to work when the tests fail.
  • ๐Ÿ” Check it. /hooks lists what's configured, and Ctrl+O shows what each hook did.

Anthropic's docs list 33 hook events as of Claude Code 2.1.319. By our count of Anthropic's exit code table on November 9, 2026, exit code 2 can stop the action on 16 of them. On the other 17 it can't, including PostToolUse, where the tool has already run.

Before you start

Hooks need nothing beyond Claude Code and a settings file. Four things shape how these steps go:

  1. jq installed. The commands below parse the hook's JSON input with it. Run brew install jq on macOS or apt-get install jq on Debian and Ubuntu.
  2. Claude Code 2.1.295 or later for Step 3. That's when onFailure arrived. Run claude update if you're behind.
  3. A trusted folder. In an interactive session, Claude Code holds back hooks from every settings file until you accept the workspace trust dialog for that folder.
  4. A file for the hooks. These steps use .claude/settings.json in your project, which you can commit so your team runs the same hooks. If the file already has a hooks key, add each event beside the ones already there.

Step 1: Format every file Claude edits

Add this to .claude/settings.json. Edit|Write means the hook runs only after those two tools, and -w is Prettier's short flag for writing the file in place:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "jq -r '.tool_input.file_path' | xargs npx prettier -w"
          }
        ]
      }
    ]
  }
}

To test it, ask Claude to add a line with single-quoted strings to a JavaScript file. With Prettier's defaults, the quotes become double quotes. A hook that succeeds shows nothing in the conversation, so the reformatted file is your confirmation.

Step 2: Block edits to protected files

A PreToolUse hook runs before the tool does, so this is the event that can stop a change. Save this script as .claude/hooks/protect-files.sh:

#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

PROTECTED_PATTERNS=(".env" "package-lock.json" ".git/")

for pattern in "${PROTECTED_PATTERNS[@]}"; do
  if [[ "$FILE_PATH" == *"$pattern"* ]]; then
    echo "Blocked: $FILE_PATH matches protected pattern '$pattern'" >&2
    exit 2
  fi
done

exit 0

Make it executable:

chmod +x .claude/hooks/protect-files.sh

Then test the script on its own, with no session involved. It should print the Blocked: line and then 2:

echo '{"tool_name":"Edit","tool_input":{"file_path":".env"}}' | .claude/hooks/protect-files.sh; echo $?

Register it next to the formatter, as a second key inside hooks:

"PreToolUse": [
  {
    "matcher": "Edit|Write",
    "hooks": [
      {
        "type": "command",
        "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-files.sh",
        "args": []
      }
    ]
  }
]

The empty args array makes Claude Code start the script directly, with no shell in between, so there's no quoting to get wrong. Ask Claude to add a comment to your .env file. Claude Code stops the edit before it runs, and Claude receives the Blocked: message as feedback and can change course.

Step 3: Make the guardrail fail closed

Add "onFailure": "block" to any hook that enforces a rule. On most events, a hook that fails or times out lets the action go ahead, so a wrong path or a crashing script turns your guardrail into nothing at all, with only a notice in the transcript to tell you. Change the handler from Step 2 to this:

{
  "type": "command",
  "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-files.sh",
  "args": [],
  "onFailure": "block"
}

With that set, Claude Code treats each of these as a block: the script can't start, it exits with anything other than 0 or 2, it reaches its timeout, or it prints JSON that can't be parsed.

To test it, rename the script and ask Claude to edit any file. The edit is blocked, and the error includes failed; blocking because onFailure is "block". Rename the script back afterward.

Step 4: Send Claude back when the tests fail

A Stop hook runs when Claude finishes responding, and exit code 2 keeps it working. Save this as .claude/hooks/tests-pass.sh, swap in your own test command and run chmod +x on it:

#!/bin/bash
INPUT=$(cat)

if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0
fi

if ! npm test >/dev/null 2>&1; then
  echo "Tests are failing. Fix them before you finish." >&2
  exit 2
fi

exit 0

Register it under Stop, with no matcher, because Stop doesn't use one:

"Stop": [
  {
    "hooks": [
      {
        "type": "command",
        "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/tests-pass.sh",
        "args": []
      }
    ]
  }
]

The stop_hook_active check gives Claude one extra pass and then lets it stop, even if the tests are still red. Without that check, Claude Code overrides the hook itself after eight blocks in a row with no tool call in between. Stop fires every time Claude finishes responding, so a slow test suite runs after a one-line answer too.

Step 5: Confirm what's running

Type /hooks in Claude Code. It opens a read-only list of every configured hook, labeled by where it came from. Then press Ctrl+O for the transcript view, where a hook's outcome is one of these three:

What you seeWhat it means
NothingThe hook succeeded. Check its effect, such as a reformatted file
Your Blocked: messageThe hook exited 2 and stopped the action
A <hook name> hook error noticeA non-blocking error. The action went ahead

The third row is the one to look for on the first run of a new guardrail. It means your hook failed and the action went through anyway.

Where Claude Code hooks stop protecting you

A hook guards only the calls it matches, so treat it as one layer. Anthropic's docs say to use the permission system rather than a hook to enforce a hard allow or deny. Here are the gaps that matter most:

  • The matcher sees only the tools you name. The Step 2 hook watches Edit and Write. A Bash command that writes to .env doesn't match it.
  • The if filter is best-effort. It narrows a hook by arguments, such as Bash(git *), and Anthropic's docs describe it in those words.
  • A deny doesn't stop other hooks. Every matching hook runs to completion, in parallel, before Claude Code merges the results. A logging hook still logs a command that another hook denied.
  • PostToolUse can't undo anything. The tool has already run, so exit 2 there only shows your message to Claude.
  • A hook's allow can't beat a settings deny rule. A hook returning "allow" doesn't bypass deny rules from settings.

One strength is worth knowing. A PreToolUse hook that returns a deny holds in every permission mode, including bypass mode, because those hooks fire before any permission-mode check.

Hooks also carry their own risk. Command hooks run with your full user permissions. In a non-interactive claude -p run, Claude Code never shows the trust dialog, so hooks committed to a repository's .claude/settings.json run in a folder you've never trusted. Read a repo's .claude/ files before you script Claude Code over it.

Reference: which events can stop the action

Exit code 2 stops the action on these 16 events, and it does something different on each:

EventWhat exit 2 does
PreToolUseBlocks the tool call
PostToolBatchStops the agentic loop before the next model call
UserPromptSubmitBlocks the prompt, so it never reaches Claude
UserPromptExpansionBlocks the expansion
StopKeeps Claude working
SubagentStopKeeps the subagent working
TeammateIdleKeeps the agent team teammate working
TaskCreatedRolls back the task creation
TaskCompletedStops the task being marked complete
ConfigChangeBlocks the config change, except policy_settings
PreCompactBlocks compaction
PreModelSwitchBlocks the model switch
ElicitationDeclines the MCP server's request
ElicitationResultBlocks the response
WorktreeCreateAny non-zero exit fails the creation
WorktreeRemoveAny non-zero exit fails the removal if the directory is still there

PermissionRequest looks like it belongs on this list, and it doesn't. Exit 2 isn't honored there, so deny through the JSON decision object instead.

Reference: exit codes

Exit codeEffect
0Success. For PreToolUse this is no decision, and the normal permission flow still applies
2Blocks the action on the events above. Stderr is the message
Anything elseNon-blocking error. The action goes ahead, unless onFailure is "block"

To send a structured decision instead, exit 0 and print JSON. For PreToolUse, permissionDecision goes inside hookSpecificOutput. At the top level, Claude Code ignores it and reports no error.

Reference: where hooks live

LocationScopeShared with your team
~/.claude/settings.jsonAll your projectsNo
.claude/settings.jsonOne projectYes, if committed
.claude/settings.local.jsonOne projectNo
Managed policy settingsOrganization-wideYes, set by an admin
Plugin hooks/hooks.jsonWhile the plugin is enabledYes
Skill frontmatterRest of the session after the skill runsYes
Subagent frontmatterWhile that subagent runsYes

Hook entries merge across these levels rather than replacing each other.

Reference: hook types and timeouts

TypeWhat it doesDefault timeout
commandRuns a shell command10 minutes
httpPOSTs the event JSON to a URL10 minutes
mcp_toolCalls a tool on a configured MCP server10 minutes
promptAsks a Claude model for a single-turn verdict30 seconds
agentRuns a subagent that can use tools (experimental)60 seconds

The default for command, http and mcp_tool drops to 30 seconds on UserPromptSubmit, PreModelSwitch and PostModelSwitch, and to 10 seconds on MessageDisplay. Set timeout in seconds on any hook to override it.

Troubleshooting

SymptomCheck
The hook never runsRun /hooks and confirm it's under the right event. Matchers are case-sensitive, so edit won't match Edit
/hooks shows nothingLook for a trailing comma or comment in the JSON, and confirm the file's location. Restart the session if a file edit wasn't picked up
"command not found"Use ${CLAUDE_PROJECT_DIR} or an absolute path
The script doesn't run at allRun chmod +x on it
The hook runs but JSON does nothingA shell profile echo may be printing first, or a field may be at the wrong level. Run /debug and read the log
"Only hooks from managed settings run here"Your organization set allowManagedHooksOnly, so your own hooks don't run

Anthropic's hooks guide has the full troubleshooting list, and the hooks reference has every event's input and output.

Frequently asked questions

What are Claude Code hooks?

Claude Code hooks are user-defined shell commands that run at fixed points in a session, such as before a tool call or when Claude finishes responding. They run every time their event fires, so a rule doesn't depend on the model remembering it.

Where do I put Claude Code hooks?

Put them under a hooks key in a settings file. .claude/settings.json applies to one project and can be committed for your team, and ~/.claude/settings.json applies to all your projects. Plugins, skills and subagents can also define hooks.

How do I block a tool call with a hook?

Register a PreToolUse hook on the tool, write the reason to stderr, and exit with code 2. Claude Code stops the call and passes your message to Claude. You can also exit 0 and print JSON with a permissionDecision of deny.

Why doesn't exit code 1 block the action?

Claude Code treats any exit code other than 0 or 2 as a non-blocking error, so the action goes ahead and a notice appears in the transcript. Use exit code 2 to block, or set "onFailure": "block" so that a failing hook blocks as well.

Can a hook override a permission deny rule?

No. A hook returning "allow" doesn't bypass deny rules from settings. The reverse does hold: a PreToolUse hook that returns a deny blocks the tool in every permission mode, including bypass mode.

Do Claude Code hooks run inside subagents?

Yes. Hooks from settings files, managed policy settings and plugins also run inside subagents. When a subagent calls a tool, tool events such as PreToolUse and PostToolUse fire the same configured hooks as in the main conversation.

How do I turn off all hooks?

Set "disableAllHooks": true in your settings file. Claude Code reads the value left after settings precedence, so a project's file can override yours. Hooks in managed settings keep running unless disableAllHooks is also set there.

What's the difference between hooks and CLAUDE.md?

CLAUDE.md is written guidance that Claude reads and applies with judgment. A hook is a command that Claude Code runs itself when its event fires, so use CLAUDE.md for conventions and hooks for anything that has to happen every time.

Pair the hook with the rule behind it

A hook enforces one rule at one point in the session. The reasoning behind that rule still has to live somewhere Claude reads, or it will keep trying the blocked edit and working around the block. Our guide to compound engineering for Claude Code shows how to build those written rules up one fix at a time, and our subagents guide covers giving a narrow job its own context and tools.

#claude-code#Hooks#ai-agents#guardrails#Automation
Free assessment

Stop guessing. Score your first workflow.

Score one workflow in about five minutes. Nine questions, a grade, and the specific gap holding it back - no call required.