# Claude Code hooks and guardrails

Claude Code is an agentic loop that reads files, writes files, runs shell commands, and calls external tools. Without guardrails, a single hallucinated path or a misread instruction can delete the wrong file or push broken code. Hooks let you intercept each tool call to lint, log, reformat, or block it. Permission modes and deny lists constrain what the agent can even attempt. For MCP tools in particular, [MCP security basics](/guides/mcp-security-basics) covers what to allow and what to block.

This guide covers both sides: automation (hooks) and restriction (guardrails). They compose. A hook can enforce a guardrail; a guardrail can make hooks unnecessary.

## How does the agent loop work?

Every tool call passes through `PreToolUse` before it executes and `PostToolUse` after. Your hook is a shell command or HTTP endpoint. It reads JSON from stdin, optionally writes JSON to stdout, and exits with a code that signals allow or block.

## Hook events

| Event | When it fires | Can block? | Can mutate? |
|---|---|---|---|
| `PreToolUse` | Before any tool runs | Yes | Yes, tool input |
| `PostToolUse` | After a tool succeeds | No | Yes, tool output |
| `PostToolUseFailure` | After a tool errors | No | No |
| `UserPromptSubmit` | When the user submits a prompt, before Claude reads it | Yes | Yes, prompt text |
| `Notification` | When Claude Code emits a notification | No | No |
| `Stop` | When the agent finishes its turn | No | No |
| `SubagentStart` | When a sub-agent is spawned | No | No |
| `SubagentStop` | When a sub-agent finishes | No | No |
| `SessionStart` | Once at session open | No | No |
| `SessionEnd` | Once at session close | No | No |
| `PreCompact` | Before context compaction | No | No |

`PreToolUse` is the workhorse. Most automation and all blocking happens there.

## Where do hooks live?

Hooks are configured in `settings.json`. Claude Code merges settings from three locations, in increasing specificity:

- `~/.claude/settings.json` (user-global)
- `.claude/settings.json` (project, checked into source control)
- `.claude/settings.local.json` (project-local, gitignored)

Put guardrails that must apply everywhere in the user-global file. Put project-specific automation in the project file.

## Hook config structure

```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/lint-bash-command.sh"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/format-written-file.sh"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/notify-done.sh"
          }
        ]
      }
    ]
  }
}
```

`matcher` is a regex matched against `tool_name`. Omit it for events that do not have a tool name, like `Stop` or `Notification`. Multiple matchers can match the same event. All matching hooks run in order.

## How do I read context inside a hook?

Claude Code writes JSON to the hook's stdin. For `PreToolUse` on `Bash`, it looks like:

```json
{
  "session_id": "abc123",
  "tool_use_id": "toolu_01XYZ",
  "tool_name": "Bash",
  "tool_input": {
    "command": "rm -rf ./dist",
    "description": "Clean build artifacts"
  }
}
```

Your hook reads this, decides what to do, optionally writes JSON to stdout, and exits.

## Exit codes

| Exit code | Meaning |
|---|---|
| `0` | Allow. Stdout (if any) is interpreted as mutation JSON. |
| `2` | Block. Stderr is shown to Claude as the reason. Claude may retry with a different approach. |
| Any other | Error. Claude Code logs it and continues as if the hook were not there. |

Blocking with exit code `2` is a hard stop on that specific tool call. It does not terminate the session. Claude sees your stderr message and can adapt, ask for clarification, or try a different tool.Hooks fail open, not closed. Any exit code other than 0 or 2 is treated as an error and the tool runs anyway, so a `set -e` script that dies on a typo or a `grep` that returns 1 on no match silently waves the command through. Always exit exactly 2 on the block path and test that your guard stops a known-bad command.

## Example: block destructive shell commands

```bash
#!/usr/bin/env bash
# ~/.claude/hooks/block-destructive.sh
set -euo pipefail

input=$(cat)
command=$(echo "$input" | jq -r '.tool_input.command // ""')

danger_patterns=(
  'rm -rf /'
  'rm -rf ~'
  'git push --force'
  'git push -f'
  'DROP TABLE'
  'truncate.*--all'
)

for pattern in "${danger_patterns[@]}"; do
  if echo "$command" | grep -qiE "$pattern"; then
    echo "Blocked: command matches danger pattern '$pattern'" >&2
    exit 2
  fi
done

exit 0
```

Wire it in `.claude/settings.json`:

```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/block-destructive.sh"
          }
        ]
      }
    ]
  }
}
```

## Example: auto-format after a file write

```bash
#!/usr/bin/env bash
# ~/.claude/hooks/format-on-write.sh
set -euo pipefail

input=$(cat)
file_path=$(echo "$input" | jq -r '.tool_input.file_path // .tool_input.path // ""')

if [[ -z "$file_path" ]]; then
  exit 0
fi

case "$file_path" in
  *.ts|*.tsx)
    prettier --write "$file_path" 2>/dev/null || true
    eslint --fix "$file_path" 2>/dev/null || true
    ;;
  *.py)
    ruff format "$file_path" 2>/dev/null || true
    ;;
esac

exit 0
```

This runs after `Write` and `Edit` succeed. Because it is `PostToolUse`, it cannot block, but it ensures every file the agent writes is formatted before Claude reads its next context.A common mistake is putting your security check in a `PostToolUse` hook and expecting it to stop a bad action. By the time `PostToolUse` runs the tool has already executed, so a `rm -rf` has already deleted and a force push has already landed; exit code 2 there does nothing. Any check that must prevent something belongs in `PreToolUse`, which is the only event that can block.

## Mutating tool input (PreToolUse)

Return a JSON object with `hookSpecificOutput.updatedInput` to replace the tool's arguments before it runs:

