Atlas
TechnicalHow-to

Claude Code Hooks as Guardrails: Block, Format, Test

Use Claude Code hooks to block risky shell commands, protect files, format every edit and keep Claude working until tests pass, with configs you can copy.

The short version

  • Claude Code hooks are shell commands that Claude Code runs at fixed points in a session, so a rule like "format every edited file" runs every time instead of depending on the model to remember it.
  • A PreToolUse hook can block risky shell commands such as rm -rf or force pushes, and edits to files like .env. Its deny holds even in bypassPermissions mode.
  • A PostToolUse hook with an Edit|Write matcher can run a formatter such as Prettier on each file Claude edits. It can't undo the edit.
  • A Stop hook can keep Claude working while tests fail. Check stop_hook_active so it doesn't loop, and know that Claude Code overrides a Stop hook after eight blocks in a row.
  • Policy hooks must exit 2 (exit 1 enforces nothing) or return a JSON decision. Add onFailure: "block" so a broken hook doesn't let the action through, and keep absolute bans in permission rules.
On this page
  1. The short version
  2. How Claude Code hooks work
  3. Block risky shell commands with PreToolUse
  4. Protect sensitive files from edits
  5. Enforce formatting after every edit
  6. Make tests pass before Claude stops
  7. Make guardrails fail closed
  8. Test and debug your hooks
  9. Where to start
  10. Questions people ask

Claude Code hooks are shell commands that Claude Code runs at fixed points in a session, such as before a tool call, after it, and when Claude finishes responding. They turn "please always run the formatter" from a request the model may forget into a rule that always runs. Three hooks cover most guardrail needs. A PreToolUse hook blocks risky commands and edits. A PostToolUse hook formats each edited file. A Stop hook keeps Claude working while tests fail.

This guide sets up all three, explains the exit codes and JSON that control them, and covers the mistakes that make a guardrail fail without telling you.

How Claude Code hooks work

You define a hook in a settings file under the hooks key. It has three levels: the event, a matcher group, and one or more handlers. The file you put it in decides who it applies to:

  • ~/.claude/settings.json: all your projects, only on your machine.
  • .claude/settings.json: one project, and you can commit it to the repo. Team guardrails go here.
  • .claude/settings.local.json: one project, not shared.
  • Managed policy settings: organisation-wide, controlled by admins.

When an event fires, Claude Code sends JSON to your command's stdin. For a Bash call, a PreToolUse hook gets tool_name and tool_input (tool_input.command holds the shell command). For Edit and Write, tool_input.file_path holds the target file as an absolute path.

Your command answers with its exit code:

  • Exit 0: no objection. On PreToolUse this does not approve the call. The normal permission flow still applies.
  • Exit 2: block. Write the reason to stderr. On PreToolUse, Claude gets it as feedback on why the call was blocked. On Stop, Claude gets it as the reason to keep working.
  • Any other code: unless stdout holds valid JSON that decides the outcome, this is a non-blocking error and the action goes ahead.

The docs are explicit that exit 1, the usual Unix failure code, enforces nothing. If a hook enforces a policy, it must exit 2 or return a JSON decision.

All hooks that match an event run in parallel, and each one runs to completion even if another denies. For PreToolUse decisions the most restrictive result wins, in the order deny, defer, ask, allow. (defer only works in non-interactive -p mode.)

Block risky shell commands with PreToolUse

The docs show the basic pattern: read stdin, pull out the command, and exit 2 if it matches something you never want run. Here it is, extended to a short deny list. Save it as .claude/hooks/block-risky-bash.sh:

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

for pattern in "rm -rf" "git push --force" "drop table"; do
  if echo "$COMMAND" | grep -qi -- "$pattern"; then
    echo "Blocked: '$pattern' is not allowed in this repo. Ask the user to run it." >&2
    exit 2
  fi
done

exit 0

Make it executable with chmod +x .claude/hooks/block-risky-bash.sh, then register it:

json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-risky-bash.sh"
          }
        ]
      }
    ]
  }
}

$CLAUDE_PROJECT_DIR is the project root where the session started, so the path still works after Claude changes directory. The scripts here use jq to parse JSON, so install it first.

