hook 打錯路徑,閘門就默默打開:Claude Code 2.1.295 的 onFailure: "block" 實跑六種失敗
在 Claude Code 2.1.295 上,我拿同一個 PreToolUse 的 command hook,把它弄壞成六種樣子:腳本路徑不存在、exit 1、逾時、印出壞掉的 JSON 等等。沒設 onFailure 的時候,六種全部放行,被守著的 Bash 指令照跑。加上一行 "onFailure": "block",擋下其中五種。
剩下那一種為什麼過得去?得先知道 hook 出錯的時候,Claude Code 把它當成什麼。
官方 hooks 文件自己埋了一句讓人不太安心的提醒。講到腳本啟動失敗那段,它說設定 policy hook 之後,第一次跑要留意那行 non-blocking 的通知,因為「a mistyped path in settings.json leaves the gate silently disabled」。路徑打錯一個字,守門員就不在崗位上了,畫面上只多一行通知,動作照做。
2.1.295 是我 10 月 9 日抓 CHANGELOG 時最新的一版,onFailure 是那一版 Added 的第一條。exit 2 會擋、其他不擋這些基礎,3 月那篇〈Claude Code Hooks 完整指南〉寫過了;10 月 5 日那篇〈mods 的守門 hook 出錯會放行〉講的是 mods 用 JS 寫的守門。這篇管的是 settings 裡最常見的那種 command hook,它出錯時的去向,以及這一版多出來的開關。
停電的柵欄是升著的
想像停車場出口的電動柵欄。管理員看一眼你的票,按「放行」或「不放行」。hook 就是那個管理員,exit 2 是他明確按下「不放行」。
問題是管理員可能根本沒來上班(腳本不存在),可能睡著了(逾時),也可能講話含糊不清(印出一堆解析不了的東西)。這些時候柵欄該升還是該降?Claude Code 的預設答案是:升著,車子照過。
文件的原句是「Any other exit code doesn’t block on its own for most hook events.」啟動失敗也丟進同一個桶:腳本路徑不存在或不能執行時,shell 會用像 127 這樣的代碼結束,你看到的是一行 Failed with non-blocking status code: /bin/sh: /path/to/hook.sh: No such file or directory,然後動作照做。
最容易踩的是 exit 1。寫 shell 的人手很順就會打 exit 1 表示失敗,文件特別點名了這件事:「Without valid JSON on stdout, Claude Code treats exit code 1 as a non-blocking error and proceeds with the action, even though 1 is the conventional Unix failure code.」想擋就要 exit 2。例外只有 WorktreeCreate,它任何非零都會中止建立 worktree。
JSON 也一樣。stdout 要「以 { 開頭、以 } 結尾」才會被拿去當 JSON 解析;解析失敗或欄位驗證不過,在標準決策模型的事件上算 non-blocking error,動作照樣進行。
所以擋不擋,原本整個決定在腳本裡面。腳本好好地跑完、好好地 exit 2,門才會關;腳本自己倒了,門就開著。
onFailure 只做一件事
官方 hooks 文件頁到 2026 年 10 月 9 日都還沒寫這個欄位。下面的描述是我從本機 2.1.295 的二進位檔裡用 strings 撈出來的 schema 說明:
1 | strings -a /Users/cheng/.local/share/claude/versions/2.1.295 \ |
撈到的原文:
1 | What a failure of this hook does: it could not start (a missing script or plugin directory), |
這段機制描述的來源是 2.1.295 二進位裡的字串,不是文件頁,基準日 2026-10-09。CHANGELOG 那條寫的是「command and HTTP hooks」,文件之後補上時措辭可能會不同,以文件為準。動手前先跑 claude --version,低於 2.1.295 的版本認不認得這個欄位,我沒測。
它做的事就這一件:把「失敗」改記成 exit 2。失敗的定義有四類,啟動不了(腳本或 plugin 目錄不存在)、逾時、exit code 不是 0 也不是 2、印出無效或驗證不過的 JSON。值只有兩個,continue 是預設,也就是現在的放行行為;block 讓失敗走 exit 2 的路,被事件守著的東西(tool call、權限請求、prompt)就被擋下。
回到柵欄。block 等於跟停車場說:管理員不在、睡著、講話聽不懂,一律當他按了「不放行」。
有兩種情況它不管用:async hook,以及 Stop、SubagentStop、TaskCompleted、TeammateIdle 這四個事件。字串沒說原因。我的猜測是這幾個事件的 exit 2 意思是「叫 Claude 繼續做」,而不是「擋下某個動作」,把失敗轉成 exit 2 反而會多跑一輪,但這只是推測,沒有證實。
設定長這樣,就是在 hook 那一項多一個欄位:
1 | {"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[ |
六種弄壞的方法,十二次實跑
我想知道 schema 寫的四類失敗,實際上是不是都會被擋。測法是讓 Claude 執行一行 touch,hook 守在 PreToolUse 的 Bash 上,最後看 marker 檔有沒有長出來。判定只看 ls 的結果,不看模型自己說了什麼。
1 | claude -p --model haiku --settings <設定檔> \ |
--settings 指向暫存目錄裡的臨時 JSON,每次只換腳本、有沒有加 onFailure 這兩個地方,沒動到我自己的任何設定。執行的模型用 haiku。
這行指令我第一次寫錯。--allowedTools "Bash(touch *)" 後面直接接 prompt,prompt 會被當成 --allowedTools 的第二個值吃掉,結果報 Error: Input must be provided either through stdin or as a prompt argument when using --print。在中間放一個別的旗標(我放 --output-format text)把兩者隔開就正常了。--allowedTools 吃多個值,寫腳本呼叫 claude -p 時要留意擺放順序。
六種形態,每種各跑「不設」跟「設 block」一次,共十二次:
1 | # 失敗形態 腳本做的事 不設 onFailure onFailure: "block" |
「擋下」那幾格,ls 回的是 No such file or directory;「放行」那幾格,marker 檔都在。被擋的時候,Claude 拿到的 tool_result 是一個 is_error: true 的錯誤(用 --output-format stream-json 看得到):
1 | PreToolUse:Bash hook error: [<腳本路徑>]: failed; blocking because onFailure is "block" |
訊息直接寫了是哪支腳本、為什麼擋,事後要查也查得到原因。每種我都只跑一次,沒有重複取樣。
第六種:我以為我測了壞 JSON
第 6 列不是我設計出來的。我原本只想測「壞 JSON」,手一滑寫成 echo "{bad json",少了右括號。結果加了 onFailure: "block",marker 檔照樣長出來。
光看這一列,會以為 block 沒作用。可是同樣加了 block,exit 1、逾時、腳本不存在都擋下了,問題顯然出在那串輸出本身。我另外那組寫成 {bad} 的,有頭有尾,就被擋了。
兩者差在哪?回去翻文件的 JSON 判定規則:stdout 要以 { 開頭、以 } 結尾,才會被當成 JSON 解析。{bad json 沒有結尾的 },所以我猜它根本沒進 JSON 解析那一步,被當成一般文字輸出處理,自然也不算「無效的 JSON」,而腳本又是 exit 0,從 Claude Code 的角度看,這支 hook 根本沒失敗。
這個解釋跟文件的規則對得上,但我要說清楚:onFailure 在這種情況下怎麼判定,我手上沒有官方描述可以證實,原因目前只能算推測。
這個坑值得記下來。我以為我在測「壞 JSON 會不會被擋」,實際上測到的是「一段看起來像 JSON 的文字會不會被擋」,那是兩件事。你如果也要驗自己的守門腳本,壞樣本要先確認它在對方眼裡真的算「壞」,不然拿到的放行結果,回答的是另一個問題。
換到寫守門腳本那一側,結論也很直接:想表達「不准」,就明確 exit 2。不要指望某種輸出「壞得夠明顯」會被當成失敗。
該開在哪些 hook 上
我的立場:擋危險指令、擋密鑰外洩這種 policy 型的守門 hook,都該加上 onFailure: "block"。這類 hook 存在的理由就是「有疑慮就不准」,它自己倒了卻預設放行,等於把最重要的那道門交給運氣。
純通知、純記錄的 hook 不要開。看第 1 列就知道代價:腳本找不到,被擋的是那個 Bash 呼叫本身。一支只是要寫 log 的 hook 路徑打錯,結果是你的工作被它卡住,這個交換不划算。
會讓我改變想法的條件是逾時。如果你的守門腳本要打網路、平常就偶爾超時,開 block 等於把網路抖動直接變成工作中斷。那種情況我會先把腳本的逾時處理好,再考慮開。
這次沒測到的範圍也要說清楚。我只用了 PreToolUse 事件、command 型 hook、--settings 帶進來的設定檔。HTTP 型 hook(CHANGELOG 說也支援)、UserPromptSubmit、PermissionRequest 這些事件在 block 下的實際行為,我都沒跑;plugin 自帶的 hooks.json 寫這個欄位有沒有一樣的效果,我也沒驗。
二進位裡還有兩句擋下理由的字串,一句是 blocking because this hook is required.,另一句提到 CLAUDE_CODE_RESTRICT_PERSONAL_CONFIG 設定時,使用者自己的 hook 必須成功。看起來 hook 失敗轉成阻擋不只 onFailure 一條路,但這兩條我只在程式內字串看到,沒找到文件,也沒驗證,先記著就好。
柵欄的問題不只出在 hook
想知道自己的 hook 屬於哪一種,有個很便宜的方法:把 settings 裡最重要的那支守門 hook,路徑故意改錯一個字母,叫 Claude 跑一個它該擋的指令。門開著的話,就知道該補哪一行了。
同一個問題換個地方也會冒出來。CI 裡的必要檢查、部署前的審核腳本、閘道器前面的驗證服務,測的時候很容易只測它擋不擋得住壞東西,忘了測它自己倒下時,門是開著還是關著。
至少在 hook 這一層,2.1.295 之後不必等腳本自己好好跑完才知道答案,可以在設定檔裡先寫好:守門的人倒下時,門關著。
原文來源:Hooks reference - Claude Code Docs(Exit code 0、Other exit codes 等節)、Claude Code CHANGELOG(2.1.295 條目),以上皆以 2026 年 10 月 9 日讀取的版本為準;
onFailure的 schema 描述取自本機 2.1.295 二進位內字串;實跑環境為本機claude --version輸出2.1.295 (Claude Code),2026 年 10 月 9 日


