```bash
#!/usr/bin/env bash
# Redirect all writes to a sandbox directory during testing
input=$(cat)
file_path=$(echo "$input" | jq -r '.tool_input.file_path // ""')

if [[ "$file_path" == /prod/* ]]; then
  sandbox_path="${file_path/\/prod\//\/sandbox\/}"
  echo "{\"hookSpecificOutput\": {\"updatedInput\": {\"file_path\": \"$sandbox_path\"}}}"
fi

exit 0
```

Claude never sees the original path. It works with the redirected one transparently.

## Mutating tool output (PostToolUse)

Return `hookSpecificOutput.updatedToolOutput` to replace what Claude receives as the tool result:

```json
{
  "hookSpecificOutput": {
    "updatedToolOutput": "Redacted: response contained credentials"
  }
}
```

Use this to strip secrets from shell output before Claude sees them, or to inject structured summaries instead of raw multi-thousand-line logs.

## Permission modes

Hooks intercept at the tool-call level. Permission modes work one layer up, at the approval level.

| Mode | Behaviour |
|---|---|
| Default | Claude asks before any tool call flagged as risky |
| `--dangerously-skip-permissions` | All permission prompts auto-approved. Never use in production. |
| `allowedTools` list | Named tools are pre-approved without prompting |
| `deniedTools` list | Named tools are always blocked, no prompt |

Configure `allowedTools` and `deniedTools` in `settings.json`:

```json
{
  "allowedTools": ["Read", "Glob", "Grep"],
  "deniedTools": ["Bash", "Write", "Edit"]
}
```

This produces a read-only agent: it can search the codebase but cannot change anything. Useful for code-review workflows where you want Claude to analyse but a human to apply changes. The same allow and deny lists are central to [keeping an agent on track](/guides/keep-your-agent-on-track).

`PreToolUse` hooks fire before permission checks for most scenarios, but the permission system and hooks are independent layers. A tool in `deniedTools` never reaches a hook. A hook that exits `2` blocks a tool even if it is in `allowedTools`.

## Deny lists vs hooks: which should you use?

| Situation | Use |
|---|---|
| Entire tool class is always off-limits | `deniedTools` |
| Tool is allowed but specific patterns are dangerous | `PreToolUse` hook |
| You want to log every call for audit | `PreToolUse` + `PostToolUse` hooks |
| You want to transform arguments dynamically | `PreToolUse` hook with mutation |
| You want to sanitise output before Claude sees it | `PostToolUse` hook with mutation |
| You want a desktop notification when the agent finishes | `Stop` hook |

## Sandboxing

For the highest-risk automation (running untrusted code, interacting with production systems), combine guardrails with OS-level sandboxing:

- **macOS**: run Claude Code inside a Seatbelt profile (`sandbox-exec`), which restricts filesystem and network access at the kernel level.
- **Linux**: use a network-namespaced container (`docker run --network none`) or a bubblewrap sandbox (`bwrap`).
- **CI**: always run Claude Code in an ephemeral container with no production credentials mounted. Use short-lived tokens scoped to the minimum needed permission.

A hook that blocks `rm -rf /` is good. A sandbox where that command has no effect even if it runs is better. Use both.

## Logging hook for audit trails

```bash
#!/usr/bin/env bash
# Log every tool call to a JSONL file for later review
input=$(cat)
log_file="${CLAUDE_AUDIT_LOG:-$HOME/.claude/audit.jsonl}"

echo "{\"ts\": \"$(date -u +%FT%TZ)\", \"event\": \"PreToolUse\", \"data\": $input}" >> "$log_file"

exit 0
```

Hook it for all tools with a catch-all matcher:

```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": ".*",
        "hooks": [
          {
            "type": "command",
            "command": "~/.claude/hooks/audit-log.sh"
          }
        ]
      }
    ]
  }
}
```

Each JSONL line is one tool call. Parse it later with `jq` to answer questions like "how many files did the agent write in the last session" or "did it ever touch a file outside the project root."

## HTTP hooks

If your hook needs to call out to an external service (a policy server, a Slack webhook, a compliance API), or to an [MCP server](/guides/mcp-fundamentals), use `type: "http"` instead of `type: "command"`:

```json
{
  "type": "http",
  "url": "https://internal.example.com/claude-policy",
  "method": "POST",
  "timeout_ms": 3000
}
```

Claude Code POSTs the same JSON context to the URL. The response body is interpreted the same way as stdout from a command hook. If the request times out or returns a non-2xx status, the hook is treated as errored and the tool call proceeds.

## Prompts that work with guardrails

You are in read-only mode. Use Read, Glob, and Grep to understand the codebase. Do not attempt to write, edit, or run any commands. Produce a written plan only.

Before deleting or overwriting any file, state the full path and your reason. Wait for my explicit confirmation before proceeding.

Combine these system-prompt instructions with `deniedTools` and a blocking `PreToolUse` hook for defence in depth. Instructions alone can be overridden by a sufficiently confident model. Hooks and deny lists cannot.

## What should you set up first?

1. Add a `PreToolUse` hook on `Bash` that logs every command to a file. Run it for a day. You will see what the agent does, which informs every other guardrail.
2. Add a block list of the five most dangerous patterns you see (force pushes, recursive deletes of non-artifact directories, credential files).
3. Configure `deniedTools` for any tool the agent should never use in your workflow (often `WebFetch` if you want a fully offline agent).
4. Add a `PostToolUse` hook on `Write` and `Edit` to run your formatter. This eliminates a whole class of "Claude wrote valid code but wrong style" follow-up prompts.
5. Add a `Stop` hook to send yourself a desktop or Slack notification. Long agentic runs are not worth babysitting.

Everything else is refinement.
