Claude Code Hooks: gut zum Lenken, schwach als Schranke
Ein Hook läuft, ob das Modell will oder nicht. Das macht ihn gut für Routinearbeit wie Formatieren und dafür, einen Agenten auf den gewünschten Weg zu lenken. Als Schranke ist er schwächer, als er aussieht: Er prüft nur die Tools, auf die du ihn ansetzt, eine kaputte Prüfung lässt den Aufruf durch, solange du Blockieren nicht ausdrücklich einschaltest, und in manchen Setups antwortet nach ihm noch ein Mod.
Inhalt
Claude Code Hooks sind Befehle, die du in einer Settings-Datei registrierst, und Claude Code führt sie an festen Punkten einer Session aus: vor einem Tool-Aufruf, danach, wenn Claude aufhört, wenn es deine Aufmerksamkeit braucht. Das Modell entscheidet nie, ob ein Hook läuft. Genau das macht sie attraktiv, und genau deshalb greifen Leute zu Hooks als Sicherheitsschicht und vertrauen ihnen dann weiter, als es die Dokumentation selbst tut.
Meine eigene Bilanz zeigt beide Seiten. Im Februar liefen in meinem Agenten-Setup ein Bash-Guard, ein Kontext-Hook bei jedem Prompt und, bei ein paar Agenten, eine LLM-Prüfung am Ende jedes Turns. Im April flog der Guard raus, mit einer Commit-Notiz, die lautet: „ineffective, agents bypass via Write/Edit“. Diese Anleitung zeigt, was Hooks gut können, wie du einen schreibst, der im Fehlerfall blockiert, und welche Wege ein Hook nie sieht. Die Befehle unten liefen auf Claude Code 2.1.310 unter Linux; wo eine Aussage nur auf der Dokumentation beruht, steht das dabei.
Was ist ein Claude Code Hook, und welche Events können blockieren?
Ein Hook besteht aus einem Event, einem Matcher und einem Handler, und nur bei manchen Events kann der Handler etwas stoppen. PreToolUse kann einen Tool-Aufruf blockieren. PostToolUse kann es nicht, weil das Tool schon gelaufen ist.
Die Konfiguration ist dreistufig verschachtelt: das Event (PreToolUse, Stop), eine Matcher-Gruppe, die es filtert (bei Tool-Events der Tool-Name, etwa Bash oder Edit|Write), und ein oder mehrere Handler. Ein Handler ist in den meisten Setups ein Shell-Befehl, er kann aber auch ein HTTP-Endpoint sein, ein MCP-Tool, ein einzelner LLM-Prompt oder ein Subagent. Die Referenz listet mehr als dreißig Events. Die, mit denen die meisten Setups zu tun haben:
per session: SessionStart, SessionEnd
per turn: UserPromptSubmit, Stop, StopFailure
per tool call: PreToolUse, PermissionRequest, PostToolUse, PostToolUseFailure
on the side: Notification, PreCompact, PostCompact, FileChanged, ConfigChangeEin Command-Hook liest das Event als JSON von stdin und antwortet mit seinem Exit Code und stdout. Für einen Hook, der nichts oder reinen Text ausgibt, ist die Regel einfach: Exit 0 heißt kein Einwand, Exit 2 heißt blockieren, und jeder andere Code ist ein nicht blockierender Fehler. Claude Code zeigt dann einen hook error-Hinweis, und der Aufruf läuft durch die normalen Permission-Prüfungen weiter. Was Exit 2 bewirkt, hängt vom Event ab. Die Referenz hat eine Tabelle pro Event; diese Zeilen decken die meisten Setups ab:
PreToolUse Yes Blocks the tool call
UserPromptSubmit Yes Blocks the prompt, so it never reaches Claude
Stop Yes Prevents Claude from stopping, continues the conversation
PostToolUse No Shows stderr to Claude; the tool already ran
Notification No Exit code and stderr are ignored
SessionStart No Shows stderr to user onlyFür feinere Steuerung beendet sich ein Hook mit Exit 0 und gibt ein einzelnes JSON-Objekt aus, und die Felder müssen auf der richtigen Ebene sitzen. Steht permissionDecision auf oberster Ebene, ignoriert Claude Code es ohne Fehlermeldung. Bei PreToolUse gehört die Entscheidung in hookSpecificOutput:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Use the deploy script, not a raw push"
}
}Die Entscheidung kann allow, deny, ask oder defer sein (Letzteres nur in einem claude -p-Lauf), und updatedInput daneben ersetzt die Argumente des Tools, bevor der Aufruf läuft. Antworten mehrere Hooks auf denselben Aufruf, gewinnt die restriktivste Antwort, in der Reihenfolge deny, defer, ask, allow. Sobald ein Hook gültiges JSON ausgibt, entscheidet das JSON: Ein Hook, der mit Exit 1 und einem gültigen deny endet, verweigert trotzdem.
Wie schreibst du deinen ersten Claude Code Hook?
Schreib ein Skript, das stdin liest, registrier es unter einem Event in .claude/settings.json und teste es, indem du JSON hineinpipest, bevor Claude Code es je aufruft. Hier ein Guard, der einen Marker-Befehl blockiert:
#!/bin/bash
# .claude/hooks/guard-bash.sh
set -euo pipefail
input=$(cat)
cmd=$(python3 -c 'import json,sys; print(json.load(sys.stdin)["tool_input"].get("command",""))' <<<"$input")
if [[ "$cmd" == *marker-deny* ]]; then
echo "Blocked by guard-bash.sh" >&2
exit 2
fi
exit 0Mach es mit chmod +x ausführbar und registrier es dann. Die .claude/settings.json eines Projekts enthält die Hooks für dieses Projekt und kann committet werden; ~/.claude/settings.json enthält sie für alle deine Projekte:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard-bash.sh",
"onFailure": "block" }
]
}
]
}
}Teste das Skript zuerst für sich allein:
echo '{"tool_name":"Bash","tool_input":{"command":"touch marker-deny-x"}}' \
| CLAUDE_PROJECT_DIR=$PWD .claude/hooks/guard-bash.sh; echo "exit=$?"
# Blocked by guard-bash.sh
# exit=2In Claude Code listet /hooks jeden geladenen Hook und die Datei, aus der er stammt. Für einen Lauf startest du Claude Code mit --debug-file und liest die Hooks:-Zeilen, die das Event, den Befehl, den Exit-Status und die Laufzeit nennen. Mit genau diesem Skript und diesen Settings habe ich Claude um zwei Bash-Aufrufe gebeten, touch ok-file und touch marker-deny-file. Das Log zeigt beide, und nur die erste Datei existiert:
Hooks: PreToolUse:Bash [...guard-bash.sh] finished with status 0 (25ms)
Hooks: PreToolUse:Bash [...guard-bash.sh] finished with status 2 (25ms)Die Beispiele in der Dokumentation parsen die Eingabe mit jq, und meine erste Version des Guards tat das auch, ohne set -euo pipefail. Auf der Maschine, auf der ich getestet habe, ist kein jq installiert. Der Guard schrieb jq: command not found nach stderr, cmd kam leer zurück, das Skript erreichte exit 0, und Claude Code protokollierte einen sauberen Status 0. Claude führte genau den Befehl aus, den der Guard verhindern sollte, und nichts auf dem Bildschirm sagte, dass der Guard kaputt war. Strict Mode ändert den Exit: Ein fehlender Parser lässt das Skript jetzt mit 127 enden. Das habe ich am veröffentlichten Skript geprüft, indem ich den python3-Aufruf auf ein Binary umbenannt habe, das es nicht gibt, und mit gesetztem onFailure wurde der Aufruf abgelehnt:
Hooks: PreToolUse:Bash [...guard-bash.sh] finished with status 127 (16ms)
[WARN] Hooks: PreToolUse command hook failed; blocking (onFailure: "block")Welche Claude Code Hook-Muster lohnen sich?
Die, bei denen ein ausgefallener Lauf wenig kostet und ein zuverlässiger Lauf jedes Mal einen Schritt spart: Formatieren, Benachrichtigungen, Kontext und Prüfungen, bevor Claude aufhört. Ein Guard ist das riskanteste Muster auf der Liste, aus Gründen, die die letzten drei Abschnitte konkret machen.
Formatieren ist das erste Beispiel in der Dokumentation und das, mit dem ich anfangen würde. Ein PostToolUse-Hook auf Edit|Write lässt deinen Formatter über die Datei laufen, die Claude gerade angefasst hat (aus der Dokumentation, hier nicht getestet):
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }
]
}
]
}
}Er verpasst Dateien, die Claude über Bash ändert. Für eine Datei, die formatiert bleiben muss, egal was sie anfasst, verweist die Dokumentation auf einen FileChanged-Hook.
Ein Notification-Hook mit einem Matcher wie permission_prompt oder idle_prompt sagt dir Bescheid, wenn Claude dich braucht, über notify-send, osascript oder ein Skript, das dir eine Nachricht aufs Handy schickt. Die Hooks, die einen fertigen oder wartenden Agenten melden, habe ich ausführlicher in Claude Code über tmux steuern beschrieben.
Kontext lässt sich zurückholen. Bei SessionStart wird das reine stdout eines Befehls zu Claudes Kontext hinzugefügt, und der Matcher compact feuert direkt nach der Compaction, also genau dann, wenn Projektkonventionen gern aus der Zusammenfassung fallen. UserPromptSubmit kann auf dieselbe Weise jedem Prompt Kontext mitgeben. Meiner hat die aktuelle Uhrzeit eingefügt. Der erste Fix in seiner Historie betraf das Ausgabeformat (ein flaches additionalContext-Feld bewirkte nichts, bis es unter hookSpecificOutput wanderte), und die Commits danach drehten sich um die Häufigkeit: erst eine Drosselung pro Agent, dann ein Intervall, das von 30 auf 5 Minuten verkürzt wurde. Alles, was ein Hook bei jedem Prompt einfügt, bezahlst du bei jedem Prompt, und bei UserPromptSubmit bekommt ein Command-Hook standardmäßig 30 Sekunden, nicht die üblichen 10 Minuten. Für Kontext, der sich nie ändert, empfiehlt die Dokumentation CLAUDE.md statt eines Hooks.
Ein Stop-Hook kann die Arbeit prüfen, bevor Claude aufhört, und Claude mit einer Begründung zurückschicken. Als prompt-Hook beurteilt ein Modell den Turn und antwortet mit ok false und einer Begründung, um ihn fortzusetzen. Meiner prüfte den Turn eines Agenten auf drei Verstöße, einer davon: „done“ melden, während Tests fehlschlagen. Die Historie verzeichnet ungültiges JSON vom Richter-Modell und zwei Klassifikationsprobleme, die man vorher kennen sollte. Die erste Version meldete Verstöße aus früheren Teilen der Unterhaltung; behoben, indem das Modell angewiesen wurde, nur den letzten Turn zu beurteilen. Eine spätere Version meldete einen Agenten, der Feedback analysiert und eine Frage gestellt hatte, als hätte er einen Fix behauptet, und die Regel wurde auf ausdrückliche Abschlusswörter eingeengt. Ein Stop-Richter ist ein Klassifikator und muss wie einer getunt werden, und wie ich in ein Prompt ist keine Invariante argumentiert habe: Ein Modell als Schranke wägt immer noch ab, es setzt nichts durch. Um einen Agenten zurückzuschubsen, damit er seine Arbeit fertig macht, reicht Abwägen völlig.
Zwei Stop-Mechaniken solltest du kennen. Der Hook feuert jedes Mal, wenn Claude eine Antwort beendet, nicht nur am Ende einer Aufgabe. Und ein Hook, der blockiert, schickt Claude immer wieder zurück, deshalb übergeht Claude Code ihn nach acht Blocks in Folge ohne Tool-Aufruf dazwischen. Die Eingabe enthält stop_hook_active, das true ist, wenn Claude bereits wegen eines Stop-Hooks weitermacht; dann beende den Hook mit Exit 0, damit er nicht seine eigene Fortsetzung blockiert.
Langsame Arbeit kann im Hintergrund laufen. Ein Command-Hook mit "async": true hält Claude nicht auf, und wenn er endet, erreichen die Felder additionalContext und systemMessage aus seiner JSON-Ausgabe Claude im nächsten Turn. Die rohe Ausgabe eines Test-Runners tut das nicht, also wickle ihn in ein Skript, das dieses JSON ausgibt. Ein async-Hook kann nichts blockieren, Claude Code setzt timeout bei ihm nicht durch, und unter claude -p wird einer, der beim Herunterfahren noch läuft, beendet.
Was kostet ein langsamer Claude Code Hook?
Seine eigene Laufzeit bei jedem passenden Event, denn ein synchroner Hook hält die Aktion an, bis er endet. Meine funktionierenden Guards brauchten laut Debug-Logs 25 bis 28 ms pro Tool-Aufruf. Ein Hook, der einen Netzwerkdienst oder ein Modell aufruft, kostet weit mehr, und das Standard-Timeout für einen Command-Hook liegt bei den meisten Events bei 10 Minuten.
Die Rechnung läuft pro Event, nicht pro Session. Ein PreToolUse-Hook ohne Matcher läuft bei jedem Tool-Aufruf im agentischen Loop, Subagenten eingeschlossen, denn Hooks aus den Settings feuern auch dort. Drei Wege, die Rechnung klein zu halten, alle aus der Dokumentation:
matcher narrows by tool name, e.g. "Bash" or "Edit|Write"
if narrows by tool and arguments in permission-rule syntax,
e.g. "Bash(git *)", so the process never spawns for other calls
async moves work that can't block anyway off the critical pathPrompt- und Agent-Hooks sind die teure Sorte. Ein Prompt-Hook ist ein Modellaufruf mit 30 Sekunden Standard-Timeout, auf Stop also ein Aufruf am Ende jeder Antwort. Ein Agent-Hook startet einen Subagenten, der bis zu 50 Tool-Use-Turns brauchen kann, mit 60 Sekunden Standard. Um den langsamen Hook zu finden, lies die Dauer am Ende jeder Hooks:-Zeile im Debug-Log:
Hooks: PreToolUse:Write [...guard-files.sh] finished with status 2 (26ms)Wann lässt ein Claude Code Hook die Aktion durch?
Immer dann, wenn er fehlschlägt, außer du setzt onFailure auf "block". Ein Skript, das nicht startet, mit einem anderen Code als 0 oder 2 endet und keine gültige Entscheidung ausgibt, fehlerhaftes JSON ausgibt oder in ein Timeout läuft, ist ein nicht blockierender Fehler, und der Tool-Aufruf läuft durch die normalen Permission-Prüfungen weiter. Die Dokumentation warnt vor dem Fall, der am häufigsten zuschlägt:
When you set up a policy hook, watch for this notice on its first run,
because a mistyped path in `settings.json` means the hook never runs.Genau diesen Fall habe ich durchgespielt: ein PreToolUse-Hook, der auf ein nicht existierendes Skript zeigt. Ohne onFailure zeigt das Debug-Log Status 127, und das touch lief. Mit "onFailure": "block" am selben Hook fügt das Log die oben gezeigte Warnzeile hinzu, und der Aufruf wird abgelehnt.
Das hat sich erst kürzlich geändert. Als ich am 7. Oktober über zehn Permission-Umgehungen, die Claude Code in drei Releases geschlossen hat, geschrieben habe, war offenes Versagen das einzige dokumentierte Verhalten für ein Hook-Skript, das abstürzt oder hängt, und der Workaround war ein bash-EXIT-Trap, der jeden Fehler in Exit 2 verwandelt. onFailure kam am Tag darauf mit v2.1.295. Bei command- und http-Hooks zählt es als Fehler: ein Skript, das nicht startet, einen Exit Code außer 0 oder 2, einen HTTP-Fehler, ein Timeout und eine Ausgabe, die sich nicht parsen lässt oder die Validierung nicht besteht.
Es fängt kein Skript ab, das intern kaputtgeht und trotzdem mit 0 und ohne Ausgabe endet, und genau das hat mein jq-Guard getan. Für onFailure war dieser Lauf ein Erfolg. Also bleiben beide Hälften nötig: Strict Mode oder ein EXIT-Trap, damit interne Fehler als Exit ungleich null sichtbar werden, und onFailure, damit dieser Exit blockiert. Das Feld wirkt nicht bei Stop-, SubagentStop-, TaskCompleted- und TeammateIdle-Hooks und nicht bei async-Hooks.
Hält ein Claude Code Edit|Write-Hook Bash davon ab, die Datei zu schreiben?
Nein. Ich habe in einem Test-Repository zwei Guards registriert: den Bash-Guard und einen Edit|Write-Guard, der jeden Pfad blockiert, der protected enthält. Dann habe ich Claude gebeten, eine Datei mit dem Write-Tool zu schreiben und eine weitere mit Bash:
Write protected-a.txt blocked by guard-files.sh (status 2)
Bash echo two > protected-b.txt guard-bash.sh had no rule for it (status 0), file existsMein Commit aus dem April hält dieselbe Lücke von der anderen Seite fest: Der Guard überwachte Bash, und die Agenten editierten mit Write und Edit an ihm vorbei. Ein Datei-Guard muss jedes Tool abdecken, das die Datei schreiben kann, und Bash kann alles schreiben. Ein Guard, der an einem Tool-Namen hängt, schützt also einen Weg, nicht die Datei. Der Rat der Dokumentation zum Formatieren zeigt auf dieselbe Lösung: FileChanged reagiert auf die Datei, egal was sie geschrieben hat.
Wo schützt dich ein Claude Code Hook sonst nicht?
Am Rand der Konfiguration, in der er läuft. Neben der Lücke beim Tool-Namen nennt die Dokumentation vier weitere.
Der if-Filter arbeitet nach bestem Bemühen. Wenn Claude Code nicht erkennen kann, welche Befehle eine Bash-Eingabe ausführt, lässt es den Hook trotzdem laufen, und das ist die sichere Richtung. Aber die Referenz sagt klar, was das für die Durchsetzung bedeutet:
Because the if filter is best-effort, use the permission system rather
than a hook to enforce a hard allow or deny.Permission-Modi schalten das deny eines Hooks nicht ab. Ein PreToolUse-deny hält in jedem Modus, auch in bypassPermissions: Mein Bash-Guard blockierte seinen Marker-Befehl in einem Lauf, der mit --dangerously-skip-permissions gestartet wurde. Auch das Umgekehrte gilt, laut Dokumentation und hier nicht getestet: Ein allow eines Hooks kann eine deny-Regel aus den Settings nicht überstimmen.
Nach deinem Hook kann noch etwas antworten. Auf einer Maschine ohne Managed Settings und ohne Team- oder Enterprise-Login kann ein Mod einen Aufruf genehmigen, den dein PreToolUse-Hook blockiert hat. Das habe ich in ein Mod kann genehmigen, was dein Hook blockiert hat auf 2.1.287 getestet, und der Hooks-Guide enthält heute denselben Hinweis.
Headless-Läufe vertrauen dem Repository. Laut Dokumentation hält eine interaktive Session Hooks zurück, bis du den Workspace-Trust-Dialog bestätigst. Unter claude -p oder dem SDK gibt es keinen Dialog, und Hooks, die in der .claude/settings.json eines Repositorys committet sind, laufen in einem Ordner, den du nie geöffnet hast. Das habe ich in was die Settings-Datei eines geklonten Repos noch ausführen kann durchgearbeitet. Für ein Repository, das du nicht selbst geschrieben hast, schaltet --settings '{"disableAllHooks": true}' seine Hooks für einen Lauf ab, wobei Managed Hooks weiterlaufen.
Und hinterher ist es zu spät. PostToolUse kann mit updatedToolOutput ersetzen, was Claude sieht, und die Dokumentation warnt, dass geschriebene Dateien, ausgeführte Befehle und gesendete Requests dann schon wirksam sind. Ein Redaction-Hook an dieser Stelle versteckt ein Secret vor dem Modell, nicht vor dem Netzwerk.
Was sollte dein erster Claude Code Hook sein?
Ein Formatter oder ein Benachrichtigungs-Hook. Er zahlt sich am ersten Tag aus, und wenn er kaputtgeht, kostet das eine unformatierte Datei. Ein Guard, der kaputtgeht, versagt genau in die Richtung, die er verhindern sollte, und das leise.
Wenn du trotzdem einen Guard schreibst, gib ihm, was eine Prüfung in Produktion bekommt: Strict Mode oder einen EXIT-Trap, onFailure: "block" und je einen Test mit entfernter Abhängigkeit und mit fehlerhafter Eingabe. Dann nenn ihn, was er ist. Ein Hook, der an Tool-Namen und Befehlstext hängt, lenkt: Er fängt die Befehle, die ein Agent normalerweise schreibt, auf den Wegen, an die du gedacht hast. Der Teil eines Guards, den ich auch mit diesem Wissen behalten würde, ist der Begründungstext. Die Meldungen meines alten Guards nannten den vorgesehenen Weg (ein Wrapper-Skript, eine sicherere Art, ein Credential zu lesen), sodass ein Block dem Agenten den nächsten Schritt mitgab statt einer nackten Absage. Schreib die Begründung für das Modell, das sie lesen wird. Die Regel, die halten muss, egal wie der Befehl geschrieben ist, braucht eine Grenze, die überhaupt keine Befehle liest, wie der Artikel über die Permission-Umgehungen von der Seite der deny-Regeln aus argumentiert hat.
01 / Diskussion
Hier gibt es keine Kommentarspalte. Diskussionen laufen auf X.
02 / Weitere Artikel
Claude Auto Mode wird Standard in den Claude-Apps: was die Prüfung blockiert und was hinter den 89 % steckt
Anthropics Studie ist ehrlich, was ihren Aufbau angeht: müde Tester gegen einen einzigen eingeschleusten Befehl. Sie nennt keine False-Positive-Rate und keine Fehlerquote im echten Betrieb, und in Cloud-Sessions gibt es keinen Manual-Modus, zu dem man zurückwechseln könnte.
Claude-Code-Kosten 2026: was Pro, Max und die API für dieselbe Arbeit verlangen
Die Abopreise sind fix und die Tokenpreise öffentlich, also sieht der Vergleich nach reiner Rechnung aus. Ganz so einfach ist es nicht: Claude Code cacht deine Unterhaltung je nach Bezahlweise unterschiedlich, und das Tool, mit dem die meisten ihre Abo-Nutzung in Dollar umrechnen, bepreist den Cache, den du hattest, nicht den, den du nach dem Wechsel bekämst.
Claude-Code-Sandbox unter Linux: was srt abschottet und was bypassPermissions öffnet
Die eingebaute Sandbox ist eine echte Grenze auf Betriebssystemebene um Shell-Befehle, und nur um sie. Wie stark sie ist, hängt von zwei Dingen ab, die der Einrichtungsbildschirm nie zeigt: deinem Berechtigungsmodus und dem, was schon existierte, als der Befehl startete. Ein Test unter Ubuntu dazu, was srt blockiert und was es offen lässt, der Linux-Bug, der sie immer noch kaputt macht, und wann ein Container oder eine VM besser passt.