Claude Code can run shell commands, edit files, and push to git on your behalf. That power is the point — and the risk. Hooks are the mechanism for putting deterministic guardrails around it: small scripts the harness runs at defined points in the agent's loop, able to inspect — and block — what the agent is about to do.
This guide covers the PreToolUse hook specifically, because it's the one that can stop a dangerous action before it executes.
The hook lifecycle
Claude Code fires hooks at several lifecycle events. The most useful for safety are:
| Event | Fires | Can block? |
|---|---|---|
PreToolUse |
Before a tool call runs | Yes — exit 2 |
PostToolUse |
After a tool call returns | No (observe/log) |
SessionStart |
When a session begins | No (inject context) |
Stop |
When the agent finishes a turn | Yes — can require more work |
How a PreToolUse hook receives the tool call
When the agent is about to call a tool, the harness serializes the call as JSON and pipes it to your hook on stdin. For a Bash command, that looks like:
{
"tool_name": "Bash",
"tool_input": { "command": "git push --force origin main" }
}
Your hook reads that JSON, decides, and signals its verdict through the exit code:
-
exit 0— allow the call to proceed. -
exit 2— block the call. Anything the hook wrote tostderris fed back to the model, so the agent learns why and can change course or ask the user instead of blindly retrying.
A minimal command gate
Here's a complete PreToolUse hook that blocks force-pushes to main:
#!/usr/bin/env bash
set -euo pipefail
cmd=$(jq -r '.tool_input.command // empty')
[ -z "$cmd" ] && exit 0
if echo "$cmd" | grep -qE 'git\s+push\s+.*(--force|-f)\b.*\bmain\b'; then
echo "BLOCKED: force-push to main. Ask the user first." >&2
exit 2
fi
exit 0
Register it in your plugin's hooks.json:
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash",
"hooks": [{ "type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/gate.sh" }] }
]
}
}
Three patterns worth gating
1. Secrets before a push. The highest-value gate. Before any git push, scan the outgoing commits — not the working tree — for credential-shaped strings and forbidden filenames (.env, private keys). Use gitleaks if available, with a regex layer as a portable fallback. A single leaked key that reaches a public remote must be treated as compromised even after force-removal, so catching it pre-push is the whole game.
2. Blast-radius commands. Some commands are correct in intent but catastrophic when a variable is empty or a path is wrong: rm -rf "$DIR" with $DIR unset, git reset --hard origin/main discarding local work, chmod 777, curl ... | sh. Gate the patterns and make the agent surface them to a human.
3. Shared state an agent shouldn't touch. If you run multiple agent sessions, one can "clean up" another's git worktree that merely looks orphaned. Gate writes to .claude/worktrees/ and refuse git add -A in repos that contain them, since -A silently stages worktree gitlinks.
A gotcha: don't assume jq is installed
Hooks run in whatever shell the user has. jq is common but not guaranteed. Make the JSON extraction degrade gracefully:
extract_cmd() {
if command -v jq >/dev/null 2>&1; then
jq -r '.tool_input.command // empty'
elif command -v python3 >/dev/null 2>&1; then
python3 -c 'import json,sys; print(json.load(sys.stdin).get("tool_input",{}).get("command",""))'
fi
}
A hook that errors out because a dependency is missing fails open — the dangerous command runs anyway. Test your hooks against the no-jq case.
If you'd rather not write and maintain these yourself, I packaged all three gates as a tested, one-command Claude Code plugin — secret scanning, dangerous-command gating, and worktree protection. It's free and MIT-licensed: cc-powerpack on GitHub.
These practices are covered in depth in The Claude Code Operator's Handbook — 18 chapters on running AI coding agents safely and efficiently. Read a free sample or get it ($29).
Top comments (0)