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 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
{
"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:
{
"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.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 0Wire 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 0This 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 0Claude 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.
| 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:
{
"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?
| 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
#!/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 0Hook it for all tools with a catch-all matcher:
{
"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, 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
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?
- Add a
PreToolUsehook onBashthat logs every command to a file. Run it for a day. You will see what the agent does, which informs every other guardrail. - Add a block list of the five most dangerous patterns you see (force pushes, recursive deletes of non-artifact directories, credential files).
- Configure
deniedToolsfor any tool the agent should never use in your workflow (oftenWebFetchif you want a fully offline agent). - Add a
PostToolUsehook onWriteandEditto run your formatter. This eliminates a whole class of "Claude wrote valid code but wrong style" follow-up prompts. - Add a
Stophook to send yourself a desktop or Slack notification. Long agentic runs are not worth babysitting.
Everything else is refinement.