在 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
2
strings -a /Users/cheng/.local/share/claude/versions/2.1.295 \
| grep -n "What a failure of this hook does"

撈到的原文:

1
2
3
4
5
6
What a failure of this hook does: it could not start (a missing script or plugin directory),
timed out, exited with a code other than 0 or 2, or printed JSON that is invalid or fails
validation. 'continue' (default): the failure is reported and the action goes ahead.
'block': the failure counts as exit code 2, so the action the event guards (a tool call,
a permission request, a prompt) is blocked. Ignored for async hooks and on Stop,
SubagentStop, TaskCompleted and TeammateIdle.

這段機制描述的來源是 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
2
{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[
{"type":"command","command":"<腳本絕對路徑>","onFailure":"block"}]}]}}

六種弄壞的方法,十二次實跑

我想知道 schema 寫的四類失敗,實際上是不是都會被擋。測法是讓 Claude 執行一行 touch,hook 守在 PreToolUse 的 Bash 上,最後看 marker 檔有沒有長出來。判定只看 ls 的結果,不看模型自己說了什麼。

1
2
3
claude -p --model haiku --settings <設定檔> \
--allowedTools "Bash(touch *)" --output-format text \
"Run exactly this one bash command ...: touch <marker檔>"

--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
2
3
4
5
6
7
#  失敗形態                      腳本做的事                       不設 onFailure  onFailure: "block"
1 腳本不存在 nope.sh(檔案不存在) 放行 擋下
2 exit 1 exit 1 放行 擋下
3 壞 JSON(有頭有尾) echo "{bad}"; exit 0 放行 擋下
4 逾時 sleep 6,hook 設 "timeout": 1 放行 擋下
5 JSON 合法但不合 schema permissionDecision 填 "maybe" 放行 擋下
6 壞 JSON(只有開頭) echo "{bad json"; exit 0 放行 放行

「擋下」那幾格,ls 回的是 No such file or directory;「放行」那幾格,marker 檔都在。被擋的時候,Claude 拿到的 tool_result 是一個 is_error: true 的錯誤(用 --output-format stream-json 看得到):

1
2
PreToolUse:Bash hook error: [<腳本路徑>]: failed; blocking because onFailure is "block"
No stderr output

訊息直接寫了是哪支腳本、為什麼擋,事後要查也查得到原因。每種我都只跑一次,沒有重複取樣。

第六種:我以為我測了壞 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 日