DeepSeek Harness · 審批與沙箱

審批與權限:兩個旋鈕,一個下拉框

沙箱模式和審批策略是兩個獨立旋鈕,預設只是常用組合。核心原始碼:packages/interaction/user-approval 與 packages/interaction/permission-presets。

課程目標讀完你能說清三件事:為什麼 plan mode、auto-accept、YOLO 這些產品概念在 DSH 底層只拆成 sandbox/mode 和 approval/policy 兩個正交變數;預設下拉框為什麼只是給旋鈕組合起名字,切預設時日誌裡到底寫了哪幾條事件;以及審批為什麼在每個環節都 fail-closed(拿不到答案就當拒絕)。
互動演示 · 雙旋鈕控制檯

下面是一個可以擰的權限控制檯。左邊兩個旋鈕各管一件事,右邊下拉框是預設。先選一個操作,點「播放」看這次工具呼叫一路闖關的過程;然後隨便擰旋鈕、換預設,再跑一遍,看同一個操作的命運怎麼變。

旋鈕一 · 沙箱模式

sandbox/mode(管檔案效果) read-only workspace-write danger

旋鈕二 · 審批策略

approval/policy(管問不問人) ask never

預設下拉框

permission preset(旋鈕快捷方式)
會話日誌追加的事件
模型發起工具呼叫write ./src/app.ts
沙箱層按旋鈕一裁決檔案效果
審批層被攔後想提權,按旋鈕二決定問不問
結局等待執行
審批彈窗:Agent 想為這一次呼叫把沙箱臨時放寬到 danger-full-access,允許嗎?(只授權這一個操作)
點「播放」執行當前組合,或滾動到此處自動播放一遍。
演示為教學化模擬。旋鈕取值、預設表與提權審批流程對應 docs/subsystems/permission-presets.zh.md、docs/subsystems/approval.zh.md 與 packages/sandbox/sandbox/src/escalation.ts;審批彈窗期間你真的可以點按鈕替使用者做決定。
先分清兩個旋鈕各管什麼

先給結論:DSH 把權限這個大詞拆成了兩個互不打聽的小問題。旋鈕一 sandbox/mode 回答的是這條命令的檔案效果允許到什麼程度,取值 read-only、workspace-write、danger-full-access 三檔,只管檔案系統,網路和行程可見性都不在它的詞彙表裡(docs/subsystems/sandbox.zh.md 開頭的定義)。旋鈕二 approval/policy 回答的是碰到需要人拍板的事問不問,取值 ask、never 兩檔。

兩個旋鈕在日誌裡也是兩種獨立事件:sandbox/mode 和 approval/policy。執行、提示詞、回放,全都只讀這兩種旋鈕事件的摺疊結果。這就是正交的實際含義:擰任何一個,另一個紋絲不動。

旋鈕一 · sandbox/mode
  • read-only:後端必須拒絕寫入,只保留 /dev/null 這類 shell 必需的接收器。
  • workspace-write:工作區根目錄與後端承諾的臨時區可寫,界外一律攔。
  • danger-full-access:繞過隔離。消費方直接 spawn 原始命令,根本不呼叫 ctx.sandbox。
旋鈕二 · approval/policy
  • ask(預設):交給組合的應答者鏈。一個應答者都沒有?鏈走到頭返回 unavailable,照樣按拒絕處理。
  • never:確定性返回 rejected,不分發任何應答者。CI 與無人值守的標準姿勢。
  • 唯一的放行值是 allowed-once,且只授權所詢問的那一個操作。rejected、cancelled、unavailable 三種結果呼叫方全部執行拒絕。
預設:只是給格子起名字

然後是那個下拉框。ctx.permissionPresets 維護一張表:名字對映到一個旋鈕組合。預設表就兩行,workspace-write 對應 workspace-write 加 ask,danger-full-access 對應 danger-full-access 加 never(permission-presets/src/index.ts 第 167 至 176 行的預設配置)。它是可選能力,不在 agent loop 主幹上,也不擁有任何強制執行。

切預設時發生什麼?三條事件,順序固定:先追加一條只記日誌的 permission/preset 記下你的意圖,再透過兩個旋鈕各自的規範 setter 寫 sandbox/mode 和 approval/policy,而且只寫生效值真的變了的那個。重新選中當前預設?什麼都不追加。回放時執行層只認旋鈕事件,preset 事件的唯一作用是當兩個預設共享同一組合時,記住你當初選的到底是哪一個名字。

還有 custom。它是保留字,表裡不許有叫這個名字的條目(外掛載入時就拋異常)。當你手動把旋鈕擰到一個表裡沒有的組合,current() 才派生出 custom 給客戶端顯示。它只出不進:可以是當前狀態,永遠成不了切換目標,也絕不出現在事件 payload 裡。所以演示裡的下拉框把它做成了灰色不可選項,這正是原始碼的行為。

fail-closed 貫穿始終

沒有應答者、應答者拋異常、返回了詞彙表外的怪值、彈窗期間使用者關掉了介面,統統歸一化成 unavailable 或 cancelled,呼叫方一律當拒絕。任何一環拿不到明確的 allowed-once,就不放行。

審批不進模型上下文

approval/asked 與 approval/decided 成對寫入會話日誌,只做審計。模型看到的是工具結果和執行時上下文快照。ApprovalRequest 還故意不帶工具參數,用 callId 指向已經流式輸出的那次呼叫,避免渲染一份會漂移的副本。

提權是一次性的

