本页文字稿为音频的配套解读,内容取自小山学堂课程素材,属二次演绎版本。
行号与源码核对日期为 2026-08-13,随版本演进可能变化。
这一集只讲一件事——沙箱的边界应该画在哪一层。
不是"沙箱怎么配置",而是"用哪一层机制去拦"。大部分人对沙箱的理解是"给命令套一层限制",这在只跑本机命令时够用;一旦要跑进程内工具、要换容器、要把执行整体搬到远端,它就不够用了。
| 要限制的对象 | 隔离机制 | 生效位置 |
|---|---|---|
| 文件 | 路径策略 / 内核限制 | 进程内或子进程 |
| 进程 | 限制器(runner)包装 | 子进程启动时 |
| 网络 | 沙箱策略或环境隔离 | 视实现而定 |
| 整个执行环境 | 容器 / microVM / 远程 | 环境本身 |
自检判据:判断一个隔离手段落在哪一层,只看一件事——它拦住的动作发生在哪个位置。位置不同,能拦住的攻击面就不同。
| 层 | 适用 | 机制 | 关键实现 |
|---|---|---|---|
| 第一层 共内核子进程 | 要 spawn 的命令 | 给 argv 包一层限制 runner | ctx.sandbox.confine(argv, policy) |
| 第二层 进程内工具 | read / write / edit(不起子进程) | 受信代码内做策略检查,抛结构化拒绝 | fs-sandbox |
| 第三层 整个执行世界 | 容器 / microVM / 远程执行 | 整个能力 seam 的同级替换 | packages/e2b/ |
第一层的平台后端:Linux 用 bwrap 或 Landlock;macOS 用 Seatbelt(sandbox-exec);Windows 用 ACL 受限令牌。前提是子进程与宿主共享文件系统和内核。
第一、二层是在现有环境里做减法(收窄能力);第三层不是减法,是整体替换(给一个全新的环境)。想限制就做减法,想隔离就换环境。
ctx.sandboxPolicy.resolve() 为每一次能力调用解析一份完整策略(模式 + 工作区根目录 + 会话标识)。
优先级:显式批准的提权模式 > 会话设置 > 部署默认值。
越靠近某一次具体动作的意图,优先级越高——因为越具体的意图信息越充分、判断越可信。
read-only、一个 workspace-write,向同一个提供方要不同的边界,互不干扰;临时权限最怕放宽容易、收紧忘了。 把"临时宽一点"做成参数而不是状态,就永远不会忘记收。
反例:若让部署默认值压过会话设置,用户明明选了 workspace-write 却被管理员默认值按回只读——这类"谁也解释不清当前为什么是这个状态"的问题,根源往往就是优先级链排反了。
native/landlock-run:约 300 行 C11、musl 静态链接。
做法:先在自己身上装好 Landlock 规则集,再 exec 目标命令。规则集跨 execve 继承,所以命令拉起的每一个子孙进程都在限制之下。
只限制顶层进程的设计,一遇到会 fork 子进程的命令就漏了。
| 约定项 | 值 |
|---|---|
| 启动器失败退出码 | 125(LAUNCHER_FAILURE_EXIT) |
| 探测返回值 | full / partial / unusable |
| 二进制约定 | 锁定在 native/landlock-run/docs/cli-contract.md |
| 内核不支持 | 直接退出,不跑命令 |
关键细节:约定退出码是 125,但成功 exec 的子进程自己也可能退 125。所以光看退出码永远定不了性,必须同时看到致命诊断行——这正是需要两套分类器的原因。
confine() 返回的包装 argv 附带两套正交的 stderr 分类器(packages/sandbox/sandbox/src/index.ts 第 95–116 行):
| 顺序 | 分类器 | 匹配到意味着 | 上报方式 |
|---|---|---|---|
| 1 | 剔除无害通知行 | 进度提示 / 弃用警告 / 调试信息 | 忽略 |
| 2 | runnerFailureRules | runner 自己挂了,命令根本没跑 | 按沙箱基础设施故障上报 |
| 3 | denialSignatures | 沙箱正常工作,拦下了越界操作 | 按安全拦截上报 |
顺序不能反。把故障误判成拒绝,你会以为系统好好拦住了一次越界,实际上命令压根没执行——问题被藏起来了。
| 后端 | 拒绝词 |
|---|---|
bwrap | EROFS(只读挂载) |
| Landlock | EACCES |
| Seatbelt | EPERM |
消费方只匹配当前后端声明的那几个词,不用跨后端并集——并集会认领某个后端根本不会产生的拒绝,把一次正常的失败误报成沙箱拦截。
分类器的词表要窄而准,不要图省事求全。 宽词表换来的是误报;漏报至少还有机会在别处暴露,误报会让你在错误方向上越走越远还自以为一切正常。
强制执行的完整性是后端上报的事实。老 Landlock ABI、Windows ACL 的 Everyone 与硬链接缺口都只能报 partial。需要绝对边界的消费方必须显式处理这个区别,不能装看不见。
受限策略下,静默的无隔离透传永远不合法。要求了沙箱但一个后端都不可用时,confine() 抛出 SandboxUnavailableError,整个调用失败,命令一行都不会跑。
出处:packages/sandbox/sandbox/src/index.ts 第 131–144 行(错误定义)、第 124 行(SANDBOX_UNAVAILABLE 错误码)。
错误消息第一句:"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 在这里的含义很朴素:环境没准备好,答案是不跑。
fs-sandbox 继承本地文件系统后端,只在两个变更操作前加一道按调用解析的围栏(packages/fs/fs-sandbox/src/index.ts 第 126–132 行):
danger-full-access → 直接放行;read-only → 抛 FS_SANDBOX_DENIED。在包含性检查之前先重新规范化一次路径——专门防"检查时是 A、写入时被符号链接换成 B"的调包戏法(TOCTOU)。然后用检查过的那个新目标去做变更。
这个"先规范化再检查,然后用检查过的目标做变更"的顺序是可复用的模式:把检查结果当作唯一合法输入传给下一步,而不是检查完回头再取一次原值。
可写根目录来自 writableRoots() 一个函数,和 Seatbelt 给 bash 的授权范围同源——两边不会漂移。否则会出现"文件工具能写、命令行工具不能写"的分裂。
优势:它自己知道拒绝了什么,不必去解析内核或限制器吐出的文本。把判断留在自己手里,比解读别人的输出可靠得多。
所有涉及可变状态的组件——bash 执行器、持久 PTY、LSP 宿主、文件工具——都不直接碰操作系统,而是委托给 ctx.fs 与 ctx.subprocess 两个插口。
共享路径命名空间(docs/subsystems/filesystem.zh.md「目标标识与元数据」):ctx.fs.processPath(target) 返回的绝对路径,ctx.subprocess 起的子进程直接就能打开。
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 适配器后,它们所有涉及可变状态的工作都发生在同一个沙箱内。
边界(README 第 15 行):搬走的只是文件和进程;harness 进程本身、模型调用、agent 与会话状态、会话持久化都留在本机。
| 做法 | 代价 |
|---|---|
| 每个工具做一套远端分支 / fork 远端专用实现 | 工具越多分支越多,两边行为迟早漂移 |
| 收敛到两个插口(本方案) | 长期纪律:所有组件只准连插口,不许直接碰 OS |
只要有一个组件图省事直接调了系统接口,换世界时它就得单独处理——而且往往是最后一个被发现的问题。
| 产品 | 沙箱形态 | 特点 |
|---|---|---|
| DeepSeek Harness | 三层 seam + 按调用携带策略 + 可整体替换的执行世界 | fs 与 subprocess 共享命名空间,换 E2B 适配器即换世界 |
| Grok Build | 独立 Rust crate xai-grok-sandbox,按预置 profile 分级 | 强项在本机限制的工程完成度;未见"两 seam 整体换远程世界"的等价抽象(基于已公开证据保留) |
| Claude Code | 重心押在执行前的权限判定(bashPermissions 三层链路),macOS 可用 sandbox-exec 辅助 | 环境级隔离更多交给部署方;未见按调用携带策略的沙箱 seam 与可整体替换的执行世界抽象 |
| Codex | Windows 上把读与网络也圈进去 | Windows 这一格各家分得最开:Claude 直接写明没有沙箱,Grok 标 unknown,DSH 的 windows-acl 停在写限制 |
partial 支持是否被显式处理,而非当 full 用?deepseek-harness-master 本地仓库核对。包 argv、进程内检查、整体替换环境是三种不同层级的手段。用错层级,再努力也是白费。自检方法:看它拦住的动作发生在哪个位置。
凡是需要"临时宽一点再收回来"的场景,把它做成参数而不是状态,就永远不会忘记收。优先级链按"越具体的意图越优先"排列。
把基础设施故障误判成安全拦截是最危险的一类误报——它让问题看起来像是被处理好了。顺序不可颠倒。
安全机制的默认值应该是失败,且错误消息要写明怎么修;实在不想修,也得让人把"不设防"写在明面上。
插口越少越窄,未来可替换性越强。收敛是长期纪律,回报是搬家零改动。
场景一:同一 DSH 进程里开两个会话,甲read-only、乙workspace-write,两边同时各跑一条写文件的 bash 命令。写出每条命令从resolve()到confine()各自拿到的策略,说明为什么提供方不需要任何切换状态的动作。
答:甲解析出 { mode: 'read-only', workspaceRoot, sessionId: 甲 },乙解析出 { mode: 'workspace-write', ... }。两次 confine() 各自携带自己的策略参数交给同一提供方——策略是参数不是状态,提供方无状态可切换,因此互不干扰。
场景二:一条被 landlock-run 包装的命令以 125 退出。列出下结论前必须检查的证据。
答:
LAUNCHER_FAILURE_EXIT = 125——但不能仅凭退出码定性;runnerFailureRules 是否命中致命诊断行。两种判定下工具层应报给模型的内容:
| 判定 | 上报 |
|---|---|
| runner 故障 | 沙箱基础设施故障——命令根本没跑,需修环境(装 bwrap / 换内核等) |
| 命令自己退 125 | 命令已执行、正常失败,按普通非零退出处理,给出 stderr 供模型调试 |
SANDBOX_UNAVAILABLE 拒绝运行,绝不裸奔;来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)
本内容改编自小山学堂课程素材,为二次演绎版本。