沙箱到底拦在哪、拦了什么?
本课一问:上一课看到沙箱能拦下 python -c 写伪 HOME。那它到底放行什么、拒绝什么?为什么连工作区里面的 .git/config 也要单独保护?
这一课先不碰:各家在沙箱 / 容器 / 不做之间怎么选(第 3 课);数据经合法命令外传(岔路 B1);SBPL 的规则优先级语义(我们没有一手核实,本课只回放录制、不做求值器)。
从已有出发
沙箱按进程实际发出的系统调用裁决。上一课我们只用到「写伪 HOME 被拒」这一条。现在把它摊开:一个沙箱策略要回答三个问题——哪里能写、能不能联网、哪里不能碰。
先自己设计:只把工作区设为可写
最省事的策略:允许写工作区(和 /tmp),其余全部只读,顺手断网。看起来够了——模型要改的就是工作区里的代码。
- ① 朴素方案
- ② 它在哪失败
- ③ 改进方案
问题出在「工作区」不是铁板一块。工作区里有 .git/:
- 写
.git/config能改remote.origin.url(下次git push推到别处)、能设core.hooksPath(让下次git commit执行任意脚本)。 - 写
.git/hooks/pre-commit直接植入钩子。
「可写工作区」把这些一起放行了。朴素策略下,写 .git/config 没有被拦。
Codex 的做法:从 (deny default) 起步(默认全拒),再把可写根一个个加回来;而且在可写根内部,把 .git、.codex、.agents 用锚定正则重新挖成只读。可写工作区,但工作区里的元数据目录不可写。
echo [t9] >> .git/config,朴素策略下 照样写成了(effect 发生),Codex 策略下 被拦——能写 .git/config 就能改 remote、植入 git hook。 改进:把 Codex 的真策略摊在边界图上
下面这张图切换四种策略,看边界怎么变。默认选的 r03(写 .git/config)在朴素策略下工作区整块可写(.git 也可写),切到 codex-workspace-write 时 .git 变红(受保护)。每条命令的退出码、stderr、触发的策略行都是本机录制。
全部为 T9 录制(2026-09-26 · macOS 26.6.2 · 伪 HOME,不碰真实 ~/.ssh);策略源 codex@8cabf5a:codex-rs/sandboxing/src/seatbelt_base_policy.sbpl。区域可写性由各策略 SBPL 文本推导并经录制结果核对;判定为 denial.rs 的 JS 移植现场算出。
试三件事:① 选 r03,在 naive-workspace-only 与 codex-workspace-write 之间来回切,看 .git 从「可写」变「受保护」、结果从「没拦」变「被拦」;② 选 r12(读伪 HOME 的假私钥):四种策略下它都没被拦——沙箱挡写和网络,不挡读;③ 选 r10/r11(curl):被拦了,但下方「is_likely_sandbox_denied」判为「不是沙箱拒绝」——这是启发式的漏报,curl 只报了 exit 6/7、没有关键词。
边界是怎么拼出来的:三段源码
① 默认全拒,子进程继承。基础策略头 15 行就定了调:
② 可写根内部,把元数据目录挖成只读。这段 Rust 拼出 .git/.codex/.agents 的 require-not:
③ 网络:推断不出端点就返回空策略(fail closed)。不开网络时,网络段直接是空串,由 (deny default) 兜底:
极小真数字:被拦之后,怎么判断是不是沙箱干的?
命令失败了,退出码非 0,可能是沙箱拦的,也可能是命令自己出错。Codex 没有确定性的办法,只做一个保守的启发式 is_likely_sandbox_denied——先在 stderr/stdout/合并输出里查 7 个关键词,命中就判「是」;再对退出码 2/126/127 快速排除;最后在 Linux seccomp 下认 128+SIGSYS。顺序很重要(勘误表 X7):带关键词的 126 也会判「是」。
我们把这段逐行移植成 JS,在上面的边界图里对每条录制现算判定,再和真值(是否真被拦)比。全 14×4 里的成绩:真拦判出 TP 20、没拦没判 TN 13、漏判 FN 6(全是 curl 被拒、报 exit 6/7 无关键词)、误判 FP 3(命令失败且输出里恰好有 sandbox 一词)。它是启发式,不是精确结论。
三方对照(报告里如实写明):本机没有把 Codex 整个 crate 编译起来(它依赖 codex_protocol 的类型),所以对照分三路——① 页面里的 JS 移植;② 本机 rustc 1.97 编译的逐行 Rust 转写(用等价的最小结构体复刻 denial.rs:13-69 的分支顺序与常量);③ 项目 miniagent/denial.py。三者在全部 64 条用例(56 条真录制 + 8 条构造边界)上判定完全一致。
演进地图
- 上一章 只有进程内规则A3 权限规则、Pi 的确认弹窗解决了在执行前按「工具名 + 命令文字」裁决,拦住模型明着提出的危险调用。卡在规则看不见子进程实际做什么:不套沙箱时,
python -m unittest …; python -c "写 ~/.ssh"里权限层按前缀放行(rule_index 8),复合命令里的 python -c 把内容真的写进了伪 HOME(T9 r07)。 - 第一代 朴素 OS 沙箱:只放行工作区allow default 再挖掉写与网络解决了约束进程实际发出的系统调用(含全部子进程):写工作区外、联网被内核拒。14 条命令里拦下 6 条。卡在工作区里面不设防:同一条写
.git/config的命令,朴素策略下照样成功(T9 r03)——能借此改 remote、植入 git hook。 - Codex Seatbelt(seatbelt_base_policy.sbpl,122 行)解决了从
(deny default)起步,可写根用参数注入,.git/.codex/.agents用锚定正则单独保护,默认断网。workspace-write 下拦到 9 / 14 条。卡在沙箱挡写和联网,不挡读:读伪 HOME 里的假私钥,在四种策略下全部成功(T9 r12)——读到的东西还能经合法命令传出去。 - 第三代 分层:沙箱 + 审批 + 容器Claude Code(OS 沙箱 + 审批)、claude.ai(gVisor 容器)、Cowork(完整 VM)解决了把边界移出进程,按「爆炸半径」分档;规则管「允许调用什么」,隔离管「调用之后能碰到什么」。卡在私有数据 + 不可信内容 + 外发能力三者同在时,数据可经已放行的域名外传——规则与沙箱都放行(lethal trifecta,→ 岔路 B1)。
- 并行选择 干脆不做进程内沙箱Pi 核心、bypassPermissions解决了不做「半个沙箱」以免被误认为边界;坦白这是 YOLO,把真正的隔离交给容器 / 微虚拟机。卡在什么都不套时,T9 的 14 条越界命令全部发生(none 策略拦下 0 条);所以 Pi 说:要边界就上容器。
数字口径:全部由本机 sandbox-exec 加载 Codex 真策略录制(T9,2026-09-26 · macOS 26.6.2 · 伪 HOME,不碰真实 ~/.ssh);a04-sandbox-fixtures.json,2026-09-26 生成。
不是。这份基础策略有 122 行,而且是被真实 bug 一条条磨出来的:Python 的 multiprocessing 需要 ipc-posix-sem,PyTorch / libomp 需要共享内存的几条 ipc-posix-shm-*(见下面的 Dig)——少一条,正常的测试就跑不起来。白名单越紧越安全,却越容易误伤真实工作;这份清单就是这两头拉锯的产物。
不能。is_likely_sandbox_denied 只是关键词 + 退出码的启发式,Codex 自己在注释里说「没有确定性的办法」。所以它会漏报(网络被拒无关键词)、会误报(输出里有 sandbox 一词);实践里它只作提示,原始 stderr 一定要原样回灌给模型。
🔍 白名单里那几条「为 Python / PyTorch 开的洞」
基础策略里有专门为科学计算放行的两段:
这两条不是安全需要,是「不放行就跑不了正常代码」逼出来的。它们最能说明「白名单是磨出来的」:没人一开始就想得到要给信号量和共享内存开洞。
🔍 Linux 侧:seccomp 永远拒的三类系统调用 + 「有 deny-read 就不许摘沙箱」
macOS 用 Seatbelt(SBPL),Linux 用 bubblewrap + seccomp 两阶段(Landlock 已降为 legacy)。seccomp 这一层无论网络策略如何,永远拒三类:
另外,Codex 有一条克制:如果策略里存在不可读的路径,就禁止「摘掉沙箱重跑」这条提权路径——因为不套沙箱就没人执行「不许读」了。
证据三问
| 问 | 本课的回答 |
|---|---|
| ① 它解决什么问题? | 给「哪里能写、能不能联网、哪里不能碰」一个进程无法绕过的答案,包括工作区内部的 .git 这类元数据 |
| ② 为什么这样设计? | (deny default) + 白名单,默认最小权限;元数据目录用锚定正则单独保护,连「创建目录」的缝都堵上 证据等级 · 数学可证明 在写明的前提下可以严格推导出来,不依赖实验。只要前提成立,换数据、换规模仍然成立。 本条算法性质:deny-default 下未被任一 allow 覆盖的操作一定被拒;.git 的 require-not 同时排除 literal 与 subpath,可推出创建与写入都不被允许。策略文本是 pin 源码直证。 出处待补 |
| ③ 实践上验证了吗? | 本机录制:workspace-write 下写 .git/config 被拒、读伪 HOME 不被拒、curl 被拒;拒绝判定的 JS 移植与 Rust/Python 三方一致 证据等级 · 实验验证 由对比实验得出的经验结论,只在出处写明的模型、数据和规模下成立。换了条件可能不再成立,引用时要带上实验设置。 本条项目实测 T9:14 命令 × 4 策略,伪 HOME,2026-09-26;启发式在 42 次沙箱运行里 TP20/TN13/FN6/FP3。 出处待补 |
一句话
.git 都要单独保护;它挡写和网络,挡不住读。先停在这里:记住「deny default + 白名单」「元数据单独保护」「挡写和网络、不挡读」「拒绝判定只是启发式」四件事。
再深一层:展开上面两个 Dig,看白名单里为 Python/PyTorch 开的洞、以及 seccomp 永远拒的系统调用。
下一个缺口
沙箱、容器、干脆不做——各家怎么选,代价是什么? → 第 3 课
Codex 把沙箱做到 122 行白名单;可 OpenCode 源码里根本没有 OS 沙箱,Pi 故意不做,Hermes 默认直接在宿主机跑。同一个问题,五家给了完全不同的答案——下一课把它们并排看,重点是各自把隔离责任交给了谁。
你可能会问
- 「read-only 模式和 workspace-write 差在哪?」 read-only 没有任何可写根(边界图里切到
codex-read-only看:连工作区都变只读,写note.txt、写/tmp都被拒)。它用在「只让模型看、不让它动」的场景。 - 「既然挡不住读,私钥不就暴露了?」 是的,这正是沙箱的边界。它防的是「写坏你的系统、往外传」,不是「不让模型看见任何东西」。要防读,得靠 deny-read 路径(那样就不能摘沙箱重跑了),或者干脆别把敏感文件放进它能读到的地方——岔路 B1 会接着讲数据外传。
- 「为什么不在页面上做个 SBPL 求值器,自己算拦不拦?」 因为 SBPL 的规则优先级语义我们没有一手核实(比如多条 require 冲突时谁赢),猜一个求值器可能算错。所以本章只回放本机真跑的结果,不在页面上模拟 SBPL 的语义。