寫操作被沙箱攔下後,模型可以帶 sandbox_permissions 請求提權重試,審批通過給 allowed-once,這次重試以顯式更寬的模式解析一次策略,用完即棄。會話的旋鈕設定不會因此改變。

關鍵證據 · never 在瀑布之前就攔死

有個邊界問題值得摳:如果一個外掛在審批服務掛載之後,用 prepend 往應答者鏈最前面塞一個全部放行的應答者,能不能繞過 never?答案是不能,因為 never 根本不在鏈裡執行。看 decide() 的開頭:

packages/interaction/user-approval/src/index.ts第 304 至 312 行節選
  private async decide(req: ApprovalRequest, session: Session): Promise<ApprovalOutcome> {
    const signal = req.signal
    if (signal?.aborted) return 'cancelled'
    // The 'never' policy is decided HERE, before any dispatch: a listener
    // registered with `prepend: true` after this service mounts would sit
    // ahead of any gate LISTENER, so a listener-shaped gate cannot keep the
    // documented promise that 'never' rejects deterministically regardless
    // of registration order — only the service's own request path can.
    if (this.effectivePolicy(session) === 'never') return 'rejected'
原始碼快照說明:依據本地倉庫 deepseek-harness-master,核對檔案 packages/interaction/user-approval/src/index.ts,核對日期 2026-08-13。程式碼塊保留原始碼原文。

註釋把設計意圖講完了:never 的裁決點在服務自己的請求路徑上,任何監聽器形態的門都保不住與註冊順序無關這個承諾。往後看第 317 至 329 行,ask 路徑的兜底同樣密不透風:鏈的無應答預設值是 unavailable,拋異常的應答者被摺疊成 unavailable,返回怪值的也歸一化成 unavailable。

切預設的完整寫入路徑

預設只是快捷方式,這句話的全部實現是一個十四行的私有方法 apply()。它做三件事,順序固定。第一步 resolve(name) 查表,名字不在表裡直接拋異常,異常訊息還會把已知的表鍵列出來。第二步對照 current() 的派生結果:你選的名字和當前生效的預設不同,才追加一條 permission/preset 事件記下意圖;重選當前預設,這一步什麼都不寫。第三步挨個檢查兩個旋鈕,預設宣告的沙箱模式與事件折疊出來的生效值(拿不到就用部署預設值兜底)不同,就調 setSandboxMode() 寫一條旋鈕事件;審批策略同理,走傳入的 setApproval 寫入。誰的生效值沒變就跳過誰。

所以切預設沒有第三個執行路徑,也沒有隱藏狀態:一條意圖日誌,加上至多兩次走各自規範 setter 的旋鈕寫入,日誌裡追加了什麼,執行層就摺疊出什麼。派生 custom 的邏輯在同檔案 derive():先看上次選的預設還配不配當前旋鈕值,配就保住它;不配就按宣告順序找第一個匹配的表項;都不匹配才返回 custom。

出處:packages/interaction/permission-presets/src/index.ts 第 379 至 392 行(apply())、第 309 至 321 行(derive()),核對日期 2026-08-13。

橫向對比 · 單一模式軸 vs 雙旋鈕
Claude Code:一根模式軸 + 規則表

Claude Code 把同一塊地盤做成了單一的 permission mode 軸:default、plan、acceptEdits、bypassPermissions、dontAsk 五檔(還原原始碼 restored-src/src/utils/permissions/PermissionMode.ts 第 44 至 91 行,見書稿第 7 章),外加一張 allow / deny / ask 三種行為的規則表,規則內容可以細到 Bash(git commit:*) 這種命令前綴。

對照著看,摺疊的代價就顯出來了。dontAsk 大約等於 DSH 的 never,bypassPermissions 大約等於 danger-full-access 加 never,但這五檔在一根軸上滑動,沙箱收多緊和問不問人被綁在一起賣。DSH 的雙旋鈕能表達 read-only 加 ask 這種組合:沙箱只讀兜底,遇到確需寫入的操作彈窗問一次,一次放行。在 CC 的模式軸上找不到直接對應的檔位。反過來 CC 也有 DSH 雙旋鈕表達不了的東西:規則表是按工具和命令前綴的細粒度控制,DSH 的兩個旋鈕都是會話級別的全域值,工具粒度的門禁要靠 hook 層去做。兩邊各自把複雜度放在了不同的地方。Grok Build 的授權鏈走的又是另一條路(從工具請求到受限執行逐層放行),站內 Grok 完整授權鏈 一課有逐層拆解,可對照閱讀。Codex 則在同一道題上疊了兩層:命令級的 execpolicy 用精確前綴加反例把例外釘在載入期,Guardian 更進一步,用一次模型審查把彈窗從主路徑上拿掉。

課堂練習
01

推演兩個刁鑽場景

場景一:審批彈窗彈出後,使用者直接關掉了瀏覽器,UI 應答者隨 dispose 被移除。這次請求最終以什麼結果結算,工具呼叫是放行還是拒絕?場景二:策略是 never,一個外掛用 prepend 註冊了一個永遠返回 allowed-once 的應答者。寫出請求從 request() 進入到返回值的路徑,說明為什麼這個應答者一次都不會被呼叫。(提示:兩題的答案都在本頁的原始碼面板和它後面那段話裡。)

Takeaway:權限在 DSH 底層只有兩個正交變數,sandbox/mode 管檔案效果,approval/policy 管問不問人,預設是給組合起名字的表鍵,custom 只出不進。審批全鏈路 fail-closed:唯一的放行值是 allowed-once,且只管那一個操作;never 在服務內部、瀑布分發之前就攔死,誰都插不了隊。