# 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.

9 October 2026 · Technical · How-to · 7 min read · https://bridge-terminal.com/atlas/blog/claude-code-hooks-guardrails/

## 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.

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](https://bridge-terminal.com/atlas/blog/claude-code-headless-ci-permissions/).

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](https://bridge-terminal.com/atlas/blog/claude-md-best-practices-project-instructions/) 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](https://bridge-terminal.com/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.

## About Atlas: Guardrails across a whole AI team, not one session

Hooks enforce rules inside a single Claude Code session. Atlas runs a team of Claude Code sessions and applies the same idea to the team: building waits for an approved architecture, a signed-off design and failing tests, and "done" is refused until the work is on main or queued for the landing train and the product's rules pass. Production go-lives, spending and deleting data are always left for you to decide. — https://bridge-terminal.com/atlas/

## Sources

- [Automate actions with hooks - Claude Code Docs](https://code.claude.com/docs/en/hooks-guide)
- [Hooks reference - Claude Code Docs](https://code.claude.com/docs/en/hooks)
- [Mods overview - Claude Code Docs](https://code.claude.com/docs/en/plugins/mods/overview)

_Researched and drafted with Claude, then checked against the sources above. Atlas is an independent product, not affiliated with Anthropic or OpenAI._
