学 AI 产品 · 专业 AI 产品经理播客第 4 章 · T4 解剖 DeepSeek Harness:一切皆插件的 Agent 底座 · EP 11
第 4 章 · EP 11

沙箱:从 seatbelt 到执行世界

时长 15:41音色 云健 · 男声

同步字幕

章节导航(点击跳转)

0:00开场 · 沙箱的边界画在哪一层1:55
1:55三层各管什么1:43
3:39策略跟着调用走1:50
5:29Landlock 后端为什么自己写1:12
6:41拒绝不等于故障1:54
8:35没有后端就拒绝跑1:28
10:03进程内工具怎么做检查1:50
11:54两个插口,整体搬家2:14
14:09可带走的原则1:32
解读全文

dsh11 · 沙箱:从 seatbelt 到执行世界

  • 模块:T4 解剖 DeepSeek Harness:一切皆插件的 Agent 底座
  • 集页:https://xueai-podcast.pages.dev/t/dsh11/
  • 取材课节:沙箱:从 seatbelt 到执行世界(1 节,素材 5319 字)
  • 来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)
本页文字稿为音频的配套解读,内容取自小山学堂课程素材,属二次演绎版本。
行号与源码核对日期为 2026-08-13,随版本演进可能变化。

一、本集要解决什么

这一集只讲一件事——沙箱的边界应该画在哪一层。

不是"沙箱怎么配置",而是"用哪一层机制去拦"。大部分人对沙箱的理解是"给命令套一层限制",这在只跑本机命令时够用;一旦要跑进程内工具、要换容器、要把执行整体搬到远端,它就不够用了。

要限制的对象隔离机制生效位置
文件路径策略 / 内核限制进程内或子进程
进程限制器(runner)包装子进程启动时
网络沙箱策略或环境隔离视实现而定
整个执行环境容器 / microVM / 远程环境本身

自检判据:判断一个隔离手段落在哪一层,只看一件事——它拦住的动作发生在哪个位置。位置不同,能拦住的攻击面就不同。


二、能力地图 · 三层沙箱

层适用机制关键实现
第一层 共内核子进程要 spawn 的命令给 argv 包一层限制 runnerctx.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 却被管理员默认值按回只读——这类"谁也解释不清当前为什么是这个状态"的问题,根源往往就是优先级链排反了。


四、Landlock 后端 · 为什么自己写

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。所以光看退出码永远定不了性,必须同时看到致命诊断行——这正是需要两套分类器的原因。


五、拒绝 ≠ runner 故障

confine() 返回的包装 argv 附带两套正交的 stderr 分类器(packages/sandbox/sandbox/src/index.ts 第 95–116 行):

顺序分类器匹配到意味着上报方式
1剔除无害通知行进度提示 / 弃用警告 / 调试信息忽略
2runnerFailureRulesrunner 自己挂了,命令根本没跑按沙箱基础设施故障上报
3denialSignatures沙箱正常工作,拦下了越界操作按安全拦截上报

顺序不能反。把故障误判成拒绝,你会以为系统好好拦住了一次越界,实际上命令压根没执行——问题被藏起来了。

拒绝词按后端方言匹配

后端拒绝词
bwrapEROFS(只读挂载)
LandlockEACCES
SeatbeltEPERM

消费方只匹配当前后端声明的那几个词,不用跨后端并集——并集会认领某个后端根本不会产生的拒绝,把一次正常的失败误报成沙箱拦截。

分类器的词表要窄而准,不要图省事求全。 宽词表换来的是误报;漏报至少还有机会在别处暴露,误报会让你在错误方向上越走越远还自以为一切正常。

六、两条硬规矩

1. partial 不能当 full 用

强制执行的完整性是后端上报的事实。老 Landlock ABI、Windows ACL 的 Everyone 与硬链接缺口都只能报 partial。需要绝对边界的消费方必须显式处理这个区别,不能装看不见。

2. 没有后端就拒绝跑,绝不裸奔

受限策略下,静默的无隔离透传永远不合法。要求了沙箱但一个后端都不可用时,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。

workspace-write 分支的关键顺序(第 133–147 行)

在包含性检查之前先重新规范化一次路径——专门防"检查时是 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 与可整体替换的执行世界抽象
CodexWindows 上把读与网络也圈进去Windows 这一格各家分得最开:Claude 直接写明没有沙箱,Grok 标 unknown,DSH 的 windows-acl 停在写限制

