DeepSeek Harness · 審批與沙箱

沙箱:從 seatbelt 到執行世界

ctx.fs 和 ctx.subprocess 同享一個路徑命名空間,執行環境可以整體換掉。核心源碼:packages/sandbox/、packages/e2b/ 與 native/landlock-run/。

課程目標讀完你能回答兩個問題:沙箱的邊界應該畫在哪一層,為什麼 ctx.sandbox 只管與宿主共內核的子進程,而容器和遠程執行走的是另一條路;以及把整個執行環境從本機換成遠端 E2B 沙箱,到底要改多少程式碼(劇透:消費方零改動)。
互動演示 · 執行世界切換台

先玩再講。情景 A 演示換世界:上排是 bash、PTY、LSP 這些幹活的組件,它們只連着中間兩個插口。點「切到 E2B」,看插口下面的世界整體換掉時,上面有沒有一根綫斷。再點「反面教材」,看沒有插口的架構換世界是什麼慘狀。情景 B 是拒絕解碼器:同一條報錯 stderr,怎麼區分是沙箱攔了,還是沙箱自己壞了。

消費方(幹活的組件,一行程式碼都不用改) 兩個插口(能力 seam)· 同一個路徑命名空間 ctx.fs resolve / read / write / processPath ctx.subprocess spawn / 進程組 / stdio 本機執行世界 fs-local + subprocess-local · 外加 bwrap / Landlock / Seatbelt 管束子進程 read 看到的 /work/app.ts,就是 bash 裏能 cat 的那個 /work/app.ts
bash: line 1: /etc/hosts: Permission denied(退出碼 1)
非零退出 + stderr光看退出碼永遠不夠
第一步 · 查 runner 故障規則先剔除無害通知行,再找致命簽名
第二步 · 查 denial 方言只匹配當前後端會產生的拒絕詞
點「播放」自動走一遍,或直接點上面的大按鈕手動折騰。
演示為教學化模擬。情景 A 對應 packages/e2b/README.zh.md 與 docs/subsystems/filesystem.zh.md 的執行世界約定;情景 B 對應 packages/sandbox/sandbox/src/index.ts 第 95 至 116 行的 denialSignatures 與 runnerFailureRules 兩套正交分類器。
沙箱的邊界畫在哪一層

先給結論:DSH 把沙箱拆成了三層,各管各的。

第一層 · 共內核子進程

ctx.sandbox.confine(argv, policy) 只做一件事:把你要 spawn 的 argv 包一層限制 runner。Linux 用 bwrap 或 Landlock,macOS 用 Seatbelt(sandbox-exec),Windows 用 ACL 受限令牌。前提是子進程與宿主共享文件系統和內核。

第二層 · 進程內工具

read / write / edit 這些操作根本不 spawn 子進程,包 argv 沒意義。fs-sandbox 在受信程式碼裏做策略檢查:規範化路徑、驗證包含關係,拒絕時拋結構化的 FS_SANDBOX_DENIED。它自己知道拒絕了什麼,不用去猜內核 stderr 的文本。

第三層 · 整個執行世界

容器、microVM、遠程執行屬於整個能力 seam 的同級替換,壓根輪不到 ctx.sandbox 出面(docs/subsystems/sandbox.zh.md 第 5 行)。想換到遠端,換掉 ctx.fs 和 ctx.subprocess 兩個插口的實現就行。

策略跟着調用走,不焊死在提供方上

第一層有個容易想錯的點:沙箱策略是每次調用隨身攜帶的參數,從來不焊死成提供方的全局狀態。ctx.sandboxPolicy.resolve() 為每次能力調用解析一份完整策略(模式 + 工作區根目錄 + 會話標識),顯式批准的提權模式壓過會話設定,會話設定壓過部署預設值。所以同一個進程裏兩個會話,一個 read-only 一個 workspace-write,向同一個提供方要不同的邊界,互不干擾;批准過的提權重試也只是一次帶更寬策略的新調用,提供方狀態一點沒變。

