Build Claude Code hook configurations: shell commands that fire on lifecycle events to validate, format, log, or block what the agent is about to do.
Pick the hooks you want. Copy the settings.json block, copy the shell scripts, drop into .claude/hooks/, and mark executable. Done.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/block-rm-rf.sh"
},
{
"type": "command",
"command": ".claude/hooks/block-force-push.sh"
},
{
"type": "command",
"command": ".claude/hooks/block-terraform-destroy.sh"
}
]
},
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/budget-guard.sh 200000"
}
]
}
]
}
}# === .claude/hooks/block-rm-rf.sh ===
#!/usr/bin/env bash
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if echo "$CMD" | grep -qE 'rm[[:space:]]+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r|-rf|-fr)([[:space:]]|$)'; then
if ! echo "$CMD" | grep -qE '/tmp/'; then
echo "BLOCKED: refusing 'rm -rf' outside /tmp." >&2
exit 2
fi
fi
exit 0
# === .claude/hooks/block-force-push.sh ===
#!/usr/bin/env bash
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if echo "$CMD" | grep -qE 'git[[:space:]]+push[[:space:]].*(--force|-f([[:space:]]|$))'; then
if echo "$CMD" | grep -qE '(main|master|prod|release/)'; then
echo "BLOCKED: force-push to a protected branch." >&2
exit 2
fi
fi
exit 0
# === .claude/hooks/block-terraform-destroy.sh ===
#!/usr/bin/env bash
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if echo "$CMD" | grep -qE '(terraform[[:space:]]+destroy|kubectl[[:space:]]+delete[[:space:]]+namespace|aws[[:space:]]+s3[[:space:]]+rb)'; then
echo "BLOCKED: infra teardown is human-approval only." >&2
exit 2
fi
exit 0
# === .claude/hooks/budget-guard.sh 200000 ===
#!/usr/bin/env bash
# .claude/hooks/budget-guard.sh
CAP=${1:-200000}
STATE_FILE=".claude/.budget-tokens"
[ -f "$STATE_FILE" ] || echo 0 > "$STATE_FILE"
USED=$(cat "$STATE_FILE")
INPUT=$(cat)
EST=$(echo "$INPUT" | jq -r '.tool_input.command // empty' | wc -c | awk '{ print $1/4 }')
NEW=$(awk "BEGIN { print $USED + $EST }")
echo "$NEW" > "$STATE_FILE"
if awk "BEGIN { exit !($NEW > $CAP) }"; then
echo "BLOCKED: per-task token budget ($CAP) exceeded. Reset with: rm $STATE_FILE" >&2
exit 2
fi
exit 0
chmod +x .claude/hooks/*.sh. Start a new Claude Code session in the repo. Test with a deliberately destructive prompt and confirm the hook fires.Hooks are the difference between an agent you supervise continuously and one you can leave running. A pre-tool-use hook that inspects the command and returns a non-zero exit code blocks the action deterministically, which is a fundamentally stronger guarantee than an instruction in a prompt, because it does not depend on the model choosing to comply.
The highest-value hooks are the boring ones: block rm -rf outside a scratch directory, refuse force-pushes to main, run the formatter after every file write, log every command to an audit file. Each takes a couple of lines and removes an entire category of incident.
Test hooks carefully before relying on them. A hook with a bug that always exits non-zero blocks every tool call and makes the agent look broken; one that always exits zero silently protects nothing.