本文讲 区域 6 · 安全内核 的第三环------沙箱:权限门决定「该不该放行」,沙箱决定「放行之后,它在哪个牢房里跑」。
0. 先建立心智模型
权限放行了,然后呢?
06 篇讲的是「该不该放行」------模型要跑 exec,权限引擎说 Allow 或你点了 Approve。但「允许执行」和「随便执行」之间还差着一层:允许它跑 rm -rf /,不代表它真的能碰到 /。
这就是沙箱要回答的问题。它的哲学和权限一脉相承,只是换了个对象:
- 权限是门禁(谁可以进);
- 沙箱是牢房(进去之后,手能伸到哪)。
Grodex 对「放行之后」的答案是 macOS Seatbelt 内核级沙箱 ------不是用户态 chroot 那种能被绕过的玩具,而是把 deny 规则交给内核,变成真正的 EPERM。
一道底线贯穿始终
和 06 一样,沙箱的灵魂也是一句:不静默裸跑 。后端缺失、平台不支持、profile 表达不了、权限上限为 0------任何一个环节不满足,一律 Refused(拒绝执行),绝不让命令在没有牢房的情况下跑出去。
1. 全景:五种后端,只有一种是内核强制
grodex-sandbox-types 里声明了五种沙箱后端:
| 后端 | 真实程度 |
|---|---|
Seatbelt(macOS) |
有真实内核强制,本文的主角 |
Landlock(Linux) |
只有 generate_landlock_rules 生成规则,没接线、没依赖 |
Bubblewrap / Docker |
纯类型占位,没实现 |
这里要诚实:grodex 的沙箱目前只在 macOS 上真正生效。Linux 的 Landlock 是「设计到位、等待接线」,Bubblewrap/Docker 只是枚举占位。这不是缺陷,而是「先把最硬的一条路走通」------Seatbelt 是 macOS 原生提供、零额外依赖、内核强制的方案,选它做第一块拼图最合理。
2. Seatbelt:默认全 deny 的白名单
Seatbelt 的 profile 是一个 Lisp 风格的规则文件。grodex 生成 profile 的第一行永远是:
lisp
(version 1)
(deny default)
默认全拒绝,然后逐条放行------这是沙箱和权限共用的一套思维:不是「列出危险的东西挡住」,而是「默认关死,只开白名单」。因为「危险」是列不完的,「安全」才是有限的。
白名单怎么放(platform.rs):
lisp
read_only_paths → (allow file-read* (subpath "/workspace"))
read_write_paths → (allow file-read* file-write* (subpath "/workspace"))
deny_paths → (deny file-read* file-write* (subpath "/etc/passwd")) ; 显式 deny 覆盖父级 allow
allow_exec → (allow process-exec)
allow_fork → (allow process-fork)
network → (allow network*) / (deny network*)
「编译」这个词用在这里其实是写临时文件 :把 profile 写到 {tmp}/grodex-sandbox-{pid}.sb,然后执行:
bash
sandbox-exec -f <tmpfile.sb> -- <program> [argv...]
有个值得记一笔的坑:早期实现用 -p - 从 stdin 喂 profile,但当前 macOS 的 sandbox-exec 拒绝 - 作为 profile 来源,所以改成了 -f <file>。这种「和平台 API 的隐性约定搏斗」的细节,正是做沙箱最磨人的地方。
3. 注入防护:一个字符都不能含
Seatbelt 的 profile 语言没有转义机制 。这意味着如果路径里含 " 或 \,你没法「安全地」把它塞进 profile 字符串------任何转义尝试都是语义不明的。
grodex 的处理是直接拒绝 (seatbelt_safe_path):
rust
// 路径含 `"`、`\` 或控制字符 → 返回 None → ProfileUnrepresentable
宁可让这次操作失败(用户换条路),也绝不出带语义不明的规则------因为一旦 profile 被注入,攻击者就能在牢房里越狱。这是 fail-closed 原则在「字符串安全」这个最不起眼、却最致命的地方的体现。
4. fail-closed 的五道防线
「绝不裸跑」不是一句话,而是五处独立的兜底:
| 场景 | 位置 | 行为 |
|---|---|---|
| 平台不支持(非 macOS) | platform.rs |
Err(Unsupported) |
sandbox-exec 不存在 |
platform.rs |
Err(BackendMissing) |
| profile 含不安全路径 | platform.rs |
Err(ProfileUnrepresentable) |
| 上述任一错误 | runtime.rs::run |
统一映射 Refused |
| 权限上限为 0 | 外部 supervisor 二进制 | 无条件 Refused |
最后一条最关键:外部 supervisor 是一个独立进程 ,它读请求 JSON、跑 sandbox-exec、写回响应。它在入口处无条件拒绝 authority_ceiling == 0------意思是「子代理没继承权限上限,就不许跑」。这条防线跑在独立进程里,隔离了 Agent 的地址空间和它要执行的命令,就算 Agent 本身被攻破,也污染不到牢房。
5. 权限 vs 沙箱:两道门的协同
在 turn_coordinator 的工具执行点,两道门的顺序是固定的(execute_single_tool):
markdown
1. Delegation envelope 检查 ------ 父代理的硬上限(见 08 篇)
2. 权限检查 + PermissionLease ------ 「该不该放行」
3. invariant #5 断言 ------ 权限没放行,绝不调用运行时
4. 沙箱路径校验 ------ 「放行后能碰哪些路径」
5. 真正执行
lease 决定调用是否允许;沙箱路径校验决定允许后能碰哪些路径。 两者都通过,才走到真正的 runtime.execute()。
一个值得注意的细节:第 4 步的沙箱校验是用户态 的路径检查(PathValidator,防 ../ 和符号链接逃逸,对原始/词法归一/canonicalize 三种形态都做前缀匹配)。它挡的是「明显越界」;而真正硬的内核强制,发生在 exec 工具内部执行时------那是第二层(见 §7)。
6. 七层 profile:一次执行,七份规则的求交
沙箱最独特的设计,是 profile 不是一份写死的规则,而是七个来源求交的结果:
scss
PolicyCeiling(最严) → UserBinding → Capability → Tool → Supervisor → OS → Default(最宽)

