AI 工程

Claude Code 內建的三個 mod 被公開了:sec-default / diff / telemetry 拆解與實作指南

你每天在用的 /diff,其實是一個 plugin

2026 年 9 月 9 日,anthropics/claude-code 的 main 分支多了一個資料夾:mods/。裡面三個子目錄——sec-defaultdifftelemetry。commit(d9c456d,作者是 Anthropic 的 Alice Poteat,GitHub 帳號 @poteat,PR #93215)訊息只有一句:

Add mods: sec-default, diff and telemetry, the hooks-module plugins built into Claude Code

mods/README.md 第一段講得更白:「These three ship inside Claude Code; this folder is their source, published as it is built into the binary.」

意思是:你用 /diff 打開的那個未提交變更面板(CHANGELOG 2.1.260 加的,2.1.267 還在修它的閃爍問題),不是引擎內部的特權功能。它是一個 plugin,用的是任何人都能用的 API。Anthropic 把原始碼公開,等於把「內建功能是怎麼寫的」整個攤開給你抄。

這篇要做三件事:把這套 API(工程上叫 function hooks,產品名叫 Claude Mods)的機制講清楚、把三個內建 mod 拆開看它們各自示範了什麼招、然後給你今天就能跑的步驟。

先跑起來:三個指令

(1) 打開 flag。 function hooks 目前預設關閉。Alice Poteat 在 issue #91870 的 9/9 更新裡明講:「any folks who want to test and give feedback may use CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude」。

CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude

(2) 把三個 mod 的原始碼拉下來。 整個 repo 不小,用 sparse checkout 只拿 mods/

git clone --depth 1 --filter=blob:none --sparse \
  https://github.com/anthropics/claude-code.git cc
cd cc && git sparse-checkout set mods

(3) 用原始碼跑 diff mod(README 給的指令):

claude --plugin-dir mods/diff

然後在一個有未提交修改的 git repo 裡打 /diff。cheat sheet 上寫 --plugin-dir 會 hot-reload on save——存檔就重載,這是你之後迭代自己 mod 的主要迴圈。

一句提醒先講在前面:mods/README.md 自己標了 early access——「the API these mods are written against may change between releases without notice」。現在學機制、學招式可以;把生產流程綁死在上面不行。

運作原理:hook 是一個 function,鏈是一顆洋蔥

hooks.json 多一個 key

今天的 hook 長這樣(官方文件的例子):

{ "hooks": { "PreToolUse": [ { "matcher": "Bash",
  "hooks": [ { "type": "command", "command": "./hooks/block-rm.sh" } ] } ] } }

function hook 的版本,hooks/hooks.json 只多一個 modules

{ "modules": ["./register.ts"] }

然後 hooks/register.ts 匯出一個 register(on)

export function register(on) {
  on('tool.call', { tool: 'Bash' }, ($, e, next) => {
    if (e.command.includes('rm -rf /'))
      return { deny: 'Destructive command blocked by hook' }
    return next(e)
  })
}

三個參數就是整套設計的全部:

  • $ 是引擎介面——這個 plugin 能讀什麼、能造成什麼副作用。架構文件的說法是「the world by any other name」。
  • e 是事件本身:某個 $ 方法被呼叫時的那個參數,一個不可變的 plain value。
  • next 是延續(continuation):呼叫它,下面每一層 hook 跑完、core 給出答案,回傳一個 promise。你可以呼叫零次、一次、或很多次。

寫過 Koa / Express 中介層的人看到這裡就懂了。架構文件寫得更直接:on(X, A)on(X, B)on(X, C) 折成 X = A(B(C(⊥)))。註冊得早的包住註冊得晚的,所以位置就是權力

五種擺位,一張表背起來

架構文件 §2.2 列了五種寫法,同樣都是 tool.call

擺位 你寫什麼 什麼時候用
before $.ui.log(...) 然後 return next(e) 記錄、注入 context
after const r = await next(e),處理完再 return r 改寫工具輸出、redact 密鑰
during const p = next(e),做自己的事,return p 並行副作用(通知、預熱)
instead 直接 return { deny: '...' } 擋下來,或自己回答
modifying return next({ ...e, timeout: 30 }) 改寫請求本身

這張表就是舊 hook 最痛的地方被解掉的地方:以前 PreToolUsePostToolUse 是兩個腳本、兩份 stdin JSON、兩邊還要自己對狀態;現在一個 await next(e) 就把 pre / post 縫在同一個 closure 裡。

五層 tier:企業控制的全部就在這裡

Claude Mods 的五層 tier:prepend / user / append / builtin / core,e 往下、result 往上

cheat sheet 上的鏈是五層,權力由上往下遞減:

prepend(組織政策)→ user(你自己裝的)→ append(組織政策)→ builtin(隨 binary 出貨)→ core(引擎本身)

e 往下走,每一層可以改寫「問題」;core 給出預設答案;result 往上走,每一層可以改寫「答案」。組織同時握住兩端——這句話是整個企業控制模型的一句話總結。

next 上還掛了幾個東西,值得記住:

  • next.origin{ plugin, tier },這次 dispatch 是誰發起的。
  • next.to(e, 'append')只有 managed tier 能用:跳過中間幾層,直接從指定 tier 繼續。這是 sec-default 唯一真正重要的一招。
  • next.is('tool.*', e) — type predicate,在 glob 或 * hook 裡把 e 收窄。
  • next.catch(...) — 你的 hook throw 或超時之後的補救;它拿到同一個 nextnext.called 告訴你剛才有沒有 dispatch 過。
  • next.signal — 一個 AbortSignal,整條鏈結束或取消時觸發,用來收掉你 float 出去的工作。

$ 是唯一的門

架構文件 §4 講得很硬:跑 plugin hook 的環境「has no ambient file system or network」。你能做的事,就是你在 $ 上呼叫的那些。而且拼寫必須是字面$.noun.verb(...)——cheat sheet 的「RULES OF THE ROAD」寫:loader 會盤點 $ 的用法和 on("event") 的字串,「refuses anything it cannot see」。想動態組字串繞過盤點,claude plugin validate 就不讓你過。

這件事的意義比它看起來大:一個 mod 的副作用集合是靜態可判定的。所以 plugin.register 這個事件的 e 裡才會有 uses[]——組織的 prepend plugin 可以在某個 plugin 載入之前,就看到它靜態用了哪些能力,然後 allow 或 refuse。

$ 本身也是折出來的。engine.create 是建 $ 的那次 fold,core 註冊在最後、負責放進原始能力,其他每一層在 next(e) 回來的東西上加自己的 noun:

on('engine.create', async ($, e, next) => ({
  ...(await next(e)),
  audit: { record },
}))

一層可以 noun,也可以(不往上傳)。組織的 plugin 在最上面、最後回傳,所以它可以只放行指定的 noun。架構文件把這個叫 blast-door。

三個內建 mod 對照:sec-default / diff / telemetry 各自 hook 什麼事件、示範什麼招式

拆 sec-default:三招,沒有第四招

sec-default 只有 24 個 .ts 檔、308 行(我 clone 下來數的),但它是三個 mod 裡設計密度最高的。

它要解的問題很具體:function hooks 一開,每個 plugin 對每個事件都有一票。而組織今天設定的一堆東西——classic hooks、managed CLAUDE.md 和 rules、managed settings、MCP allowlist——在 function hooks 之前根本不在使用者的射程內。現在突然在了。sec-default 坐在最外層,把「原本就不該被碰的」擋回去,而且不新增任何自己的政策

它的 register.ts 幾乎可以整段貼上來:

on('classic.*',       ($, e, next) => next.to(e, 'append'))
on('prompt.section',  ($, e, next) => next.to(e, 'append'))
on('prompt.context',  ($, e, next) => next.to(e, 'append'))
on('skill.prompt',    ($, e, next) => next.to(e, 'append'))
on('attribution.text',($, e, next) => next.to(e, 'append'))
on('settings.read',   ($, e, next) => next.to(e, 'append'))

README 說它「has three moves and nothing else」:

  1. 跳過 user tiernext.to(e, 'append')
  2. 點名拒絕next.origin.tier === 'user' 時回 { deny }
  3. 放行next(e)

第三招才是重點——README 最後一句是「adds no policy of its own. Everything else passes through untouched.」prompt.submittool.calltool.checksession.*ui.*fs.*http.fetch 全部原樣通過。

兩個實作細節值得學。

判來源用 e.provider,不是用呼叫者。 tool.describe / command.describe / agent.offer / agent.spawn 這四個事件,它看的是「這個主體是誰提供的」:

const USER_REACHABLE_TIERS = Object.freeze(['user', 'builtin', 'core'])
// provider 不在這三個裡(或根本沒有 provider)→ 當成組織的,跳過 user tier

原始碼註解寫得很清楚:「a missing or odd provider fails closed」。沒有 provider 就當組織的。

fail closed 一路貫徹到讀不到設定的時候。 政策是用 $.settings.read({ source: 'policy' }) 讀的,外面包了一層 500ms 的 memo(POLICY_MEMO_MS = 500,讓 tool.list / tool.register 的一波呼叫只讀一次;代價寫在註解裡——設定改動最多晚 500ms 才被看到)。而讀失敗的處理是:

const decidedByPolicy = (policy, decide) =>
  policy.then(decide).catch(() => true)   // 讀不到 → 當作政策生效

具體效果:只要 managed settings 裡有 allowedMcpServers(設成空陣列也算),user tier 的 plugin 就不能註冊工具,拒絕訊息是寫死的一句 allowedMcpServers (managed): plugins outside policy may not add tools。而 tool.list 會把兩份清單合起來——組織 MCP server 的工具(mcp__<server>__ 前綴)用組織那份,其餘用 user tier 改過的那份;任一份被拒、或政策讀不到,就整份用組織的。

管理員實際要動的設定只有一個。 預設情況下,在有 managed settings 的機器、或 Team / Enterprise 組織,CLI 會把 sec-default seat 在 prepend tier 的第一位。但只要你設了 prependPlugins,那份清單就是整個 prepend tier,你得自己把它放回去:

{ "prependPlugins": ["acme-guard@acme-tools", "sec-default@builtin"] }

順序有意義——排在前面的包住後面的。順帶一提,next.to 在 managed tier 之外會被拒絕,所以你用 --plugin-dir mods/sec-default 載它,載到的是一個只會放行的空殼。

拆 diff:一個真功能長什麼樣

diff 是三個裡最大的:448 個 .ts、5,808 行。它示範的是「一個完整功能可以完全活在 plugin 層」。

它 hook 的事件(README 的表):session.start 綁引擎、註冊 /diff、釘住 repo;ui.render of PromptHint 讀終端寬度;ui.render of Pane 畫面板;command.run of diff 開關、of clear/resume 清狀態;tool.call of Edit/Write/NotebookEditBash/PowerShell 之後刷新;turn.complete 刷新;prompt.submit 注入 context。

幾個可以直接偷走的招:

畫面是 ui.render + $.ui.resolve(e) 元件不是你 import 來的,是跟 surface 要的:

on('ui.render', { component: 'Pane' }, async ($, e, next) => {
  if (e.requestId !== PANE_ID) return next(e)
  const { Box, Text, Button, Select } = await $.ui.resolve(e)
  return Views.paneView({ ui: { Box, Text, Button, Select }, ... }, model)
})

同一棵樹,terminal 用 Ink 畫、desktop 用 DOM 畫。你不用管。

「搭下一個 prompt」是 prompt.submit 的 after 擺位。 面板上每個檔案有個 ask 按鈕,按了就把那個檔的 hunks 掛到下一次送出的 prompt:

on('prompt.submit', async ($, e, next) => {
  const result = await next(e)
  if (!armed || result.drop !== undefined) return result
  disarm()
  return { ...result, context: [...(result.context ?? []), armed.text] }
})

這是「注入 context」的正規寫法,比在 CLAUDE.md 裡塞東西精準得多——一次性、按了才有。

tool.call 的 hook 用 try/finally 包住 next(e) 因為上面任何一層都可能改你的結果、或根本不呼叫你,所以刷新面板的動作放在 finally

on('tool.call', { tool: [...EDITING_TOOLS] }, async ($, e, next) => {
  let result
  try { return (result = await next(e)) }
  finally { if (isPaneOpen) scheduleRefresh(host) }
})

自動開啟的門檻是寫死的數字。 README 明講:沒選過的人,終端要 ≥ 144 欄才自動開;曾經把面板留著的人,門檻降到 110 欄;主動關過的人就不再打擾。這種「預設值 + 記住選擇」的細節,是你自己寫 mod 時最容易漏掉的部分。

還有一個很誠實的產品決定:diff 的比較基準有三種——session 起點切開的 HEAD(預設)、單純的 HEAD、跟預設分支的 merge-base——選擇用 $.store 存在 per-repo 的 key 下;lockfile、generated、test 檔被分到另一區並預設摺疊。這些都不是 API 能力,但它們全都在 plugin 層。

拆 telemetry:怎麼把一個 noun 加給所有人

telemetry 只 hook 一個事件,engine.create,做的就是架構文件 Listing 3 的模式:

on('engine.create', async ($, e, next) => {
  const beneath = await next(e)
  return { ...beneath, telemetry: telemetryOf({ ... }) }
})

它在下層給的 $ 上疊一個 $.telemetry,往上每一個 plugin 拿到的 $ 都有這個 noun。$.telemetry.log({ event, props }) 送一列 tengu_plugin_<event>$.telemetry.mark({ feature, kind, reason? })tengu_feature_<kind>

三個地方值得學:

第一,「隱私關掉」是一個純函式。 isAnalyticsOff() 讀一次環境變數就決定整個 session 送不送:DISABLE_TELEMETRYCLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFICDO_NOT_TRACK 任一被設;或跑在第三方 provider 上(Bedrock / Vertex / Foundry 等,除非 host 自己管 provider);或部署有自己的 OAuth URL;或 NODE_ENV=test。任一成立,一列都不送。

第二,schema 是在送出前被拒絕的,不是在後端被清洗的。 README 寫「Nothing free-form reaches a row」:event 名和每個 property key 都必須是 snake_case token;值只能是有限數字、布林,或一個 Choice(一個字串,連同它被選出來的那份清單);mark 的 kind 只有 ok / sad / bad,後兩者必須reason、第一個不准給。違規的 entry 在任何東西送出之前就被 refuse。這是我看過最乾淨的「用型別強制事件命名紀律」實作。

第三,也是最該抄的:優雅降級的協定。 telemetry 這個 mod 只在 Anthropic 自家內部版本上被 seat(session.authorize 只存在於那裡)。那別人的 mod 呼叫 $.telemetry 會怎樣?diff 的 README 直接寫了:「$.telemetry is the telemetry plugin's noun; where it is absent the rows are dropped and nothing else changes.」——noun 不在就當作「這裡沒有 analytics」,不是當作錯誤。你要在 engine.create 加 noun,就該同時定義好「沒有我的時候,別人怎麼辦」。

數字、限制,跟哪些是我沒驗證的

先把來源分清楚:

  • 三個 mod 的原始碼、README、hooks.json、檔數行數(sec-default 24 檔 308 行、telemetry 60 檔 786 行、diff 448 檔 5,808 行)——我 clone 下來自己讀、自己數的。
  • API 語義(五種擺位、五層 tier、next.*engine.create fold、ui.render 與 surface 三元組)——來自 Alice Poteat 的架構文件 PDF(2026 年 8 月)與她 9/9 貼在 issue #91870 的 $ cheat sheet。
  • /diff 面板本身已經出貨——CHANGELOG 2.1.260「Added a diff panel that opens beside the conversation in fullscreen mode… toggle it with /diff」,2.1.267 還在改它的空狀態。

限制,照原文說:

  1. 不在官方文件裡。 我把 CHANGELOG 整份抓下來 grep 過,截至 2.1.267 沒有任何一條提到 function hooks、hooks module 或 modules key。這是 early access。
  2. API 會變。 mods/README.md:「the API these mods are written against may change between releases without notice」。
  3. 不在 marketplace。 同一份 README:「They are not listed in this repository's marketplace; the copies that matter are the ones already in your Claude Code.」你 clone 下來的是讀物,不是安裝來源。
  4. 時程只有一個模糊數字。 Alice Poteat 在 9/9 的更新裡寫「We're shipping in N weeks」——字面就是 N,我不會替它填數字。她同時宣布產品名定為 Claude Modsfunction hook 保留為實作術語。
  5. 信任模型是全有全無。 cheat sheet 的 trust 欄寫得直白:「plugins are trusted code with the process's reach」。mod 不是沙箱;組織的治理手段是 admission——plugin.register 上的一個 hook。裝別人的 mod,跟裝一個會跑 postinstall 的 npm 套件是同一個風險等級。
  6. 每個 hook 有預算。 throw 或超過 10 秒,這個 hook 會被跳過並留一行暗色訊息;除非你宣告了 .catch,它會在一個 grace budget 裡跑。回傳形狀不對則一律跳過。所以:hook 裡不要做長工作,要做就 float 出去,並用 next.signal 收尾。

什麼時候該寫 mod,什麼時候別寫

該寫:

  • 你要的行為需要「同一個地方同時看到 before 和 after」——例如把工具輸出裡的密鑰在進模型之前換掉(issue #91870 的 case study 之一就是這個,而且那個 plugin 是 Claude 自己寫出來的)。
  • 你要畫東西:面板、狀態列、在既有元件上加 badge。舊 hook 完全做不到。
  • 你要加工具或加 slash command,而且要它跟著 session 狀態變。
  • 你是管理員,需要的控制比 settings 檔的欄位還多。

別寫:

  • 一個 PostToolUseprettier --write 就解決的事。classic hook 還在,而且被 1:1 包成 classic.<Event> 事件,沒有要淘汰。
  • 需要跨版本穩定的生產流程。現在沒有相容性承諾。
  • 只是想給模型一些背景知識——那是 skill 或 CLAUDE.md 的工作,別動 prompt.section
  • 團隊裡沒人寫 TypeScript。這是一套有型別的 API,declare module 'claude-code' 的宣告合併是它的一等公民;沒有型別你會很痛。

今天可以做的四件事

  1. 把 flag 打開跑一天CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude,然後 claude --plugin-dir mods/diff,對照原始碼讀 /diff。看得懂一個內建功能,你就看得懂全部。
  2. 寫一個 20 行的 audit mod。架構文件的 Listing 5 就是這個,跑一天,你會第一次看清楚你的 session 到底在呼叫什麼:
    on('*', ($, e, next) => {
      $.ui.log(next.origin.plugin + ' → ' + next.event)
      return next(e)
    })
    
  3. 把你現有的 shell hook 挑一個改寫成 function hook,只改一個,感受 await next(e) 取代 pre/post 一對的差別。
  4. 管理員先做一件事:檢查你的 managed settings 有沒有設 prependPlugins。有設的話,function hooks 一 GA,sec-default 就不會自動坐在最外層——你得自己把 sec-default@builtin 寫進去。這是目前唯一一個「不做會出事」的動作項。

來源

整理:DataAgent · Coding Agent 實戰教學

發表迴響

%d 位部落客按了讚: