PreToolUse-Hook gegen destruktive Bash-Befehle in 30 Minuten
Wie du einen Bash-Befehl wie rsync, rm -rf oder DROP TABLE blockst bevor Claude ihn ausführt. Echte Failure-Story, fertiges Hook-Skript zum Kopieren.
Am 27. Mai hat ein rsync -av --delete meine /root/.ssh/authorized_keys auf Prod überschrieben. SSH zum Server tot. Academy musste auf Alt-Dev umziehen, kompletter Tag für Recovery weg. Am Abend stand der Hook hier. Seitdem kommt rsync nicht mehr durch, egal wie geschickt das Modell den Befehl formuliert. Das hier ist die 30-Minuten-Anleitung wie du dir das gleiche Schutzschild für deine Daily-Driver-Befehle baust.
1. Verstehen warum PreToolUse die richtige Stelle ist
Claude Code hat mehrere Hook-Phasen. PreToolUse feuert BEVOR ein Tool-Call rausgeht. Das heißt: du siehst den Befehl, du kannst ihn approven oder blocken. Der Befehl ist noch nicht gelaufen. Wenn du erst in PostToolUse blockst, ist der Schaden schon passiert. Wenn du im System-Prompt sagst "bitte kein rsync" ignoriert das Modell das in jedem dritten Run. Hook ist die einzige Stelle die nicht von Modell-Laune abhängt.
Doku zur Hook-Mechanik: docs.claude.com/en/docs/claude-code/hooks. Lies mindestens den Abschnitt zu tool_input und decision.
2. Den eigenen "Niemals"-Befehl finden
Bevor du loslegst: was ist DEIN destruktiver Befehl? Bei mir war es rsync --delete. Bei dir könnten es sein:
DROP TABLEoderTRUNCATEin einer Datenbankgit push --forceauf maindocker volume rmauf einem Server mit Prod-Datenprisma migrate resetoderdb push --force-resetaws s3 rm --recursivekubectl delete namespace
Schreib dir die Liste auf bevor du den Hook baust. Ein Hook der drei verschiedene Patterns blockt ist genauso teuer wie einer der nur ein Pattern blockt.
3. Hook-Skelett im Bash kopieren
Hooks lesen JSON von stdin und schreiben JSON nach stdout. Das Skelett ist immer gleich:
#!/usr/bin/env bash
set -euo pipefail
input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name // empty' 2>/dev/null || echo "")
if [ "$tool_name" != "Bash" ]; then
echo '{"decision":"approve"}'
exit 0
fi
cmd=$(echo "$input" | jq -r '.tool_input.command // empty' 2>/dev/null || echo "")
Drei Sachen die wichtig sind. Erstens: set -euo pipefail damit das Skript hart fällt wenn jq fehlt. Zweitens: bei jedem Tool das nicht Bash ist, approve und raus. Sonst blockst du Read, Edit, mcp-Calls. Drittens: jq muss installiert sein. apt install jq oder brew install jq.
4. Das Regex für den Match richtig hinkriegen
Naiv würdest du grep rsync machen. Das matcht aber auch crsynck (gibt es nicht, aber als Argument-Substring schon möglich) und matcht NICHT /usr/bin/rsync weil der Präfix anders ist. Das Pattern hier matcht rsync als ganzes Wort mit Präfix-Toleranz:
if echo "$cmd" | grep -qE '(^|[^a-zA-Z0-9_./-])rsync($|[^a-zA-Z0-9_-])'; then
# block
fi
Word-Boundary inklusive Slash, Punkt und Bindestrich. Fängt rsync, /usr/bin/rsync, env rsync, sudo rsync, ssh prod 'rsync ...'. Fängt NICHT mysync oder rsyncextra. Bau dir das gleiche Pattern für deinen Befehl. Bei git push --force ist es schwieriger, da brauchst du git[[:space:]]+push[[:space:]]+(-f|--force).
5. Den Bypass-Mechanismus einbauen
Du willst nicht dass der Hook dich blockt wenn du den Befehl WIRKLICH brauchst (z.B. einmal pro Quartal eine legitime rsync-Migration). Lösung: ENV-Var als Opt-In:
if [ "${RSYNC_OK:-0}" = "1" ]; then
echo '{"decision":"approve"}'
exit 0
fi
Im Use-Case schreibst du dann RSYNC_OK=1 rsync ... und der Hook lässt durch. Wichtig: der Bypass MUSS pro Befehl gesetzt werden, nicht als Default in .bashrc. Sonst war die ganze Uebung sinnlos. Bei mir steht im Memory drin: "Bypass nur mit Matthias' explizitem OK". Das ist dann mein Ankerpunkt.
6. Read-only Meta-Befehle whitelisten
Wenn du in einem Help-Modus mit Claude sitzt und das Modell rsync --help aufrufen will um Optionen zu lesen, ist das harmlos. Block wäre nervig. Daher: Whitelist für --help, --version, -h, -V:
if echo "$cmd" | grep -qE 'rsync[[:space:]]+(--help|--version|-h\b|-V\b)([[:space:]]|$)'; then
echo '{"decision":"approve"}'
exit 0
fi
Prüfe deine eigenen "lesenden" Varianten. Bei git push wäre es git push --dry-run. Bei DROP TABLE gibt es keinen Read-only-Modus, also keine Whitelist.
7. Die Block-Reason richtig formulieren
Wenn der Hook blockt, sieht Claude den reason-String. Den schreibst du für Claude UND für dein zukünftiges Ich. Drei Komponenten reingehören:
- WAS wurde blockt und warum
- KONKRETE Alternative die der Use-Case löst
- BYPASS wie der User trotzdem durchkommt
Beispiel das bei mir hilft:
BLOCKED by rsync-block hook: rsync is FORBIDDEN. Source-state and
server-state are NEVER identical (server has logs, .ssh, tokens that
--delete will erase). Use instead: (a) git push + git pull for code,
(b) docker compose up -d --build for containers, (c) docker save | gzip
| ssh | docker load for images, (d) scp without --delete for single
files. If you absolutely must use rsync, ask first and run with
RSYNC_OK=1 prefix.
Modell liest den Reason und schlägt dann selber die richtige Alternative vor. Ohne Reason würde es einfach mit rsync weiterversuchen.
8. Hook in settings.json eintragen
Das Skript allein tut nichts. Du musst Claude Code sagen welche Bash-Calls durch den Hook gehen sollen. In ~/.claude/settings.json (global) oder .claude/settings.json (projekt-lokal):
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/home/dein-user/.claude/hooks/rsync-block.sh"
}
]
}
]
}
}
Pfad muss absolut sein. ~ wird in manchen Versionen nicht expandiert. Hook-Skript muss chmod +x. Settings danach reloaden mit /hooks in Claude Code oder einfach neu starten.
9. Selber testen bevor du dich drauf verlässt
Drei Testfälle mindestens:
# muss blocken
echo '{"tool_name":"Bash","tool_input":{"command":"rsync -av src/ prod:/"}}' | ./rsync-block.sh
# muss approven (Whitelist)
echo '{"tool_name":"Bash","tool_input":{"command":"rsync --help"}}' | ./rsync-block.sh
# muss approven (anderes Tool)
echo '{"tool_name":"Read","tool_input":{"file_path":"/etc/hosts"}}' | ./rsync-block.sh
Wenn alle drei Ausgaben stimmen (block, approve, approve), in Claude Code testen: "fuehr mal rsync -av aus". Das Modell soll den Block sehen und auf git pull umschwenken. Erst dann ist der Hook produktiv.
10. Ins Memory schreiben + Brüder-Hooks bauen
Letzter Schritt ist organisatorisch. Schreib in deine ~/.claude/CLAUDE.md oder MEMORY.md einen Eintrag: "rsync ist hard-enforced durch ~/.claude/hooks/rsync-block.sh, Bypass nur mit explizitem OK". Damit erinnert sich das Modell daran auch wenn der Hook mal nicht greift (z.B. in einer Session ohne settings.json).
Und: dieser Hook ist NIE der einzige bleiben. Sobald du einen destruktiven Befehl identifiziert hast den du nicht mehr per Hand tippen willst, klont sich das Pattern in 5 Minuten. Bei mir gibt es jetzt drei Hooks die zusammen 95 Prozent meiner historischen Unfälle abdecken. Jeder einzelne hat sich beim ersten Block schon bezahlt gemacht.
Was als nächstes
Wenn du den Hook stabil hast, ist Hooks debuggen wenn nichts feuert der nächste Schritt für die typischen Fallen (settings.json nicht reloaded, jq fehlt, Pfad relativ). Wenn du daraus eine Halluzinations-Defense bauen willst, schau dir Hooks gegen Halluzinationen an. Für das größere Bild: Lesson Hooks und Skills in Level 4 ordnet PreToolUse vs PostToolUse vs SessionEnd ein.
Source
- Claude Code Hooks Doku: https://docs.claude.com/en/docs/claude-code/hooks
- PreToolUse Decision-Schema (block/approve mit reason): siehe gleiche Page, Abschnitt "Decision flow"