DeepSeek Harness · 工具系統

工具執行流水綫:三段瀑布與單調 Guard

pre-execute 到 post-execute 的三段管綫,Guard 只能收緊不能放行。核心源碼:packages/core/tools/src/index.ts。

課程目標讀完你能説清三件事:一次工具調用在 DSH 裏要過哪三段瀑布,每段各管什麼;Guard 為什麼在類型上就沒有放行這個選項,插件順序怎麼排都翻不了案;被拒絕的調用不會消失,它會物化成一條模型看得見的錯誤結果,繼續走完流水綫。
互動演示 · 流水綫闖關
bash: rm -rf build/
第 1 段 · pre-execute 瀑布
進門前表態:allow / deny / ask,監聽器可重排
Guard 層 · 單調守衞
只能給拒絕理由或棄權,類型上沒有放行
第 2 段 · execute 瀑布
圍着執行包一層:超時、重試、指標
第 3 段 · post-execute 瀑布
出門前改寫:accept / block,換內容或換值
選擇情景後點「播放」,或滾動到此處自動播放情景 A。
演示為教學化模擬:監聽器與守衞的名字是課程化舉例,段與段的順序、Guard 的單調語義對應 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 行)。

核心視覺 · 一次調用的完整路徑
tool/call 落日誌 UI 同步渲染待執行卡 pre-execute 瀑布 allow / deny / ask ctx.approval 審批 僅 allowed-once 繼續 單調 Guard deny 或棄權,無 allow execute 瀑布 超時 / 重試 / 工具本體 post-execute accept / block deny 物化為 Error 結果 跳過工具本體,仍走 post-execute finalizeContent 後 tools/result 凍結定稿
教學化結構圖:路徑對應 docs/tool-execution-pipeline.zh.md 的官方流程圖,節點文案經過課程化整理。
Guard 的單調性 · 為什麼類型裏沒有 allow

先看邊界問題:兩個 pre-execute 監聽器,一個想 allow 一個想 ask,最終聽邊個?答案是排在前面的那個。瀑布是短路的,第一個不調 next() 直接返回決定的監聽器就定了案。所以 pre-execute 天然順序敏感,插件加載順序一變,安全結論就可能跟着變。

DSH 的解法是在 pre-execute 後面加一層順序不敏感的終審。Guard 的返回類型只有兩種:一個字串(拒絕理由),或者 undefined(棄權)。沒有任何返回值能表達同意。這樣一來,註冊十個 Guard 還是一百個,隨便怎麼排,結論只可能更嚴不可能更松。類型定義就是證據:

packages/core/tools/src/index.ts第 703 至 711 行
/**
 * 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
源碼快照説明:依據本地倉庫 deepseek-harness-master,核對文件 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 的瀑布上跑,橋接文檔順手暴露了兩個協議差異:

packages/hooks/hooks-claude-code/README.zh.md · 第 92 行(已知限制)
「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 行),而且模組註釋直接寫明瞭失敗語義:

grok-build-main/crates/codegen/xai-grok-hooks/src/lib.rs · 第 16 至 17 行
「- pre_tool_use hooks 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 提供預設只讀語義 一課有完整拆解。

課堂練習
01

手推一次 rm -rf 的完整路徑

部署裏註冊了兩個 pre-execute 監聽器(先 CC hooks 橋接,配置了一條 ask 規則;後一個白名單插件,對 rm 直接返回 allow)和一個沙箱 Guard(對寫出工作區的命令返回理由)。模型發起 bash: rm -rf /tmp/x。第一問:審批彈窗會唔會出現?第二問:把兩個 pre-execute 監聽器對調註冊順序,答案變唔變?第三問:沙箱 Guard 嘅結論受唔受呢個次序影響?點解?(提示:瀑布短路 + 第 1486 行的 denialReason 只在 allow 之後才問 Guard。)

Takeaway:三段瀑布各管一段:進門前表態、圍着執行包一層、出門前改寫結果。順序敏感的擴展放瀑布裏,順序不敏感的否決權交給 Guard,Guard 的類型裏沒有 allow,拒絕一旦成立誰也翻不了案。拒絕也是一等結果:物化成 Error 文本給模型,流水綫照常走完。