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.
SponsorThis spot is open to one DevOps or AI tooling company at a time. Ask about sponsoring
116 of 118 run entirely in your browser (Weather Widget uses Open-Meteo; What's My IP asks this site's server).
Share on XShare on LinkedInHooks 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.
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.