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.
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 |
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 to
stderr is fed back to the model, so the agent learns why
and can change course or ask the user instead of blindly retrying.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" }] }
]
}
}
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.
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.
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.
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.