Skip to main content
Claude Code Hooks: Automate Guardrails for AI-Generated Code

Claude Code Hooks: Automate Guardrails for AI-Generated Code

Claude Code Hooks: Automate Guardrails for AI-Generated Code
Chudi Nnorukam Mar 17, 2026 6 min read

Claude Code hooks run shell commands on tool events — block secret leaks, auto-format, gate destructive commands. Four production hooks with full code.

Why this matters

Claude Code wrote a live Stripe key into my .env file. Not malicious — it just had the key in context and needed a value. That's when I built hooks: shell commands that fire on every tool event and can block operations before they execute. Here are the 4 hooks I run on every project.

I ran a secret scanner on every project for months before I realized Claude Code was writing .env files with real credentials baked in. Not because it was malicious. Just because the context had a key, and it needed a value.

The fix took five minutes once I knew hooks existed.

Claude Code hooks let you run any shell command automatically when tool events fire. Before a file gets written, after a bash command runs, when Claude finishes a task. You get full context about what’s happening via stdin, and for PreToolUse hooks, you can block the operation entirely.

This is the guide I wish I had when I started.

What hooks are and why they matter

Claude Code is an autonomous agent. It reads files, writes code, runs commands, and makes decisions faster than you can review each one. That autonomy is the point. But it creates a gap: how do you enforce standards without reviewing every action manually?

Hooks close that gap. They’re your enforcement layer — running in the background, checking every operation against your rules, and either approving it or blocking it before any damage is done.

Think of them as middleware for your AI agent. The tool fires an event, your hook intercepts it, does its check, and returns a decision. If the hook returns {"continue": false}, Claude stops. If it returns {"continue": true} (or nothing), Claude proceeds.

The four hook events

Claude Code exposes four events you can hook into:

PreToolUse — fires before any tool runs. You can inspect the tool input and block execution. This is where guardrails live.

PostToolUse — fires after a tool completes. You get the tool output. Use this for logging, formatting, or triggering follow-on actions.

Notification — fires when Claude sends a notification (waiting for input, task complete, etc.). Good for custom alerts.

Stop — fires when the agent finishes a task. Use this for cleanup, summaries, or Slack notifications.

There’s also SubagentStop which fires when a subagent finishes, if you’re running parallel agents.

Where hooks live

Everything goes in ~/.claude/settings.json. The structure looks like this:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "/Users/you/scripts/scan-secrets.sh"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit",
        "hooks": [
          {
            "type": "command",
            "command": "/Users/you/scripts/auto-format.sh"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "/Users/you/scripts/notify-complete.sh"
          }
        ]
      }
    ]
  }
}

The matcher field is a regex matched against the tool name. Write|Edit matches both the Write tool and the Edit tool. Leave it out to match all tools for that event.

What your hook receives

Every hook gets a JSON object via stdin. For a PreToolUse hook on the Write tool, it looks like this:

{
  "session_id": "abc123",
  "hook_event_name": "PreToolUse",
  "tool_name": "Write",
  "tool_input": {
    "file_path": "/Users/you/project/.env",
    "content": "STRIPE_SECRET_KEY=sk_live_abc123..."
  }
}

For a Bash tool, tool_input contains command instead of file_path. For Edit, you get file_path, old_string, and new_string. The shape matches the tool’s schema.

PostToolUse hooks also get tool_response — the actual output the tool returned.

What your hook must return

This is the part that trips people up.

If your hook writes anything to stdout, it must be valid JSON. The Claude Code protocol reads stdout as structured data. If you print plain text, you’ll get protocol errors.

#!/bin/bash
# WRONG - will break the protocol
echo "Scanning for secrets..."
echo '{"continue": true}'

# RIGHT - suppress all non-JSON output
exec 2>/dev/null
echo '{"continue": true}'

The valid return fields are:

{
  "continue": true,
  "suppressOutput": false,
  "decision": "approve",
  "reason": "No secrets found"
}

continue: false blocks the tool. suppressOutput: true hides the hook output from Claude’s context. reason gets shown in the UI when you block.

If your script exits with code 0 and returns nothing, Claude proceeds. If it exits non-zero, Claude treats it as a blocking error.

Hook 1: Secret scanner