Linux 的 Landlock 後端值得單獨説一句。DSH 沒用現成的包裝庫,自己寫了 landlock-run:約 300 行 C11、musl 靜態連結,先在自己身上裝好 Landlock 規則集再 exec 目標命令,規則集跨 execve 繼承,所以命令拉起的每個子孫進程都在限制下。內核不支持就直接退出,不跑命令。二進制約定(argv 語法、退出碼、報告行)鎖定在 native/landlock-run/docs/cli-contract.md;probe() 返回 full、partial、unusable 三態,老內核 ABI 只報 partial。還有個細節:啓動器失敗的約定退出碼是 125,但成功 exec 的子進程也可能自己退 125,所以光看退出碼永遠定不了性,必須同時看到致命診斷行。這就引出了下面兩套分類器。

denial ≠ runner failure

confine() 返回的包裝 argv 附帶兩套 stderr 分類器。先查 runnerFailureRules:匹配到致命簽名説明 runner 自己掛了,命令根本沒跑,按沙箱基礎設施故障上報。再查 denialSignatures:匹配到説明沙箱正常工作、攔下了越界操作。順序不能反。

拒絕詞按後端方言匹配

bwrap 的只讀掛載報 EROFS 文本,Landlock 報 EACCES,Seatbelt 報 EPERM。消費方只匹配當前後端聲明的那幾個詞,不用跨後端並集,因為並集會認領某個後端根本不會產生的拒絕。

partial 不能當 full 用

強制執行完整性是後端上報的事實。老 Landlock ABI、Windows ACL 的 Everyone 與硬連結缺口都只能報 partial。需要絕對邊界的消費方必須顯式處理這個區別,不能裝看不見。

沒有後端就拒絕跑,絕不裸奔

受限策略下,靜默的無隔離透傳永遠不合法。要求了沙箱但一個後端都不可用時,confine() 拋出 SandboxUnavailableError,整個調用失敗,命令一行都不會跑。這個錯誤類型本身就把立場寫死了:錯誤消息第一句是「refusing to run the command unconfined」,隨後逐平台給出修復建議,Linux 裝 bubblewrap 或換一個開啓 Landlock 強制的內核,macOS 確認 sandbox-exec 可用,Windows 確認 ACL 受限令牌 runner 能啓動;實在不想修,就把消費方顯式切到 danger-full-access,把不設防寫在明面上。

這個異常繼承 HarnessError,攜帶 SANDBOX_UNAVAILABLE 錯誤碼穿過結構化錯誤通道,一路進 tool/result。調用方靠錯誤碼就能區分缺隔離和命令失敗這兩種結算,不用去猜 stderr 文本。fail-closed 在這裏的含義很樸素:環境沒準備好,答案是不跑,誰也別想偷偷降級成裸奔。

出處:packages/sandbox/sandbox/src/index.ts 第 131 至 144 行(SandboxUnavailableError)、第 124 行(SANDBOX_UNAVAILABLE 錯誤碼),核對日期 2026-08-13。

關鍵證據 · 進程內的結構化拒絕

第二層的實現比想像中短。fs-sandbox 繼承本地文件系統後端,只在兩個變更操作前加了一道按調用解析的圍欄:

