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.