Claude Code Hooks mit Timeout lassen Befehle durch – besser fail-closed einrichten
Ein Timeout in einem Claude Code Hook bewirkt standardmaessig, dass der Befehl trotzdem ausgefuehrt wird. Wer sicher gehen will, sollte die Konfiguration so anpassen, dass der Hook im Fehlerfall blockiert statt durchgelassen wird.

In der Referenz zu Claude Code hooks gibt es einen Satz, den man zweimal lesen sollte: "Ein Hook vom Typ command, http oder mcp_tool, der das Zeitlimit überschreitet, blockiert den Toolaufruf nicht." Ein Command-Hook, der mit einem anderen Exit-Code als 0 oder 2 endet, wird genauso behandelt. Die Dokumentation nennt das einen "non-blocking error", und die Aktion wird trotzdem ausgeführt.
Man stelle sich das Setup vor, das die Permissions-Dokumentation vorschlägt, um Bash ohne Nachfragen auszuführen: Man fügt "Bash" zur Allow-Liste hinzu und registriert einen PreToolUse-Hook, der die wenigen Befehle ablehnt, die man niemals ausführen will. Dann hat der Hook einen Bug. Er wirft bei einer unerwarteten Eingabe eine Exception oder hängt bei einem langsamen Aufruf. Der Befehl, den er verhindern sollte, läuft trotzdem, und die einzige Spur ist eine Zeile im Transcript.
Claude Code 2.1.295, veröffentlicht am 8. Oktober 2026, hat dafür den Schalter eingeführt. In den Worten des Changelogs bedeutet onFailure: "block", dass "ein Hook, der nicht starten kann, das Zeitlimit überschreitet oder mit einem unerwarteten Code endet, die Aktion blockiert, statt sie durchzulassen."
Du wirst eine kleine Schutzfunktion bauen, sie zweimal absichtlich brechen, zusehen, wie sie im Fehlerfall offen bleibt, dann ein Feld umstellen und sie erneut brechen. Das alles passiert in einem Wegwerf-Repository, dessen Remote auf deiner eigenen Festplatte liegt, sodass ein durchkommender Push nichts kostet.
onFailure benötigt Claude Code 2.1.295 oder neuer, also prüfe zuerst:
claude --version Enter fullscreen mode Exit fullscreen mode Lege dann ein Repository an, dessen origin ein bare git repository daneben ist:
mkdir -p ~/code/hook-lab && cd ~/code/hook-lab git init -q -b main git init -q --bare../hook-lab-remote.git git remote add origin../hook-lab-remote.git git commit -q --allow-empty -m "init" mkdir -p.claude/hooks Enter fullscreen mode Exit fullscreen mode Erstelle vor jedem der folgenden Versuche einen frischen Commit. Frage danach die Remote, was sie hat:
git commit -q --allow-empty -m "try" # before git ls-remote origin main # after Enter fullscreen mode Exit fullscreen mode Wenn ls-remote denselben Hash ausgibt wie git rev-parse HEAD, ist der Push angekommen. Ein älterer Hash oder gar keine Ausgabe bedeutet, dass dem nicht so war.
Eine Schutzfunktion, die das Zeitlimit überschreitet, lässt das Tor offen.
Der Hook erhält den Toolaufruf als JSON auf stdin, und bei Bash steht der Befehl in tool_input.command. Speichere dieses Python-Skript als.claude/hooks/guard.py:
#!/usr/bin/env python3 """ PreToolUse guard for Bash calls. """ import json import re import sys PUSH_MAIN = re. compile ( r " \bgit\s+push\b.*\b(main|master)\b " ) PUBLIC_REPO = re. compile ( r " \bgh\s+repo\s+create\b.*--public\b " ) def decide ( decision, reason ): out = { " hookSpecificOutput ": { " hookEventName ": " PreToolUse ", " permissionDecision ": decision, " permissionDecisionReason ": reason, }} print ( json. dumps ( out )) sys. exit ( 0 ) data = json. load ( sys. stdin ) command = data [ " tool_input " ][ " command " ] if PUSH_MAIN. search ( command ): decide ( " deny ", " Pushing to main is blocked. Push a branch.
" ) if PUBLIC_REPO. search ( command ): decide ( " ask ", " This creates a PUBLIC GitHub repository. " ) sys. exit ( 0 ) Enter fullscreen mode Exit fullscreen mode Ein Push zu main oder master erhält deny, und Claude sieht den Grund. Ein gh repo create mit --public erhält ask, was "den Nutzer zur Bestätigung auffordert", denn manchmal ist ein öffentliches Repository genau das, was man wollte.
Alles andere endet mit Exit 0 und ohne Ausgabe, sodass laut Dokumentation "der Toolaufruf durch den normalen Permission-Flow läuft." Die Entscheidungen gehen als JSON mit Exit 0 raus, dem Exit-Code, den die Doku für JSON-Ausgaben vorsieht, und dem einzigen, der nach dem Einschalten von onFailure unten weiterhin als Erfolg zählt.
{ "permissions": { "allow": [ "Bash(git push *)" ] }, "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "python3", "args": [ "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard.py" ], "timeout": 5 } ] } ] } } Enter fullscreen mode Exit fullscreen mode Drei Entscheidungen darin sind absichtlich so getroffen. Die Allow-Regel ist der Stellvertreter des Labs für "erlaube Bash, lass den Hook die schlechten Befehle abfangen." Ohne sie würde eine im Fehlerfall offen bleibende Schutzfunktion immer noch in Claude Codes normale Permission-Eingabeaufforderung führen, und du würdest den Push dort abfangen. Solange sie funktioniert, gewinnt die Schutzfunktion trotzdem gegen diese Regel: laut Permissions-Doku "hat ein blockierender Hook auch Vorrang vor Allow-Regeln."
args führt python3 direkt ohne Shell aus, was die Dokumentation empfiehlt, "wenn der Hook einen Pfad-Platzhalter referenziert". Und timeout ist in Sekunden angegeben. Der Standardwert für einen Command-Hook ist 600, sodass ein hängender Guard den Aufruf zehn Minuten lang aufhalten würde, bevor er ihn durchlässt. Fünf Sekunden reichen für zwei Regexes locker aus.
Teste das Skript, bevor Claude es überhaupt aufruft:
echo '{"tool_input":{"command":"git push origin main"}}' \ | python3.claude/hooks/guard.py; echo "exit= $? " Enter fullscreen mode Exit fullscreen mode Es sollte die deny-Entscheidung als eine Zeile JSON ausgeben, dann exit=0. Ersetze den Befehl durch git status und es gibt nur exit=0 aus.
Starte nun claude in hook-lab, tippe /hooks, um den Guard mit seiner Herkunft aufgelistet zu sehen, mache einen Commit und bitte Claude, git push origin main auszuführen. Der Guard lehnt ab, Claude erhält "Pushing to main is blocked. Push a branch.", und git ls-remote origin main gibt nichts aus. Die Remote ist weiterhin leer.
Erster Bruch: ein Hänger. Füge diese Zeile direkt nach import sys hinzu:
import time; time. sleep ( 15 ) # BREAK 1: past the timeout Enter fullscreen mode Exit fullscreen mode Mache einen Commit und bitte Claude erneut zu pushen. Der Guard läuft in sein 5-Sekunden-Zeitlimit, Claude Code bricht ihn ab, und laut Doku "läuft der Aufruf durch den normalen Permission-Flow." Deine Allow-Regel greift, der Push läuft, und ls-remote gibt nun den Hash deines letzten Commits aus.
Zweiter Bruch: ein schlechter Exit-Code. Lösche das sleep und setze stattdessen diese beiden Zeilen nach import sys:
print ( " guard broke ", file = sys. stderr ) # BREAK 2 sys. exit ( 3 ) Enter fullscreen mode Exit fullscreen mode Committe und frage wieder. Exit 3 ist weder 0 noch 2, also ist es ein non-blocking error: Das Transcript zeigt Failed with non-blocking status code: mit der ersten Zeile von stderr, und der Push landet wieder. Exit 3 steht hier für echte Abstürze. Ein Python-Traceback durch eine nicht geplante Eingabeform endet mit Exit 1, und die Doku warnt, dass ohne gültiges JSON auf stdout "Claude Code den Exit-Code 1 als non-blocking error behandelt."
Ob Zeitlimit oder Absturz, das Standardverhalten ist dasselbe: Der Aufruf geht durch.
Lass BREAK 2 im Skript und füge dem Handler ein Feld hinzu:
"timeout": 5, "onFailure": "block" Enter fullscreen mode Exit fullscreen mode Committe und bitte Claude zu pushen. Diesmal wird der Aufruf blockiert, und der Fehler enthält failed; blocking, weil onFailure auf "block" steht, die Formulierung, die die Doku für einen fehlschlagenden Hook zeigt. Tausche BREAK 2 gegen BREAK 1 und versuche es noch einmal: weiterhin blockiert, und die Meldung sagt timed out statt failed. In beiden Fällen gibt ls-remote weiterhin den Hash des letzten durchgekommenen Pushs aus.
Die Doku zählt fünf Fehlerarten: Der Hook kann nicht starten, endet mit einem anderen Code als 0 oder 2, erhält einen HTTP-Fehler, überschreitet das Zeitlimit oder gibt JSON aus, das nicht geparst werden kann oder die Validierung nicht besteht. Klartext auf stdout gehört nicht dazu. Mit gesetztem "block" macht ein Fehler "das, was Exit-Code 2 bei diesem Event tut", was bei PreToolUse den Toolaufruf blockiert.
Einer dieser Fälle trifft Guards, die JSON zurückgeben. Ein Exit-Code, der weder 0 noch 2 ist, zählt als Fehler, "selbst wenn er JSON ausgegeben hat, das die Aktion erlaubt, etwa permissionDecision: "allow"." Sobald du also "block" setzt, blockiert ein Guard, der ein allow ausgibt und dann mit Exit 1 endet, alles, was er eigentlich erlauben sollte. Exit 0 nach dem JSON, so wie decide() es macht.
Entferne beide Brüche, führe den Pipe-Test erneut aus und behalte onFailure bei.
Ich baue Hermes IDE, also bewerte diesen Abschnitt vor diesem Hintergrund. Hermes führt Claude Code in einem echten Terminal aus, mit eigenem Branch und Worktree pro Aufgabe, sodass am Hook selbst nichts geändert wird. Geändert wird, wo du erfährst, dass der Guard dich braucht.
Committe.claude/settings.json und.claude/hooks/guard.py. Ein neuer git worktree enthält nur getrackte Dateien, daher ist ein Guard, der in.claude/settings.local.json liegt, der Projektdatei, die bewusst nicht in git landen soll, nicht im Worktree vorhanden, in dem eine neue Aufgabe startet. Committet wandert er mit jeder Aufgabe mit, die du mit ⌘N (Ctrl+Shift+N unter Windows und Linux) startest.
Das ⌘N New-Task-Sheet in Hermes 2.3.4: Claude Code, das orders-api-Projekt, ein neuer hermes/-Worktree, Accept edits, Modell und Effort sowie die Vorschau des claude-Befehls, den Hermes ausführen wird.
Wenn der Guard bei gh repo create --public mit ask antwortet, hält Claude Code an einer Permission-Eingabeaufforderung an. Eine Session, die an einer Permission-Eingabeaufforderung wartet, ist genau der Zweck des "needs approval"-Status von Hermes. Der Attention-Inbox zählt die Agenten, die auf dich blockiert sind, und ⌘I (Ctrl+Shift+I) springt zu dem, der am längsten wartet. Du antwortest in Claude Codes eigenem Terminal. Hermes beantwortet niemals selbst eine Permission-Eingabeaufforderung für dich und tippt nie von sich aus in ein Terminal, sodass du die Frage des Guards selbst beantwortest. Antworte im Lab mit nein: Wenn gh angemeldet ist, würde yes ein echtes öffentliches Repository auf deinem Konto erstellen.
Ein deny oder ein fail-closed-Block erfordert nichts von dir. Claude erhält den Grund und macht weiter, und es gibt keine Permission-Eingabeaufforderung, die du beantworten müsstest.
Hermes ist kostenlos, als Quellcode unter BSL 1.1 verfügbar und läuft auf macOS, Windows und Linux; Version 2.3.4 erschien am 15. Oktober 2026. Es steht in keiner Verbindung zu Anthropic oder einem anderen Agent-Anbieter.
Ein fehlerhafter Guard blockiert nun jeden Aufruf, den sein Matcher erfasst. Mit "matcher": "Bash" stoppt ein Tippfehler in guard.py auch ls. Um das einzuschränken, kann man jeder Regel einen eigenen Handler mit einem if zuweisen, das die Syntax für Permission-Regeln akzeptiert und "genau eine Permission-Regel" enthält:
{ "type": "command", "if": "Bash(git push *)", "command": "python3", "args": [ "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard.py" ], "timeout": 5, "onFailure": "block" } Enter fullscreen mode Exit fullscreen mode Füge einen zweiten Handler mit "if": "Bash(gh repo create *)" hinzu. Wenn Claude Code nicht bestimmen kann, welche Befehle eine Bash-Eingabe ausführt, wird laut Dokumentation der Hook dennoch ausgeführt.
Die Regexes erfassen nur die offensichtlichen Schreibweisen. Ein nacktes git push, während man sich auf main befindet, schlüpft durch, und ebenso git -C sowie Shell-Aliase. Das Muster verweigert außerdem einen Branch namens fix-main-nav, ein False Positive in die sichere Richtung. Die Dokumentation bezeichnet den if-Filter als Best Effort und ergänzt: "nutze das Permission-System statt eines Hooks, um ein hartes Allow oder Deny durchzusetzen." Für main ist die Sperre, die nicht vom Laptop einer bestimmten Person abhängt, eine Branch-Protection-Regel mit aktivierten Branch-Restrictions, bei der "nur Nutzer, Teams oder Apps, denen die Berechtigung erteilt wurde, in den geschützten Branch pushen können."
onFailure hat bei Stop-, SubagentStop-, TaskCompleted- und TeammateIdle-Hooks keine Wirkung, ebenso wenig bei Hooks, die async oder asyncRewake setzen. Mache einen Guard nicht async. Wenn du Mods installierst, weisen die Permissions-Dokumente darauf hin, dass ein Mod, der tool.check verarbeitet, einen von einem PreToolUse-Hook blockierten Aufruf freigeben kann, sofern dieser Hook nicht in den managed settings liegt. Unter Windows wurde in 2.1.299 (14. Oktober) behoben, dass PreToolUse-Hooks ohne gesetzte Shell fehlschlugen, indem sie offen failten, wenn Git for Windows nicht installiert war, also führe ein Update durch.
Fail open oder fail closed ist die Frage, die man am Ende bei jeder Abhängigkeit stellt, die brechen kann: ein Auth-Check mit Timeout, ein ausgefallener Rate Limiter, ein Guard-Hook mit einem Bug. Die richtige Antwort hängt davon ab, was ein falsches Ja kostet, und dieser Trade-off zieht sich durch die zentralen Bausteine im System Design Pocket Guide: Fundamentals. Es ist eines von 8 kurzen Büchern der Pocket-Guides-Reihe zu Agents, RAG, Prompts, Observability, Events, Datenbanken und Systemdesign: die gesamte Reihe gibt es hier. Wenn du das Lab mit Claude Code in Hermes ausführen möchtest, lade es herunter oder verleihe ihm einen Stern auf GitHub.
Stefan Stefan Stefan Folgen Senior Software Engineer | Product Engineering & Architecture | AI & Security Standort VC, Australien Beigetreten 10. Februar 2026 • 25. Oktober Dropdown-Menü Link kopieren Ausblenden Das Prüfen des Remote-Commit-Hashes ist der beste Teil dieses Labs. Das würde ich auch in den Regressionstest aufnehmen: den Guard absichtlich abstürzen lassen und dann prüfen, ob der Push tatsächlich gelandet ist. Ein Transkript, das besagt, dass der Hook fehlgeschlagen ist, beantwortet nicht die Frage, die wirklich zählt.
Automatisch übersetzt. Das Original auf Englisch ist unten aufklappbar.
Original (Englisch) anzeigen
The Claude Code hooks reference has a sentence worth reading twice: "A timed-out command, http, or mcp_tool hook doesn't block the tool call." A command hook that exits with any code other than 0 or 2 gets the same treatment. The docs call that a "non-blocking error", and the action goes ahead.
Picture the setup the permissions docs suggest for running Bash without prompts: add "Bash" to your allow list and register a PreToolUse hook that rejects the few commands you never want. Then the hook gets a bug. It throws on an input it didn't expect, or it hangs on a slow call. The command it was there to stop runs anyway, and the only trace is a line in the transcript.
Claude Code 2.1.295, released October 8, 2026, added the switch for this. In the changelog's words, onFailure: "block" means "a hook that can't start, times out, or exits with an unexpected code blocks the action instead of letting it through."
You'll build a small guard, break it twice on purpose, watch it fail open, then flip one field and break it again. It all happens in a throwaway repo whose remote sits on your own disk, so a push that gets through costs nothing.
onFailure needs Claude Code 2.1.295 or later, so check first:
claude --version Enter fullscreen mode Exit fullscreen mode Then make a repo whose origin is a bare git repository next to it:
mkdir -p ~/code/hook-lab && cd ~/code/hook-lab git init -q -b main git init -q --bare../hook-lab-remote.git git remote add origin../hook-lab-remote.git git commit -q --allow-empty -m "init" mkdir -p.claude/hooks Enter fullscreen mode Exit fullscreen mode Before each attempt below, make a fresh commit. After it, ask the remote what it has:
git commit -q --allow-empty -m "try" # before git ls-remote origin main # after Enter fullscreen mode Exit fullscreen mode If ls-remote prints the same hash as git rev-parse HEAD, the push landed. An older hash, or no output at all, means it didn't.
A guard that times out leaves the gate open.
The hook gets the tool call as JSON on stdin, and for Bash the command sits in tool_input.command. Save this Python script as.claude/hooks/guard.py:
#!/usr/bin/env python3 """ PreToolUse guard for Bash calls. """ import json import re import sys PUSH_MAIN = re. compile ( r " \bgit\s+push\b.*\b(main|master)\b " ) PUBLIC_REPO = re. compile ( r " \bgh\s+repo\s+create\b.*--public\b " ) def decide ( decision, reason ): out = { " hookSpecificOutput ": { " hookEventName ": " PreToolUse ", " permissionDecision ": decision, " permissionDecisionReason ": reason, }} print ( json. dumps ( out )) sys. exit ( 0 ) data = json. load ( sys. stdin ) command = data [ " tool_input " ][ " command " ] if PUSH_MAIN. search ( command ): decide ( " deny ", " Pushing to main is blocked. Push a branch.
" ) if PUBLIC_REPO. search ( command ): decide ( " ask ", " This creates a PUBLIC GitHub repository. " ) sys. exit ( 0 ) Enter fullscreen mode Exit fullscreen mode A push to main or master gets deny, and Claude sees the reason. A gh repo create with --public gets ask, which "prompts the user to confirm", because sometimes a public repo is exactly what you meant.
Anything else exits 0 with no output, so per the docs "the tool call continues through the normal permission flow." The decisions go out as JSON with exit 0, the exit code the docs intend for JSON output, and the only one that still counts as a success once you turn on onFailure below.
{ "permissions": { "allow": [ "Bash(git push *)" ] }, "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "python3", "args": [ "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard.py" ], "timeout": 5 } ] } ] } } Enter fullscreen mode Exit fullscreen mode Three choices in there are on purpose. The allow rule is the lab's stand-in for "allow Bash, let the hook catch the bad ones." Without it, a guard that fails open would still drop you into Claude Code's normal permission prompt, and you'd catch the push there. The guard still wins over that rule when it works: per the permissions docs, "A blocking hook also takes precedence over allow rules."
args runs python3 directly with no shell, which the docs recommend "whenever the hook references a path placeholder". And timeout is in seconds. The default for a command hook is 600, so a hung guard would hold the call for ten minutes before letting it through. Five seconds is plenty for two regexes.
Test the script before Claude ever calls it:
echo '{"tool_input":{"command":"git push origin main"}}' \ | python3.claude/hooks/guard.py; echo "exit= $? " Enter fullscreen mode Exit fullscreen mode It should print the deny decision as one line of JSON, then exit=0. Swap in git status and it prints only exit=0.
Now start claude in hook-lab, type /hooks to see the guard listed with where it came from, make a commit, and ask Claude to run git push origin main. The guard denies it, Claude gets "Pushing to main is blocked. Push a branch.", and git ls-remote origin main prints nothing. The remote is still empty.
First break: a hang. Add this line right after import sys:
import time; time. sleep ( 15 ) # BREAK 1: past the timeout Enter fullscreen mode Exit fullscreen mode Make a commit and ask Claude to push again. The guard runs into its 5-second timeout, Claude Code cancels it, and per the docs "The call continues through the normal permission flow." Your allow rule matches, the push runs, and ls-remote now prints the hash of your latest commit.
Second break: a bad exit code. Delete the sleep and put these two lines after import sys instead:
print ( " guard broke ", file = sys. stderr ) # BREAK 2 sys. exit ( 3 ) Enter fullscreen mode Exit fullscreen mode Commit and ask again. Exit 3 is neither 0 nor 2, so it's a non-blocking error: the transcript shows Failed with non-blocking status code: with the first line of stderr, and the push lands again. Exit 3 is standing in for real crashes here. A Python traceback from an input shape you didn't plan for exits 1, and the docs warn that without valid JSON on stdout, "Claude Code treats exit code 1 as a non-blocking error."
Timeout or crash, the default is the same: the call goes through.
Leave BREAK 2 in the script and add one field to the handler:
"timeout": 5, "onFailure": "block" Enter fullscreen mode Exit fullscreen mode Commit and ask Claude to push. This time the call is blocked, and the error includes failed; blocking because onFailure is "block", the wording the docs show for a hook that fails. Swap BREAK 2 for BREAK 1 and try once more: still blocked, and the message says timed out instead of failed. Both times, ls-remote keeps printing the hash from the last push that got through.
The docs count five failures: the hook can't start, exits with a code other than 0 or 2, gets an HTTP error, times out, or prints JSON that can't be parsed or fails validation. Plain-text stdout isn't one. With "block" set, a failure "does what exit code 2 does on that event", which on PreToolUse blocks the tool call.
One of those bites guards that return JSON. A non-0, non-2 exit counts as a failure "even if it printed JSON that allows the action, such as permissionDecision: "allow"." So once you set "block", a guard that prints an allow and then exits 1 blocks everything it was meant to allow. Exit 0 after the JSON, like decide() does.
Delete both breaks, run the pipe test again, and keep onFailure in.
I build Hermes IDE, so weigh this section with that in mind. Hermes runs Claude Code in a real terminal, with its own branch and worktree per task, so nothing about the hook changes. What changes is where you find out the guard wants you.
Commit.claude/settings.json and.claude/hooks/guard.py. A new git worktree holds tracked files only, so a guard kept in.claude/settings.local.json, the project file meant to stay out of git, won't be in the worktree a new task starts in. Committed, it rides along with every task you start with ⌘N (Ctrl+Shift+N on Windows and Linux).
The ⌘N New task sheet in Hermes 2.3.4: Claude Code, the orders-api project, a new hermes/ worktree, Accept edits, model and effort, and the preview of the claude command Hermes will run.
When the guard answers ask on gh repo create --public, Claude Code stops at a permission prompt. A session waiting at a permission prompt is what Hermes's "needs approval" status is for. The attention inbox counts the agents blocked on you, and ⌘I (Ctrl+Shift+I) jumps to the one that has waited longest. You answer in Claude Code's own terminal. Hermes never answers a permission prompt for you and never types into a terminal on its own, so you answer the guard's question yourself. In the lab, answer no: if gh is signed in, yes creates a real public repo on your account.
A deny or a fail-closed block needs nothing from you. Claude gets the reason and carries on, and there's no permission prompt for you to answer.
Hermes is free, source-available under BSL 1.1, and runs on macOS, Windows and Linux; 2.3.4 shipped on October 15, 2026. It isn't affiliated with Anthropic or any other agent vendor.
A broken guard now blocks every call its matcher catches. With "matcher": "Bash", a typo in guard.py stops ls too. To shrink that, give each rule its own handler with an if, which takes permission rule syntax and "holds exactly one permission rule":
{ "type": "command", "if": "Bash(git push *)", "command": "python3", "args": [ "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard.py" ], "timeout": 5, "onFailure": "block" } Enter fullscreen mode Exit fullscreen mode Add a second handler with "if": "Bash(gh repo create *)". When Claude Code can't tell which commands a Bash input runs, the docs say it runs your hook anyway.
The regexes only catch the obvious spellings. A bare git push while you're on main slips past, and so do git -C and shell aliases. The pattern also denies a branch called fix-main-nav, a false positive in the safe direction. The docs call the if filter best-effort and add: "use the permission system rather than a hook to enforce a hard allow or deny." For main, the lock that doesn't depend on anyone's laptop is a branch protection rule with branch restrictions turned on, where "only users, teams, or apps that have been given permission can push to the protected branch."
onFailure does nothing on Stop, SubagentStop, TaskCompleted and TeammateIdle hooks, or on hooks that set async or asyncRewake. Don't make a guard async. If you install mods, the permissions docs note that a mod handling tool.check can approve a call a PreToolUse hook blocked, unless that hook lives in managed settings. On Windows, 2.1.299 (October 14) fixed PreToolUse hooks with no shell set failing open when Git for Windows isn't installed, so update.
Fail open or fail closed is the question you end up asking about every dependency that can break: an auth check that times out, a rate limiter that's down, a guard hook with a bug. The right answer depends on what a wrong yes costs you, and that trade-off runs through the core building blocks in the System Design Pocket Guide: Fundamentals. It's one of 8 short books in the Pocket Guides series, on agents, RAG, prompts, observability, events, databases and system design: the whole series is here. If you'd like to run the lab with Claude Code inside Hermes, download it or star it on GitHub.
Expand Stefan Stefan Stefan Follow Senior Software Engineer | Product Engineering & Architecture | AI & Security Location VC, Australia Joined Feb 10, 2026 • Oct 25 Dropdown menu Copy link Hide Checking the remote commit hash is the best part of this lab. I'd want that in the regression test too: deliberately crash the guard, then check whether the push actually landed. A transcript saying the hook failed doesn't answer the question that matters.