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 在服務內部、瀑布分發之前就攔死,誰都插不了隊。