Plain substring matching is easy to get around: rm -r -f or git push -f slip past this list. Treat it as a first line of defence and keep hard bans in permission rules (see below).

Write the stderr message for the model as well as for you. Claude reads it and adjusts, so "ask the user to run it" gives it more to work with than a bare "denied".

Deny with JSON when you want structured output

Instead of exiting 2, you can exit 0 and print a decision on stdout:

json
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Database writes are not allowed"
  }
}

permissionDecision must sit inside hookSpecificOutput. If you put it at the top level, the JSON still parses, but Claude Code ignores the field without reporting an error. The docs advise picking one method per hook: exit 2 with stderr, or exit 0 with JSON.

Why a PreToolUse deny holds in every mode

PreToolUse hooks fire before any permission-mode check. A deny blocks the tool even in bypassPermissions mode or with --dangerously-skip-permissions. This matters most in unattended runs. If you run Claude Code from scripts or pipelines, pair these hooks with the permission settings covered in Claude Code Headless in CI.

The docs list one exception. A mod (a plugin whose code runs inside Claude Code) that handles tool.check can approve a call your PreToolUse hook blocked, unless the hook is in managed settings. Check what your installed mods do before you rely on a hook alone.

It does not work the other way. A hook that returns "allow" does not bypass deny rules in settings. Hooks in settings files can tighten restrictions but not loosen them past your permission rules.

Protect sensitive files from edits

The same approach works for file edits. The Claude Code hooks guide uses this script to block changes to .env, package-lock.json and anything in .git/:

bash
#!/bin/bash
# protect-files.sh

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

# Normalize Windows backslash separators so the patterns below match
FILE_PATH="${FILE_PATH//\\//}"

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

Register it on PreToolUse with the matcher Edit|Write, the same way as the Bash hook. Claude can also change files from the shell, which this matcher does not see. The docs suggest two ways to catch every change: also match Bash|PowerShell and have the script list modified and untracked files with git status --porcelain, or add a Stop hook that scans the working tree once per turn.

Enforce formatting after every edit

Formatting belongs in a PostToolUse hook. It runs after the edit lands, so it can't prevent the edit, but it keeps the file tidy. This is the documented Prettier hook for .claude/settings.json:

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

To use a different formatter, put its command after xargs. Two things to know:

  • PostToolUse cannot undo an action, because the tool has already run.
  • To push back after an edit, for example when a linter fails, return {"decision": "block", "reason": "..."}. The reason goes to Claude to read.

If you want a specific file reformatted however it changes, including by a Bash command, the docs point to the FileChanged event instead.

Make tests pass before Claude stops

A Stop hook fires whenever Claude finishes responding. If it blocks, Claude keeps working, and your exit-2 stderr (or JSON reason) tells it why. That makes it a good place for a test gate:

bash
#!/bin/bash
INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0  # Allow Claude to stop
fi

if ! OUTPUT=$(npm test 2>&1); then
  echo "Tests are failing. Fix them before finishing:" >&2
  echo "$OUTPUT" | tail -n 40 >&2
  exit 2
fi

exit 0

Save it as .claude/hooks/require-tests.sh and register it under "Stop". Stop does not support matchers, so leave the matcher out:

json
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/require-tests.sh"
          }
        ]
      }
    ]
  }
}

The stop_hook_active check matters. The field is true when Claude is already continuing because a stop hook blocked. Claude Code also has a hard cap: after a Stop hook blocks eight times in a row with no tool call from Claude in between, Claude Code overrides it and ends the turn. You can raise the cap with the CLAUDE_CODE_STOP_HOOK_BLOCK_CAP environment variable. With the early exit on stop_hook_active, this script blocks once per stop attempt rather than looping on a test that can never pass.

Two more points:

  • Stop fires at the end of every response, not only when a task is complete. Keep the test command fast, or run only the affected suite.
  • Command hooks time out after 10 minutes by default. Set timeout in seconds if you need a different limit.

The docs also show an agent-type Stop hook that runs the test suite itself. Agent hooks are marked experimental, and the docs recommend command hooks for production workflows.

Make guardrails fail closed

