✔ Validation passed,綠色勾勾。往上看兩行,夾著一句官方教學頁上沒有的話:

1
❯ ./register.js gating hook without .catch: tool.call

這是我照 Claude Code 官方的「Write a mod yourself」教學,在暫存目錄建了一個叫 first-mod 的 mod,三個檔案加一個測試檔,內容照抄教學頁,只拿掉了註解。然後跑 claude plugin validate 檢查它。教學頁貼的範例輸出有 hooks: 和 calls: 兩行,我的多了中間這一行。

通過了,可是多一句話。這種時候最麻煩:它沒有擋你,你也不知道該不該理它。

環境先交代:macOS,claude --version 印出 2.1.291 (Claude Code)。mods 是在 2.1.287 加進來的,CHANGELOG 那一段原文是「Added Claude Mods: plugins may now modify deeper behavior」,2.1.287 上 npm 的時間是 2026 年 10 月 1 日(UTC)。新聞面的介紹在〈10 月 2 日的新聞摘要〉寫過,這篇只管動手做出一個之後碰到的事。

first-mod 在做什麼

一個 mod 就是一個 plugin,最少三個檔:.claude-plugin/plugin.json、hooks/hooks.json、hooks/register.js。讓它從普通 plugin 變成 mod 的,是 hooks.json 裡那個 modules 鍵,官方原文是「having it is what makes the plugin a mod」。

真正做事的是 register.js,官方原文如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let calls = 0
export function register(on) {
on('session.start', async ($, e, next) => {
await $.command.register({ name: 'tally', description: 'Show how many tool calls Claude has made' })
return next(e)
})
on('tool.call', async ($, e, next) => {
calls += 1
$.ui.invalidate('ui.render')
return next(e)
})
on('command.run', { command: 'tally' }, async () => {
return { text: 'Claude has made ' + calls + ' tool calls since this mod loaded' }
})
on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
return next({ ...e, props: { ...e.props, suffix: ' · tool calls: ' + calls + '…' } })
})
}

四個 hook。開場時註冊一個 /tally 指令;Claude 每用一次工具就把計數加一;有人打 /tally 就回報數字;轉圈圈的 spinner 後面多掛一段「tool calls: N」。不用 Node.js,不用 bundler,Claude Code 直接載入 .js。

每個 hook 都收到同樣三樣東西:$(mods 的 API)、e(這次事件的資料)、next(往下傳)。一開始看有點抽象,我把它想成公司裡的公文簽核。

一份公文(事件)沿著一排關卡往下送。每一關可以看一眼、蓋章轉呈,這是 return next(e);也可以把內容改了再往下送,這是 next({ ...e, 改過的欄位 });或者乾脆自己批示、不往下送了,就是不呼叫 next,直接 return 一個結果。最後一關是 Claude Code 自己原本的處理方式。

first-mod 的 tool.call hook 屬於第一種,只是在經過的公文上畫一筆正字,然後轉呈。它沒擋任何東西。

三個指令的輸出

validate 之後,我又跑了兩個指令。

第一個是用 -p 非互動模式直接打 /tally:

1
2
3
$ claude -p "/tally" --plugin-dir <first-mod 目錄>
Warning: no stdin data received in 3s, proceeding without it. If piping from a slow command, redirect stdin explicitly: < /dev/null to skip, or wait longer.
first-mod: Claude has made 0 tool calls since this mod loaded

第二行跟官方範例一致:mod 載入了,指令註冊了,-p 模式下沒有任何工具呼叫,所以是 0。第一行是 -p 在沒接 stdin 時會等 3 秒再印的警告,跟 mod 無關,訊息自己就講了加 < /dev/null 可以跳過。我沒加這個參數重跑一次,所以只能說訊息是這樣建議的。

第二個是在 first-mod 目錄跑 claude plugin test,跑的是官方教學附的那個測試檔:

1
2
3
4
5
6
tests/first-mod.test.ts:
(pass) /tally reports the tool calls the mod has seen [37.62ms]

1 pass
0 fail
Ran 1 test across 1 file. [0.36s]