packages/fs/fs-sandbox/src/index.ts第 126 至 132 行節選
  private async checkedTarget(target: FsTarget, sandboxPolicy?: SandboxExecutionPolicy): Promise<FsTarget> {
    const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve()
    const { mode } = policy
    if (mode === 'danger-full-access') return target
    if (mode === 'read-only') {
      throw new FsError(`cannot write "${target.displayPath}": file access denied under read-only mode`, 'FS_SANDBOX_DENIED')
    }
源碼快照説明:依據本地倉庫 deepseek-harness-master,核對文件 packages/fs/fs-sandbox/src/index.ts,核對日期 2026-08-13。程式碼塊保留源碼原文。

往下的 workspace-write 分支(同文件第 133 至 147 行)在包含性檢查之前先重新規範化一次路徑,專門防檢查時是 A、寫入時被符號連結換成 B 的調包戲法,然後用檢查過的那個新目標去做變更。可寫根目錄集合來自 writableRoots() 這一個函式,和 Seatbelt 給 bash 的授權範圍同源,兩邊不會漂移。

執行世界:兩個插口,整體搬家

現在到第三層。所有涉及可變狀態的組件,bash 執行器、持久 PTY、LSP 宿主、文件工具,都不直接碰操作系統,它們把活委託給 ctx.fs 和 ctx.subprocess 兩個插口。這兩個插口約定共享一個路徑命名空間:ctx.fs.processPath(target) 返回的絕對路徑,ctx.subprocess 起的子進程直接就能打開(docs/subsystems/filesystem.zh.md「目標標識與元數據」一節)。read 看到的文件和 bash 摸到的文件,是同一個世界裏的同一個文件。

於是換世界變成了換插口。packages/e2b/ 提供 fs-e2b 和 subprocess-e2b 兩個適配器,分別通過 E2B 的 Filesystem API 和 Commands/PTY API 實現同樣的 seam。官方 README 把消費方零改動這件事説得很直接:

packages/e2b/README.zh.md · 第 13 行
「現有的 dsh-bash-local、dsh-terminal-bash 和 dsh-lsp-stdio 無需 E2B 專用 fork。它們把執行環境中的所有操作委託給 ctx.fs 和 ctx.subprocess,因此掛載這兩個 E2B 適配器後,它們所有涉及可變狀態的工作都發生在同一個沙箱內。」

邊界也畫得很清醒:搬走的只是文件和進程,harness 進程本身、模型調用、agent 與會話狀態、會話持久化都留在本機(README 第 15 行)。Agent 無感的原因就在這:它調的還是那幾個工具,工具連的還是那兩個插口,只是插口後面的世界換了。

橫向對比 · Grok Build 與 Claude Code
Grok Build:xai-grok-sandbox

Grok Build 的沙箱是獨立的 Rust crate xai-grok-sandbox,按預置 profile 組織限制強度,站內已經逐行核對過:五種沙箱 Profile 講 profile 的分級設計,從工具請求到受限執行的完整授權鏈 講一次工具調用怎麼被逐層放行。和 DSH 對照,Grok 的強項在本機限制的工程完成度;至於 fs 與 subprocess 共享命名空間、兩個 seam 整體換成遠程世界的這套抽象,在已核對的 Grok Build 材料裏未見等價物。這一條基於已公開證據,保留未知項。

Claude Code:把重心押在權限判定上

Claude Code 的防綫主要修在執行之前:bash 命令要過三層權限鏈路(模式檢查、規則匹配、必要時再加 AI 分類器,見書稿第 7 章對 bashPermissions.ts 的拆解),macOS 上可用 sandbox-exec 做輔助限制。官方工程網誌對 agent 的建議是在「sandboxed environments」裏跑並配好護欄(書稿第 7 章第 350 行引文),也就是説環境級隔離更多交給部署方。基於還原源碼這份公開證據,CC 沒有 DSH 這種按調用攜帶策略的沙箱 seam,也沒有可整體替換的執行世界抽象;作為一個產品這樣取捨完全説得通,判定做得足夠細,環境就可以交給用戶。Windows 這一格分得更開:Claude 直接寫明沒有沙箱,Grok 把平台標成 unknown,DSH 的 windows-acl 包用了同一組受限令牌標誌但停在寫限制,Codex 則把讀和網絡也圈了進去,站內 Codex Windows 沙箱 一課有逐層拆解。

課堂練習
01

推演兩個邊界場景

場景一:同一個 DSH 進程裏開兩個會話,會話甲是 read-only,會話乙是 workspace-write,兩邊同時各跑一條寫文件的 bash 命令。寫出每條命令從 ctx.sandboxPolicy.resolve() 到 ctx.sandbox.confine() 各自拿到的策略內容,説明為什麼提供方不需要任何切換狀態的動作。場景二:一條被 landlock-run 包裝的命令以 125 退出。列出你在下結論之前必須檢查的證據(提示:LAUNCHER_FAILURE_EXIT 的約定、致命診斷行、無害通知行的剔除順序),並分別寫出 runner 故障與命令自己退 125 兩種判定下,工具層應該給模型報什麼。

Takeaway:沙箱分三層:共內核子進程包 argv、進程內工具做結構化拒絕、整個執行世界按 seam 整體替換。策略按調用攜帶,同進程多會話互不干擾。fs 和 subprocess 共享路徑命名空間,所以換上 E2B 適配器就是換了世界,bash、PTY、LSP 一行不改,Agent 全程無感。