This is the one I wish I’d had from day one. It runs before any Write or Edit and blocks the operation if it finds credentials.

#!/bin/bash
# ~/.claude/scripts/scan-secrets.sh

exec 2>/dev/null

INPUT=$(cat)
CONTENT=$(echo "$INPUT" | python3 -c "
import json, sys
d = json.load(sys.stdin)
ti = d.get('tool_input', {})
print(ti.get('content', '') + ti.get('new_string', ''))
" 2>/dev/null || echo "")

# Check for common secret patterns
PATTERNS=(
  'sk_live_[A-Za-z0-9]+'
  'xoxb-[A-Za-z0-9-]+'
  'AKIA[A-Z0-9]{16}'
  'ghp_[A-Za-z0-9]{36}'
  'rpa_[A-Za-z0-9]+'
  'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'
)

for pattern in "${PATTERNS[@]}"; do
  if echo "$CONTENT" | grep -qE "$pattern"; then
    echo "{"continue": false, "reason": "Blocked: potential secret detected matching pattern $pattern"}"
    exit 0
  fi
done

echo '{"continue": true}'

Register it in settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit|NotebookEdit",
        "hooks": [{ "type": "command", "command": "/Users/you/.claude/scripts/scan-secrets.sh" }]
      }
    ]
  }
}

Now every file write goes through the scanner. If it finds a Stripe live key, Slack token, or AWS key, it blocks with a reason Claude can read and explain.

Hook 2: Auto-formatter

After Claude edits a TypeScript or Python file, run the formatter automatically. No more “Claude wrote valid code but wrong indentation.”

#!/bin/bash
# ~/.claude/scripts/auto-format.sh

exec 2>/dev/null

INPUT=$(cat)
FILE=$(echo "$INPUT" | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(d.get('tool_input', {}).get('file_path', ''))
" 2>/dev/null || echo "")

if [[ -z "$FILE" ]]; then
  echo '{"continue": true}'
  exit 0
fi

case "$FILE" in
  *.ts|*.tsx)
    command -v prettier &>/dev/null && prettier --write "$FILE" &>/dev/null
    ;;
  *.py)
    command -v ruff &>/dev/null && ruff format "$FILE" &>/dev/null
    ;;
esac

echo '{"continue": true}'

PostToolUse on Edit:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [{ "type": "command", "command": "/Users/you/.claude/scripts/auto-format.sh" }]
      }
    ]
  }
}

The formatter runs silently after every edit. Claude’s next read of the file sees clean, formatted code without any back-and-forth.

Hook 3: Slack notification on task complete

I work with Claude running in the background while I do other things. The Stop hook lets me know when it’s done without watching the terminal.

#!/bin/bash
# ~/.claude/scripts/notify-complete.sh

exec 2>/dev/null

TOKEN="${SLACK_BOT_TOKEN:-}"
CHANNEL="${SLACK_NOTIFY_CHANNEL:-}"

if [[ -z "$TOKEN" || -z "$CHANNEL" ]]; then
  echo '{"continue": true}'
  exit 0
fi

INPUT=$(cat)
SESSION=$(echo "$INPUT" | python3 -c "
import json, sys
print(json.load(sys.stdin).get('session_id', 'unknown'))
" 2>/dev/null || echo "unknown")

curl -sf -X POST https://slack.com/api/chat.postMessage 
  -H "Authorization: Bearer $TOKEN" 
  -H "Content-Type: application/json" 
  -d "{"channel":"$CHANNEL","text":":white_check_mark: Claude finished task (session: $SESSION)"}" 
  > /dev/null

echo '{"continue": true}'
{
  "hooks": {
    "Stop": [
      {
        "hooks": [{ "type": "command", "command": "/Users/you/.claude/scripts/notify-complete.sh" }]
      }
    ]
  }
}

Now when a long refactor finishes, my phone buzzes.

Hook 4: Approval gate for destructive bash commands

This one requires more care. Some bash commands are irreversible — dropping databases, deleting branches, modifying production configs. The PreToolUse hook on Bash lets you intercept these.

#!/bin/bash
# ~/.claude/scripts/approve-destructive.sh

exec 2>/dev/null

INPUT=$(cat)
CMD=$(echo "$INPUT" | python3 -c "
import json, sys
print(json.load(sys.stdin).get('tool_input', {}).get('command', ''))
" 2>/dev/null || echo "")

DESTRUCTIVE_PATTERNS=(
  'rm -rf'
  'drop table'
  'DROP TABLE'
  'git push --force'
  'git reset --hard'
  'kubectl delete'
  'systemctl stop'
)

for pattern in "${DESTRUCTIVE_PATTERNS[@]}"; do
  if echo "$CMD" | grep -qF "$pattern"; then
    echo "{"continue": false, "reason": "Blocked: '$pattern' requires explicit approval. Run the command manually if intended."}"
    exit 0
  fi
done

echo '{"continue": true}'

This doesn’t ask for approval interactively — that would deadlock. Instead it blocks and explains. You review the command, run it yourself if it’s correct, and Claude continues from there.

Gotchas that cost me time

Shell profile output breaks hooks. If your .zshrc or .bashrc prints anything (greeting messages, nvm output, conda activation), it will pollute the hook stdout. Either suppress it or use exec 2>/dev/null at the top of every hook script.

Hooks run in a non-interactive shell. Your PATH, aliases, and shell functions aren’t loaded. Use full absolute paths to commands (/opt/homebrew/bin/prettier, not prettier).

PreToolUse latency adds up. If your hook takes 500ms and Claude runs 50 Edit operations, that’s 25 extra seconds. Profile your hooks. Secret scanning should be under 50ms. If it’s slow, check for regex backtracking.

The matcher is a regex, not a glob. Write|Edit works. Write* does not.

Empty stdout is fine, but non-JSON stdout breaks things. Add exec 2>/dev/null to redirect stderr, then only ever echo valid JSON.

My actual settings.json hooks section

This is what I run across all projects:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit|NotebookEdit",
        "hooks": [{ "type": "command", "command": "/Users/chudinnorukam/.claude/scripts/scan-secrets.sh" }]
      },
      {
        "matcher": "Bash",
        "hooks": [{ "type": "command", "command": "/Users/chudinnorukam/.claude/scripts/approve-destructive.sh" }]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [{ "type": "command", "command": "/Users/chudinnorukam/.claude/scripts/auto-format.sh" }]
      }
    ],
    "Stop": [
      {
        "hooks": [{ "type": "command", "command": "/Users/chudinnorukam/.claude/scripts/notify-complete.sh" }]
      }
    ]
  }
}

Four hooks. They cover the 90% case: secrets, destructive commands, formatting, and completion notifications. Everything else I handle manually because the hook overhead isn’t worth it for low-frequency events.

Where to go from here

Hooks are composable. You can chain multiple hooks on the same event. You can use them to log every tool call to a file for auditing. You can build approval workflows that post to Slack and wait for a reply before proceeding.

The pattern I’m building toward: a full audit log of every Claude action, with replay capability. Every Write, Edit, and Bash call gets logged with the session ID, timestamp, and tool input. When something goes wrong, I can reconstruct exactly what happened and in what order.

That’s the next post. For now, start with the secret scanner. It’s the one hook that pays for itself the first time it catches something.


If you’re using Claude Code for real projects, you already know the trust issue. You can’t review every edit. Hooks are how you stop trusting blindly and start trusting with guardrails.

FAQ

What are Claude Code hooks?

Hooks are shell commands that Claude Code runs automatically when specific tool events occur, like before writing a file or after running a bash command. You configure them in ~/.claude/settings.json.

Can hooks stop Claude from running a command?

Yes. PreToolUse hooks can return {"continue": false} to block the tool entirely. This is useful for approval gates on destructive operations.

Where do I define Claude Code hooks?

In ~/.claude/settings.json under a top-level 'hooks' key. Each entry specifies an event type, an optional tool matcher, and the shell command to run.

What context do hooks receive?

Hooks receive a JSON object via stdin containing the session ID, tool name, tool input (file path, command, etc.), and for PostToolUse hooks, the tool output.

Do hooks affect performance?

PreToolUse hooks add latency because Claude waits for them to finish before proceeding. Keep them fast. PostToolUse hooks run after the tool returns so they don't block the main flow.

Sources & Further Reading

Further Reading

Discussion

Comments powered by GitHub Discussions coming soon.