工具執行流水綫:三段瀑布與單調 Guard
pre-execute 到 post-execute 的三段管綫,Guard 只能收緊不能放行。核心源碼:packages/core/tools/src/index.ts。
packages/core/tools/src/index.ts 與 docs/tool-execution-pipeline.zh.md。玩的時候盯住一件事:只要有一個環節給出拒絕理由,後面誰也翻不了案。先説清問題。權限檢查、人工審批、超時、結果改寫、UI 渲染,全都想掛進工具執行這一個動作裏。如果讓每個工具自己處理,40 個工具就有 40 份權限程式碼。DSH 的做法是把工具執行做成一條流水綫,策略全部住在流水綫的固定工位上,工具本體只做一件事:執行並返回值。
流水綫的順序寫在 docs/tool-execution-pipeline.zh.md 第 8 行:tools/pre-execute 先跑,隨後是單調守衞,然後是 tools/execute 和 tools/post-execute。瀑布(waterfall)是 DSH 的監聽器排隊模式:每個監聽器拿到 (exec, next),可以調 next() 把決定權交給下一位,也可以直接返回一個決定當場定案。
三段的分工很清楚。第 1 段 pre-execute 在工具跑之前表態,返回值只有三種:allow 放行、deny 拒絕、ask 轉人工審批。ask 只有拿到審批服務的 allowed-once 才繼續,沒接審批通道就當 deny 處理。第 2 段 execute 是環繞式包裝,超時策略、重試、指標都在這裏給真正的執行包一層,它能替換取消信號但動不了調用身份。第 3 段 post-execute 在結果出來之後檢查:原樣接受、換掉內容、換掉值,或者 block 把結果改寫成一條糾正性錯誤。
拒絕不是沉默被 deny 的調用會物化成 Error: 理由 的 isError 結果,而且照樣走 post-execute 和 tools/result。模型能看到自己為什麼被拒,循環不會因為一次拒絕卡死。
參數改不了pre-execute 可以否決但不能改寫參數。因為 tool/call 事件在執行前就落了日誌,UI 的待執行卡片也已經按原參數渲染,改參數會讓歷史、介面、執行三方對不上(index.ts 第 583 至 586 行的類型註釋寫明瞭這條排除)。
Guard 是同步終審Guard 在 pre-execute 全部表態之後、工具本體之前跑,簽名是同步函式:返回字串就是拒絕理由,返回 undefined 就是棄權。全局 Guard 先問,再沿 agent 的作用域鏈從遠到近問(index.ts 第 1118 至 1127 行)。
先看邊界問題:兩個 pre-execute 監聽器,一個想 allow 一個想 ask,最終聽邊個?答案是排在前面的那個。瀑布是短路的,第一個不調 next() 直接返回決定的監聽器就定了案。所以 pre-execute 天然順序敏感,插件加載順序一變,安全結論就可能跟着變。
DSH 的解法是在 pre-execute 後面加一層順序不敏感的終審。Guard 的返回類型只有兩種:一個字串(拒絕理由),或者 undefined(棄權)。沒有任何返回值能表達同意。這樣一來,註冊十個 Guard 還是一百個,隨便怎麼排,結論只可能更嚴不可能更松。類型定義就是證據:
/**
* A monotonic execution guard evaluated after every `tools/pre-execute`
* listener and before the tool body. Returning a reason denies the call;
* returning `undefined` leaves it unchanged. Because guards have no allow
* result, listener ordering cannot turn a denial back into permission.
* @param execution - the identity-protected call after extensible pre-execute policy completed.
* @returns a final denial reason, or `undefined` to leave the call allowed.
*/
export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined
packages/core/tools/src/index.ts,核對日期 2026-08-13。程式碼塊保留源碼原文。註釋裏那句原話值得抄下來:「guards have no allow result, listener ordering cannot turn a denial back into permission」。惡意插件想放行一個被拒的調用,不需要防,因為它在類型系統裏就寫不出這個動作。這比在運行時檢查放行權限乾淨得多,問題類別直接被消滅了。
再看拒絕之後發生什麼。調度器的定案邏輯分兩步:只有 pre-execute 的決定是 allow(含審批通過的 ask),才輪到 Guard 逐個表態;pre-execute 的拒絕理由和 Guard 的拒絕理由匯到同一個變數裏,任何一方給出理由,調用就地物化成一條 Error: 理由 的錯誤結果。工具本體連碰都不碰,但這個結果帶着 post-result 標記繼續交給 post-execute 和最終觀察者。
所以審計插件、上下文注入插件在拒絕場景下照常工作,拒絕對流水綫的其餘部分只是一種普通結果。工具拋異常、找不到工具(UNKNOWN_TOOL)也走同樣的歸一化路徑。
出處:定案與物化在 packages/core/tools/src/index.ts 第 1486 至 1499 行,異常與 UNKNOWN_TOOL 的歸一化在第 1546 至 1555 行,核對日期 2026-08-13。
Claude Code 把權限判斷分散在 Tool 接口的方法上:每個工具自帶 checkPermissions、validateInput、isReadOnly,BashTool 還要再串白名單和 ML 分類器(書稿 study/chapters/02-tool-system.md 第 350 至 376 行)。外掛擴展走 PreToolUse / PostToolUse hooks。有意思的是 DSH 自己實現了一個 CC hooks 橋接插件 packages/hooks/hooks-claude-code,把 CC 的 hook 掛到 DSH 的瀑布上跑,橋接文檔順手暴露了兩個協議差異:
「PreToolUse只支持部分功能:deny與ask決策可用;allow不會預審批,不支持defer,additionalContext會被忽略,updatedInput會被記錄 + 警告但不應用」
這兩條限制另有原因:流水綫的不變式在擋路。CC 原生 hook 可以 allow 預審批、可以用 updatedInput 改寫工具參數;DSH 的橋接把前者降級、把後者只記日誌不執行,因為放行權在 DSH 裏不外借,參數在 tool/call 落日誌之後不可變。同一份 CC hook 配置,換個宿主,能做的事就變少了,這正好量出了兩套協議的表達力邊界。另外多個 CC hook 在橋接裏按最嚴格方式摺疊(deny 優先於 ask 優先於 allow),摺疊結果與順序無關(README 第 49 行),和 Guard 的單調思路一脈相承。
Grok Build 的 hooks 系統(crates/codegen/xai-grok-hooks)只有 pre_tool_use 一個點能攔截,決策類型是 Allow 或 Deny 兩個值(src/result.rs 第 5 至 10 行),而且模組註釋直接寫明瞭失敗語義:
「-pre_tool_usehooks can deny/allow (blocking); all others are non-blocking
- Fail-open by default: hook failures do not block normal operation」
Fail-open 的意思是 hook 自己崩了、超時了,調用照常放行。DSH 反過來:pre-execute 監聽器拋異常,這次調用直接歸一化成錯誤結果,寧可錯殺。兩種取向都講得通,Grok 把 hooks 當外掛增強,不讓用戶腳本拖垮主流程;DSH 把策略當流水綫的正式工位,工位塌了調用就不該過。Grok 工具系統的註冊表與只讀語義,站內 ToolKind 提供預設只讀語義 一課有完整拆解。
手推一次 rm -rf 的完整路徑
部署裏註冊了兩個 pre-execute 監聽器(先 CC hooks 橋接,配置了一條 ask 規則;後一個白名單插件,對 rm 直接返回 allow)和一個沙箱 Guard(對寫出工作區的命令返回理由)。模型發起 bash: rm -rf /tmp/x。第一問:審批彈窗會唔會出現?第二問:把兩個 pre-execute 監聽器對調註冊順序,答案變唔變?第三問:沙箱 Guard 嘅結論受唔受呢個次序影響?點解?(提示:瀑布短路 + 第 1486 行的 denialReason 只在 allow 之後才問 Guard。)