Hooks as Guardrails

PreToolUse blocking, audit logging, and security hooks

Hooks are not just an extensibility mechanism — they are the only safety layer in Claude CLI that cannot be bypassed, even with --dangerously-skip-permissions. By wiring shell scripts or HTTP endpoints into the tool execution lifecycle, you can enforce hard security boundaries that apply to every session, every user, and every agent. This chapter focuses on the security side: blocking dangerous operations, logging for compliance, and centralizing policy enforcement.

PreToolUse Blocking

The PreToolUse event fires before every matched tool call, giving your hook a chance to inspect the input and block execution. A hook that exits with code 2 prevents the tool from running entirely. The error message you write to stderr is fed back to Claude as the reason the action was denied.

Add this to your .claude/settings.json:

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

The hook script at .claude/hooks/block-dangerous.sh:

#!/usr/bin/env bash
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
BLOCKED=(
"rm -rf /"
"rm -rf ~"
"DROP TABLE"
"DROP DATABASE"
"> /dev/sda"
"mkfs."
":(){ :|:& };:"
)
for pattern in "${BLOCKED[@]}"; do
if echo "$COMMAND" | grep -qi "$pattern"; then
echo "BLOCKED: pattern '$pattern' matched" >&2
exit 2 # Exit code 2 = BLOCK the tool call
fi
done
exit 0 # Exit code 0 = allow

When Claude attempts to run any command containing a blocked pattern, the hook fires, the tool call is prevented, and Claude receives the stderr message explaining why. The matcher field is a regex — "Bash" targets only Bash tool calls. You can broaden it with "Bash|Edit|Write" to cover file operations, or use "mcp__.*" to guard all MCP tool calls.

Multiple hooks in the same array run sequentially. The first one to exit with code 2 wins — subsequent hooks in the chain are skipped, and the tool call is blocked.

Audit Logging

For compliance and forensics, you need a record of everything Claude does. PostToolUse hooks fire after a tool has executed successfully, making them the right place for audit logging — the tool has already run, so the hook cannot block it, but it can record the full context of what happened.

Add a PostToolUse logging hook to .claude/settings.json:

{
"hooks": {
"PostToolUse": [
{
"hooks": [
{
"type": "command",
"command": "./.claude/hooks/audit-log.sh",
"async": true
}
]
}
]
}
}

The logging script at .claude/hooks/audit-log.sh:

#!/usr/bin/env bash
INPUT=$(cat)
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
TOOL=$(echo "$INPUT" | jq -r '.tool_name // "unknown"')
TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input // {}')
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}"
LOG_DIR="$PROJECT_DIR/.claude/logs"
mkdir -p "$LOG_DIR"
echo "${TIMESTAMP}|${TOOL}|${TOOL_INPUT}" >> "$LOG_DIR/audit.log"
exit 0

Notice two key details. First, the hook omits the matcher field — no matcher means it fires for every tool type, capturing Bash commands, file edits, MCP calls, and everything else. Second, "async": true runs the hook in the background so logging never slows down Claude’s execution. Since this is a PostToolUse hook, exit code 2 does not undo anything — the tool already ran.

You can also log PreToolUse events to capture attempted operations that were allowed. Together, Pre and Post logs give you a complete picture: what was attempted, what was allowed, and what happened.

Command Hooks for Validation

Shell-based command hooks are the simplest way to build input validation. The hook receives the full tool call payload as JSON on stdin, which means your script can parse and inspect any aspect of the tool input before deciding whether to allow or block.

Here is a more targeted validator that blocks write operations to specific protected paths:

.claude/hooks/protect-paths.sh
#!/usr/bin/env bash
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name // ""')
FILE_PATH=""
if [ "$TOOL" = "Edit" ] || [ "$TOOL" = "Write" ]; then
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
elif [ "$TOOL" = "Bash" ]; then
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
fi
PROTECTED=(".env" "credentials" "secrets" "id_rsa" ".ssh/")
for pattern in "${PROTECTED[@]}"; do
if echo "$FILE_PATH" | grep -qi "$pattern"; then
echo "BLOCKED: operation on protected path matching '$pattern'" >&2
exit 2
fi
done
exit 0

Wire it into settings with a broad matcher to cover both file operations and Bash commands:

{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Edit|Write",
"hooks": [
{
"type": "command",
"command": "./.claude/hooks/protect-paths.sh"
}
]
}
]
}
}

Command hooks must be fast. PreToolUse hooks gate every matched tool call, so anything over 500ms will make Claude feel sluggish. Keep validation logic simple — pattern matching and string checks, not network calls or heavy computation.

HTTP Hooks for Central Policy

For teams that need centralized control, HTTP hooks send the tool call payload as a POST request to a policy server. This lets you enforce organization-wide rules from a single endpoint rather than distributing shell scripts to every developer machine.

{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "http",
"url": "http://localhost:8080/hooks/validate",
"headers": {"Authorization": "Bearer $POLICY_TOKEN"},
"allowedEnvVars": ["POLICY_TOKEN"],
"timeout": 5
}
]
}
]
}
}

The policy server receives the full hook input as the POST body and responds with a JSON decision. To block a tool call from the server side, return a JSON body with the decision and reason fields:

{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Command contains DROP TABLE"
}
}

The permissionDecision field supports three values: "allow" to approve, "deny" to block, or "ask" to fall through to the normal permission prompt. This gives the policy server fine-grained control — it can hard-block known-dangerous patterns, auto-approve known-safe operations, and defer ambiguous cases to the user.

HTTP hooks have a default timeout of 30 seconds, but for security validation you should keep this short (5 seconds or less). If the policy server is unreachable and the request times out, the hook is treated as a non-blocking error — the tool call proceeds. Design your architecture with this fail-open behavior in mind.

Security Hook Patterns

Security Hook Pattern Reference

PatternEventHandlerBehavior
Block destructive commandsPreToolUsecommandExit 2 on rm -rf, DROP TABLE, mkfs patterns
Block writes to protected pathsPreToolUsecommandExit 2 when file path matches .env, .ssh, secrets
Block all MCP write operationsPreToolUsecommandMatcher mcp__.__write. with exit 2
Log all tool executionsPostToolUsecommand (async)Append tool name, input, and timestamp to audit log
Log all MCP operationsPreToolUsecommandMatcher mcp__.*, pipe stdin to log file
Central policy enforcementPreToolUsehttpPOST to policy server, deny/allow/ask per response
AI-powered command reviewPreToolUsepromptSend command to a fast model for safety evaluation
Alert on sensitive file accessPostToolUsehttpPOST to alerting endpoint when sensitive paths are touched
Prevent premature agent stopStopcommandExit 2 if verification script finds incomplete work
Exit Code Semantics Are Reversed

In standard Unix conventions, exit code 2 typically means a usage error or misuse. In Claude hooks, exit code 2 means block the operation. Exit code 0 means allow. Any other non-zero exit code (1, 3, etc.) is treated as a non-blocking error — stderr is shown in verbose mode, but the tool call proceeds. If you bring Unix muscle memory to hook scripts, you will accidentally allow operations you meant to block.

Note

Hooks fire even when —dangerously-skip-permissions is active. This is by design — hooks are the one safety layer that cannot be bypassed. Use them as the foundation of your defense-in-depth strategy.

See The Hooks System for the full event and handler type reference.