By default, if a hook fails (it can't start, times out, or exits with a code other than 0 or 2 without a JSON decision), Claude Code reports a non-blocking error and the action goes ahead. On PreToolUse, a hook that times out does not block the tool call. That's fine for formatting but wrong for a policy hook. Add "onFailure": "block" to the hook so a failure does what exit 2 would do on that event. It needs Claude Code v2.1.295 or later. It has no effect on Stop hooks or on async hooks.

Hooks also run alongside Claude Code's permission system, not in place of it. The docs describe the if filter (for example "if": "Bash(git *)", which limits when a hook runs) as best-effort, and recommend the permission system for a hard allow or deny. Use both:

  • Put absolute bans in permission deny rules.
  • Use PreToolUse hooks for checks that need logic, such as patterns, file paths or project state.
  • Use PostToolUse and Stop hooks to enforce quality.

Rules you'd like Claude to follow but that don't need enforcing belong in your project instructions file. CLAUDE.md Best Practices covers how to write them.

Test and debug your hooks

  • Run /hooks inside Claude Code to see every configured hook, grouped by event.
  • Test a script without Claude by piping sample JSON into it:
bash
echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh
echo $?  # Check the exit code
  • If you see "command not found", use ${CLAUDE_PROJECT_DIR} or absolute paths. If you see "jq: command not found", install jq. If the script doesn't run at all, make it executable with chmod +x.
  • If your JSON has no effect and no error appears, check two things. Your shell profile may be printing text before the {, so Claude Code reads the output as plain text. Or a field may be at the wrong level. Start claude --debug and search the debug log.
  • Edits to hooks in settings files are normally picked up automatically. If they don't show up in /hooks after a few seconds, restart the session.

Commit .claude/settings.json and .claude/hooks/ together, so everyone on the team and every CI run gets the same guardrails.

Where to start

Hooks are the part of Claude Code that doesn't rely on the model remembering anything. Start with a PreToolUse deny list for commands and files you never want touched, add a PostToolUse formatter, then a Stop hook that runs your tests and respects stop_hook_active. Make policy hooks exit 2, set onFailure: "block" on your PreToolUse guardrails, and keep absolute bans in permission rules. The same idea applies beyond one session: on Atlas, each engineer runs as its own Claude Code session, and completion is refused until the work is on main or queued for the landing train and the product's rules pass.

Questions people ask

What are Claude Code hooks?

Claude Code hooks are shell commands that Claude Code runs at fixed points in a session, such as before a tool call, after it, and when Claude finishes responding. They turn a request the model may forget into a rule that always runs. You define them under the hooks key in a settings file such as .claude/settings.json.

How do I stop Claude Code from running dangerous commands like rm -rf?

Add a PreToolUse hook with the matcher Bash that reads the command and exits with code 2 if it matches your deny list. The stderr message is passed to Claude as feedback on why the call was blocked. The deny holds even in bypassPermissions mode. Plain substring matching is easy to get around, so keep hard bans in permission deny rules too.

Why does my Claude Code hook not block anything?

Exit code 1 enforces nothing: a blocking hook must exit 2 or return a JSON decision. If you use JSON, permissionDecision must sit inside hookSpecificOutput, or Claude Code ignores it without an error. Also check that your shell profile isn't printing text before the JSON, and use claude --debug to read the debug log.

How can I make Claude Code keep working until tests pass?

Use a Stop hook that runs your test command and exits 2 with the failure output when tests fail, which tells Claude to keep working. Check stop_hook_active and exit 0 when it is true so the hook doesn't loop. Claude Code also overrides a Stop hook after eight blocks in a row with no tool call in between.

What happens if a Claude Code hook crashes or times out?

By default Claude Code reports a non-blocking error and the action goes ahead; a timed-out PreToolUse hook doesn't block the tool call. For policy hooks, add "onFailure": "block" so a failure acts like exit 2. This needs Claude Code v2.1.295 or later and has no effect on Stop hooks.

Sources

  1. Automate actions with hooks - Claude Code Docs
  2. Hooks reference - Claude Code Docs
  3. Mods overview - Claude Code Docs

Researched and drafted with Claude, then checked against the sources above. Atlas is an independent product, not affiliated with Anthropic or OpenAI. Claude and Claude Code are trademarks of Anthropic; Codex is a product of OpenAI.