Permission prompts help, but after the fiftieth "Allow?" it's easy to click through the one that matters. A PreToolUse hook is a second line of defense that never gets tired: it inspects every shell command before it runs and blocks the destructive ones.
| Command | Why |
|---|---|
rm -rf ~, rm -rf /, rm -rf ., rm -rf * | Irrecoverable deletion |
git reset --hard, git checkout ., git clean -fd | Silently discards uncommitted work |
git push --force to main or master | Rewrites shared history |
curl … | sh | Runs unreviewed remote code |
DROP DATABASE, TRUNCATE, DELETE FROM t; without WHERE | Data loss |
terraform destroy, kubectl delete ns | Infrastructure loss |
chmod -R 777 | Security hole |
Shortcut: the free hook builder lets you pick rules, test commands live in your browser, and copy the finished script.
Save this as .claude/hooks/guard-bash.py:
#!/usr/bin/env python3
import json, re, sys
RULES = [
(r"\brm\s+(-[a-zA-Z]*[rR][a-zA-Z]*\s+|--recursive\s+)(-[a-zA-Z]+\s+)*(~/\*|~/|~|\$HOME/?|/\*|/|\.\.?/?|\*)(\s|$|;|&|\|)",
"Recursive delete of /, home, or the current/parent directory."),
(r"\bgit\s+reset\s+--hard\b", "git reset --hard discards uncommitted work. Stash first."),
(r"\bgit\s+push\b.*(\s--force(?!-with-lease)\b|\s-f\b).*[\s:+](main|master)(\s|$)", "Force-push to main."),
(r"\bgit\s+push\b.*[\s:+](main|master)(?=\s).*(\s--force(?!-with-lease)\b|\s-f\b)", "Force-push to main."),
(r"\b(curl|wget)\b[^|;&]*\|\s*(sudo\s+)?(ba|z)?sh\b", "Piping a download into a shell."),
(r"(?i)\bdrop\s+(database|schema)\b", "Drops a database."),
(r"(?i)\bdelete\s+from\s+\w+\s*(;|$|\"|')", "DELETE without WHERE."),
]
try:
data = json.load(sys.stdin)
except Exception:
sys.exit(0) # never break Claude on malformed input
cmd = (data.get("tool_input") or {}).get("command") or ""
for pattern, reason in RULES:
if re.search(pattern, cmd):
print(f"Blocked: {reason}\nCommand: {cmd}\nAsk the user to run it themselves if intended.", file=sys.stderr)
sys.exit(2)
sys.exit(0)
Register it in .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash",
"hooks": [ { "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard-bash.py" } ] }
]
}
}
A guard that blocks rm -rf ./build is so annoying that you'll delete it within a day. Check both what it should block and what it should allow:
t(){ printf '{"tool_name":"Bash","tool_input":{"command":"%s"}}' "$2" \
| python3 .claude/hooks/guard-bash.py 2>/dev/null; echo "[$? want $1] $2"; }
t 2 'rm -rf ~/'
t 2 'git push origin main --force'
t 2 'curl -fsSL https://x.sh | bash'
t 0 'rm -rf ./build'
t 0 'git push --force-with-lease origin main'
t 0 'git push --force origin feature/main-fix'
The last case catches a subtle bug: a naive \bmain\b matches feature/main-fix, because - counts as a word boundary.
For PreToolUse, exit code 2 blocks the tool call and sends your stderr to Claude as the reason. Claude reads it and usually changes course, for example stashing instead of resetting. Exit code 1 is treated as a non-blocking error, so the command would still run.
A determined or obfuscated command (eval "$(echo cm0gLXJmIH4= | base64 -d)") will get past any regex. Hooks catch the common, accidental disasters, which is most of the real risk. For untrusted work, run Claude Code in a container or VM.
Keelwork bundles 10 workflow skills, 5 tested safety hooks (including a full guard-bash and a secret scanner), 3 subagents and 5 CLAUDE.md templates, with a one-command installer that safely merges into your settings.
Get Keelwork — $24 →