沙箱:从 seatbelt 到执行世界
ctx.fs 和 ctx.subprocess 同享一个路径命名空间,执行环境可以整体换掉。核心源码:packages/sandbox/、packages/e2b/ 与 native/landlock-run/。
ctx.sandbox 只管与宿主共内核的子进程,而容器和远程执行走的是另一条路;以及把整个执行环境从本机换成远端 E2B 沙箱,到底要改多少代码(剧透:消费方零改动)。
先玩再讲。情景 A 演示换世界:上排是 bash、PTY、LSP 这些干活的组件,它们只连着中间两个插口。点「切到 E2B」,看插口下面的世界整体换掉时,上面有没有一根线断。再点「反面教材」,看没有插口的架构换世界是什么惨状。情景 B 是拒绝解码器:同一条报错 stderr,怎么区分是沙箱拦了,还是沙箱自己坏了。
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 failureconfine() 返回的包装 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 继承本地文件系统后端,只在两个变更操作前加了一道按调用解析的围栏:
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')
}
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 把消费方零改动这件事说得很直接:
「现有的dsh-bash-local、dsh-terminal-bash和dsh-lsp-stdio无需 E2B 专用 fork。它们把执行环境中的所有操作委托给ctx.fs和ctx.subprocess,因此挂载这两个 E2B 适配器后,它们所有涉及可变状态的工作都发生在同一个沙箱内。」
边界也画得很清醒:搬走的只是文件和进程,harness 进程本身、模型调用、agent 与会话状态、会话持久化都留在本机(README 第 15 行)。Agent 无感的原因就在这:它调的还是那几个工具,工具连的还是那两个插口,只是插口后面的世界换了。
Grok Build 的沙箱是独立的 Rust crate xai-grok-sandbox,按预置 profile 组织限制强度,站内已经逐行核对过:五种沙箱 Profile 讲 profile 的分级设计,从工具请求到受限执行的完整授权链 讲一次工具调用怎么被逐层放行。和 DSH 对照,Grok 的强项在本机限制的工程完成度;至于 fs 与 subprocess 共享命名空间、两个 seam 整体换成远程世界的这套抽象,在已核对的 Grok Build 材料里未见等价物。这一条基于已公开证据,保留未知项。
Claude Code 的防线主要修在执行之前:bash 命令要过三层权限链路(模式检查、规则匹配、必要时再加 AI 分类器,见书稿第 7 章对 bashPermissions.ts 的拆解),macOS 上可用 sandbox-exec 做辅助限制。官方工程博客对 agent 的建议是在「sandboxed environments」里跑并配好护栏(书稿第 7 章第 350 行引文),也就是说环境级隔离更多交给部署方。基于还原源码这份公开证据,CC 没有 DSH 这种按调用携带策略的沙箱 seam,也没有可整体替换的执行世界抽象;作为一个产品这样取舍完全说得通,判定做得足够细,环境就可以交给用户。
推演两个边界场景
场景一:同一个 DSH 进程里开两个会话,会话甲是 read-only,会话乙是 workspace-write,两边同时各跑一条写文件的 bash 命令。写出每条命令从 ctx.sandboxPolicy.resolve() 到 ctx.sandbox.confine() 各自拿到的策略内容,说明为什么提供方不需要任何切换状态的动作。场景二:一条被 landlock-run 包装的命令以 125 退出。列出你在下结论之前必须检查的证据(提示:LAUNCHER_FAILURE_EXIT 的约定、致命诊断行、无害通知行的剔除顺序),并分别写出 runner 故障与命令自己退 125 两种判定下,工具层应该给模型报什么。