37.62 毫秒、全程 0.36 秒,是這台 macOS、這個版本、跑一次的數字,拿來比效能沒有意義。讓我意外的是另一件事:mod 的測試直接在 shell 裡就跑完了。官方說它不需要 session、登入或網路,測試檔從 claude-code/testing 匯入 expect 和 test 就好。

另外載入一次之後,first-mod/.claude-plugin/ 底下多了一個 types/ 目錄,ls -a 看得到 claude-code、claude-code-mcp、claude-code-tools、tsconfig.json 和一個 .gitignore。官方 create 頁特別交代,事件和方法在版本之間可能會變,跟文件頁衝突時以這些型別檔為準。這句話我記下來了,因為下一節就碰到文件沒寫的東西。

那一行在說什麼

gating hook without .catch: tool.call。

我先去 create 頁和 events 頁找 gating 這個字,兩頁都沒有。所以這一行的官方定義是什麼,我沒找到,下面講的是我順著字面去翻、翻到的相關規則,不是這行輸出的正式解釋。

字面上拆得出三件事:某個 hook 被當成「會擋東西的」、它沒有 .catch、事件是 tool.call。events 頁有一節叫「Handle a hook that fails」,講的正好是 .catch,而且範例就是 tool.call。

規則是這樣:一個沒有 .catch 的 hook,如果 throw、逾時,或回傳的形狀不對,接下來怎麼辦取決於它出錯的時間點。

  • 在呼叫 next 之前出錯:Claude Code 略過它,下一關接手。
  • 在 next 已經有結果之後出錯:那個結果照用,什麼都不會重跑。

回到簽核的比喻。假設你在流程裡安插一關專門擋危險公文,例如看到 git push --force 就退件。這一關的人某天當機了(throw),或者想太久(逾時),公文不會卡在他桌上,會直接跳過他、送到下一關。下一關照常蓋章,最後一關照常執行。你以為你在門口站了一個警衛,他一倒下,門就是開的。

這就是 fail-open。守門的那一關出事,流程不會卡住,直接放行。

逾時要另外小心,因為它有硬性的時間上限。reference 頁的 Limits 表寫一個 hook 自身的執行時間上限是 10 秒(prompt.edit 是 50 毫秒)。events 頁還補了一個細節:如果你的守門 hook 要先問使用者「這個指令要不要跑」,等待要放在 $.ui.ask 這類 mods API 呼叫裡面,那段時間不算進上限;等你自己的 promise 就會算。原文接著說:「Claude Code skips a hook that times out, so the held command would run.」被扣住的指令,就這樣跑掉了。

要讓它出錯時改成擋下來,官方的寫法是在 on(...) 回傳的註冊結果上接 .catch。下面這段照 events 頁原文複製,guard 是你自己的守門函式:

1
2
3
4
5
// on returns a registration, and .catch attaches a handler to that one hook
on('tool.call', { tool: 'Bash' }, guard).catch(async ($, e, next) => {
// next.error.kind is 'throw' or 'timeout', which says how guard failed
return { deny: 'The command guard failed, so this command was not run: ' + next.error.kind }
})

guard 正常的時候,.catch 那段永遠不會跑。guard 出錯或逾時,Claude Code 拿同一個事件去叫它,它回 { deny },指令不執行,Claude 讀到的結果會帶著 throw 或 timeout。.catch 自己也有時限,Limits 表寫的是 1 秒,所以裡面別再做什麼重活,直接退件就好。

回頭看 first-mod。它的 tool.call hook 只是 calls += 1 然後 next(e),沒有任何擋東西的意圖,validate 還是把它點名了。validate 是怎麼判定「gating」的,我沒查到,不猜。我能確定的只有現象:一個純計數、一定會呼叫 next 的 tool.call hook,在 2.1.291 的 validate 下會得到這一行。

