Room 42, home

Claude Code hooks and guardrails

Automate formatting, linting, and logging around every tool call, and lock down what an agent is allowed to do with permission modes and deny lists.

Codingintermediate7 min readUpdated 12 Jun 2026

In short

  1. 01Hooks are scripts that run before or after every tool call, letting you lint, log, reformat, redact, or block what the agent does.
  2. 02A PreToolUse hook can block a tool by exiting with code 2 or rewrite its input, and PostToolUse can reformat files or strip secrets.
  3. 03Permission modes work one layer up: allow and deny lists pre-approve or hard-block whole tools, with deny always winning.
  4. 04Layer instructions, deny lists, hooks, and OS sandboxing together, since a confident model can ignore words but not deterministic guards.
Contents · 17 sections
  1. 1How does the agent loop work?
  2. 2Hook events
  3. 3Where do hooks live?
  4. 4Hook config structure
  5. 5How do I read context inside a hook?
  6. 6Exit codes
  7. 7Example: block destructive shell commands
  8. 8Example: auto-format after a file write
  9. 9Mutating tool input (PreToolUse)
  10. 10Mutating tool output (PostToolUse)
  11. 11Permission modes
  12. 12Deny lists vs hooks: which should you use?
  13. 13Sandboxing
  14. 14Logging hook for audit trails
  15. 15HTTP hooks
  16. 16Prompts that work with guardrails
  17. 17What should you set up first?

Claude Code is an agentic loopA loop where the model reads, writes, runs commands, and calls tools on its own across several steps, rather than answering once and stopping. that reads files, writes files, runs shell commands, and calls external tools. Without guardrails, a single hallucinatedWhen the model states something false with confidence, like a file path or command that does not exist. path or a misread instruction can delete the wrong file or push broken code. HooksA script that runs automatically before or after a tool call, so you can lint, log, reformat, or block what the agent does. 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 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

EventWhen it firesCan block?Can mutate?
PreToolUseBefore any tool runsYesYes, tool input
PostToolUseAfter a tool succeedsNoYes, tool output
PostToolUseFailureAfter a tool errorsNoNo
UserPromptSubmitWhen the user submits a prompt, before Claude reads itYesYes, prompt text
NotificationWhen Claude Code emits a notificationNoNo
StopWhen the agent finishes its turnNoNo
SubagentStartWhen a sub-agent is spawnedNoNo
SubagentStopWhen a sub-agent finishesNoNo
SessionStartOnce at session openNoNo
SessionEndOnce at session closeNoNo
PreCompactBefore context compactionNoNo

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

{
  "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 regexA search pattern that matches text by shape rather than an exact string, so `Write|Edit` matches either tool name. 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:

{
  "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 codeMeaning
0Allow. Stdout (if any) is interpreted as mutation JSON.
2Block. Stderr is shown to Claude as the reason. Claude may retry with a different approach.
Any otherError. 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.Common gotchaHooks 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

#!/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:

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

Example: auto-format after a file write

#!/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.Common gotchaA 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:

#!/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:

{
  "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.

ModeBehaviour
DefaultClaude asks before any tool call flagged as risky
--dangerously-skip-permissionsAll permission prompts auto-approved. Never use in production.
allowedTools listNamed tools are pre-approved without prompting
deniedTools listNamed tools are always blocked, no prompt

Configure allowedTools and deniedTools in settings.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.

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?

SituationUse
Entire tool class is always off-limitsdeniedTools
Tool is allowed but specific patterns are dangerousPreToolUse hook
You want to log every call for auditPreToolUse + PostToolUse hooks
You want to transform arguments dynamicallyPreToolUse hook with mutation
You want to sanitise output before Claude sees itPostToolUse hook with mutation
You want a desktop notification when the agent finishesStop hook

Sandboxing

For the highest-risk automation (running untrusted code, interacting with production systems), combine guardrails with OS-level sandboxingRunning code in an isolated environment with restricted filesystem and network access, so a bad command cannot reach the rest of your machine.:

  • 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

#!/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:

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

Each JSONLA file format with one JSON object per line, so you can append entries cheaply and read them back one at a time. 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, use type: "http" instead of type: "command":

{
  "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

Try this prompt
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.
Try this prompt
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.