十、审查清单

  1. 文件、进程、网络、执行环境——这四层边界是否分别用了对应层级的机制?
  2. 沙箱策略是按调用携带的参数,还是焊死在提供方上的全局状态?
  3. 同一进程多会话能否各自持有不同边界而互不干扰?
  4. 故障与拒绝是否分开上报?分类器顺序是否正确(先剔除无害行,再查故障,最后查拒绝)?
  5. 拒绝词表是否按后端方言窄匹配,而非跨后端并集?
  6. partial 支持是否被显式处理,而非当 full 用?
  7. 没有可用后端时,是否报错而非静默降级?错误消息是否写明修复路径?
  8. 进程内检查是否"先规范化再检查,用检查过的目标做变更"?
  9. 可写根目录是否与命令行侧同源?
  10. 可变状态是否收敛到少数插口之后?有没有组件偷偷直接碰 OS?

十一、约束说明

  • 行号口径:基于 2026-08-13 对 deepseek-harness-master 本地仓库核对。
  • 演示口径:课程交互演示中的报错文本与执行世界切换为教学化模拟;分类器与 seam 约定对应真实源码。
  • 证据边界:关于 Grok 是否具备等价的两 seam 抽象、CC 是否具备按调用携带策略的沙箱 seam,均基于已公开证据保留未知项。
  • 本页用途:文字稿仅供阅读,音频以集页播放器为准;题目页内容不进入音频。

十二、实践提示

提示一 · 先分层再动手

包 argv、进程内检查、整体替换环境是三种不同层级的手段。用错层级,再努力也是白费。自检方法:看它拦住的动作发生在哪个位置。

提示二 · 策略跟着调用走

凡是需要"临时宽一点再收回来"的场景,把它做成参数而不是状态,就永远不会忘记收。优先级链按"越具体的意图越优先"排列。

提示三 · 故障与拒绝必须分开上报

把基础设施故障误判成安全拦截是最危险的一类误报——它让问题看起来像是被处理好了。顺序不可颠倒。

提示四 · 缺隔离就报错,绝不静默降级

安全机制的默认值应该是失败,且错误消息要写明怎么修;实在不想修,也得让人把"不设防"写在明面上。

提示五 · 把可变状态收敛到少数插口后面

插口越少越窄,未来可替换性越强。收敛是长期纪律,回报是搬家零改动。


十三、课堂练习(附推导)

场景一:同一 DSH 进程里开两个会话,甲 read-only、乙 workspace-write,两边同时各跑一条写文件的 bash 命令。写出每条命令从 resolve() 到 confine() 各自拿到的策略,说明为什么提供方不需要任何切换状态的动作。

答:甲解析出 { mode: 'read-only', workspaceRoot, sessionId: 甲 },乙解析出 { mode: 'workspace-write', ... }。两次 confine() 各自携带自己的策略参数交给同一提供方——策略是参数不是状态,提供方无状态可切换,因此互不干扰。

场景二:一条被 landlock-run 包装的命令以 125 退出。列出下结论前必须检查的证据。

答:

  1. 先看约定退出码 LAUNCHER_FAILURE_EXIT = 125——但不能仅凭退出码定性;
  2. 先剔除无害通知行(进度提示、弃用警告、调试信息);
  3. 再查 runnerFailureRules 是否命中致命诊断行。

两种判定下工具层应报给模型的内容:

判定上报
runner 故障沙箱基础设施故障——命令根本没跑,需修环境(装 bwrap / 换内核等)
命令自己退 125命令已执行、正常失败,按普通非零退出处理,给出 stderr 供模型调试

十四、Takeaway

  • 沙箱分三层:共内核子进程包 argv、进程内工具做结构化拒绝、整个执行世界按 seam 整体替换;
  • 策略按调用携带,同进程多会话互不干扰;提权只是带更宽策略的新调用,不留状态;
  • 拒绝 ≠ runner 故障:先剔无害行、再查故障、最后查拒绝,顺序不可颠倒;拒绝词按后端方言窄匹配;
  • partial 不能当 full 用;没有后端就抛 SANDBOX_UNAVAILABLE 拒绝运行,绝不裸奔;
  • fs 与 subprocess 共享路径命名空间,换上 E2B 适配器就是换了世界——bash、PTY、LSP 一行不改,Agent 全程无感。

来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)

本内容改编自小山学堂课程素材,为二次演绎版本。