還有一件跟這個放行行為放在一起看會更不舒服的事。overview 頁的原文是「Mods aren’t sandboxed」,就算你開了 sandboxing,沙箱隔離的只有 Claude 跑的 Bash,mod 自己啟動的 process 在沙箱外面。events 頁也寫了,tool.check 上的 hook 可以核准一個被你自己 PreToolUse settings hook 擋掉的呼叫(組織 managed settings 裡的除外)。我在〈提示寫 Fetch,規則叫 WebFetch〉整理過「到底誰在說不」,mods 等於在那排人裡又多插了幾個,而且其中有人能推翻別人的決定。

我的做法

再看到 gating hook without .catch,我會先回頭看那個 hook:它在記帳,還是在守門?

記帳的,像 first-mod 這種只計數、本來就會 next(e) 的,就放著。守門的,一律接 .catch,寧可它出錯時多擋一次,也不要它在我以為有人看門的時候安靜放行。tool.call 就照上面那段回 { deny }。tool.check 的回傳形狀不一樣,events 頁的例子回的是 { decision: 'deny', reason },它的 .catch 該怎麼寫,我在文件裡沒找到範例。補完再跑一次 claude plugin test,確認測試還是綠的。

會讓我改變想法的條件有一個:那個守門 hook 本來就只是提醒性質。events 頁那個「在 main 分支禁止 git push」的例子,文件自己講了它比對的是指令文字,要真的擋住推到 main,請去 Git 主機設 branch protection。這種 hook 擋不擋得住都不是最後一道防線,它 fail-open 我能接受,加 .catch 是多一道手續而已。

這篇有實際跑的只有四件事:claude plugin validate、claude -p "/tally"、claude plugin test,以及確認 types/ 目錄被寫出來,輸出都照貼在上面。下面這些我只讀了文件,沒有實際操作:互動 session 裡 spinner 後面的計數畫面、/tally 在互動 session 的行為、改檔後的 hot reload、/plugin 列表的顯示、--safe-mode、用 /plugin-authoring 請 Claude 代寫 mod、內建的 You should know mod、官方的 sample mods,以及組織端的 allowManagedModsOnly。上面那段 .catch 寫法也沒有實跑過;我沒有試過加上 .catch 之後 validate 那一行會不會消失。

validate 的另一個用法

這次是拿 validate 檢查自己寫的東西,它更大的用處其實在別人的 mod 上。它不執行程式碼,做的是靜態分析,會列出 mod 用了哪些 hook、呼叫了哪些 $ 方法、讀寫了哪些環境變數和 state。官方的 Warning 寫得很直白:「A mod is code that runs with your permissions.」它能讀你的檔案、環境變數和含 API key 的 settings 檔,能看到每個 prompt 和每次 tool call。裝之前花幾秒看它碰了什麼,划算。

自己寫守門 hook 的話,claude plugin test 裡值得多一條「guard 丟例外時,指令有沒有被擋下」。這條比任何註解都可靠。

想看完整的守門型 mod 長什麼樣,官方 claude-code-playground 裡有一個 blast-radius,overview 頁的描述是它會扣住 rm -rf、force push 這類危險指令,顯示影響範圍並附上繼續或取消的按鈕。官方註明這些範例「as they are, without support」,我還沒裝來跑過。內建 mod 的部分原始碼公開在 anthropics/claude-code 的 mods 目錄。

管團隊環境的人,admin 頁的 sec-default 跟這篇的主題剛好對得上。它是 Claude Code 內建的 guard,排在使用者裝的每一個 mod 前面,使用者關不掉;前提是這台機器有 managed settings,或是用 Team、Enterprise 方案登入。它保護的是組織管理的那些設定,算是 mods 裡少數預設就站在擋的那一邊的設計。可是同一頁也寫了,組織自己再寫一個守門 mod 去檢查使用者的 mod,那個檢查的 hook 出錯或逾時一樣會被略過,被檢查的 mod 照樣載入。要它 fail closed,還是得補 .catch。

原文來源:Claude Code mods overview、Write a mod yourself、React to events with a mod、Mods reference、Mods admin、Claude Code CHANGELOG(以 2026 年 10 月 5 日至 6 日讀取的版本為準)