Claude Code is very good at running commands. That's exactly the problem when it runs the wrong one. Hooks let you add a safety net that runs before every action, without relying on the model "remembering" your rules.
How they work (in 30 seconds)
- They're configured in
.claude/settings.json, per event (PreToolUse,PostToolUse,Notification…) and per tool (Bash,Write|Edit…). - The hook receives the event as JSON on stdin.
- If it exits with code 2, the action is blocked and the stderr text is sent back to Claude as the reason.
1. Block destructive commands
.claude/hooks/block-dangerous-commands.sh:
#!/usr/bin/env bash
input="$(cat)"
patterns=(
'git[^;&|]*[[:space:]]push([[:space:]][^;&|]*)?[[:space:]](--force([^-]|$)|-[a-zA-Z]*f[a-zA-Z]*([[:space:]"]|$)|\+[^[:space:]"]+)'
'git[^;&|]*[[:space:]]reset([[:space:]][^;&|]*)?[[:space:]]--hard'
'DROP[[:space:]]+(TABLE|DATABASE)'
'(curl|wget)[^|]*\|[[:space:]]*(sudo[[:space:]]+)?(ba)?sh'
)
for pattern in "${patterns[@]}"; do
if printf '%s' "$input" | grep -qiE -- "$pattern"; then
echo "Blocked: matches a dangerous pattern. Ask the user to run it." >&2
exit 2
fi
done
exit 0
Two details in the push pattern: --force([^-]|$) blocks --force but lets --force-with-lease through, which is the safe way to force-push; and \+[^[:space:]"]+ catches a + refspec (git push origin +main), which forces without ever typing --force. The git[^;&|]* prefix also catches git -C some/dir push --force and git reset -q --hard.
.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-dangerous-commands.sh" }
]
}
]
}
}
Don't forget chmod +x, then test it:
echo '{"tool_input":{"command":"git push origin +main"}}' | .claude/hooks/block-dangerous-commands.sh; echo $?
# → 2
I tested it in a real session: I asked Claude to run git push --force origin main and it came back with "blocked by a PreToolUse hook, run it yourself if you really need it".
Defense in depth: a regex hook is an early warning, not a guarantee. A reader of the Spanish version of this post found two forced-push variants that slipped past my first patterns (both fixed above). For anything irreversible, put the real block where the agent can't edit it: branch protection on the remote with force pushes disabled. If the regex falls short, the push bounces off the server.
2. Catch secrets before they're written
Same pattern with matcher: "Write|Edit|MultiEdit" and regexes like AKIA[0-9A-Z]{16} (AWS), sk-ant-[A-Za-z0-9_-]{20,} (Anthropic) or gh[pousr]_[A-Za-z0-9]{36,} (GitHub). When Claude tries to write a key, it gets "use an environment variable and document it in .env.example" and fixes it on its own.
3. Format after every edit
A PostToolUse hook that reads file_path from the JSON and runs the project's own Prettier/Biome (node_modules/.bin/prettier). It always exits 0: it never blocks, it just tidies.
Free code
Hooks 1 and 2, with 27 tests running on Linux and macOS, are on GitHub under MIT: https://github.com/kailucho/claude-code-hooks-seguridad (docs in Spanish, English summary at the top).
If you want it all wired up
The Starter Kit for Claude Code (US$9, unofficial) bundles the three hooks plus a notifications one, 6 skills (/commit, /pr, /review, /tests, /plan, /debug), CLAUDE.md templates for React, Node and GraphQL, and permission presets: https://luijhy.gumroad.com/l/starter-kit-for-claude-code
What other command would you block? Let me know in the comments.
Top comments (0)