求交算法(intersect_profiles)是这套机制的灵魂:
- 只读路径 / deny 路径:并集------任何一层说不许读,就不许读;
- 读写路径:交集 ------所有层都允许写,才允许写(
/视为全集); - 网络:交集 ------
DenyAll吸收一切,仅同名Allow(host)保留; - allow_exec / allow_fork:&&------任一层禁止,就禁止。
关键的一条 fail-closed:读写交集为空时,自动把 / 追加进 deny_paths------「没有任何地方被允许写」不能被理解成「哪里都能写」,而是「哪里都不能写」。
要诚实说明现状:这套七层求交的类型和算法已完备 ,但当前 CLI 接线只用了最浅的两层(default + user_binding,即 AccessLevel::Level2)。七层全量注入(policy_ceiling / capability / tool / supervisor)是设计预留,还没从配置全接进来。这是又一个「设计就绪、等待接线」的诚实现状。
7. exec 的两层沙箱
exec 是最危险的工具,所以它享受的是双层沙箱:
- coordinator 里的
validate_exec:用户态检查(§5 的第 4 步),挡明显越界; - ExecTool 里的内核
sandbox-exec:真正执行时,把 effective profile 编译成.sb文件,套在命令外面。
第二层是怎么跑起来的(exec.rs):构造 PreparedOperation { program: "sh", argv: ["-c", command] } → spawn_blocking 里调用 client.run_dispatched()(因为 sandbox-exec 是阻塞式 std::process::Command)→ 结果三态分派:
Completed→ stdout/stderr 各截断到上限(默认 100KB),提取退出码;Refused→ 工具报错「sandbox refused exec」,绝不裸跑;- 超时 → 标记
timed_out。
一个真实的测试 seatbelt_actually_denies_blocked_read 证明了这不是字符串层面的安慰:用「allow default + 单条 deny」的 profile 实测 cat 被拒路径退出码非 0------EPERM 是内核给的,不是 grodex 自己吓唬模型。
写在最后
grodex 围绕它垒起的态度:
- 默认全 deny,白名单式放行;
- 一个字符不安全就整体拒绝;
- 五道防线,每一道都指向同一个结局------
Refused; - 七层求交,「没地方允许写」绝不变成「哪里都能写」;
- 最后用一条内核级测试证明:不是装样子,是真的
EPERM。
对任何一个「让 AI 触碰真实世界」的系统,权限决定它能不能 动手,沙箱决定它动手后碰得到什么。