从0打造编码Agent 阅读 + 实验 ≈ 35 分钟 页内实验 ×1
A4 沙箱与隔离:规则拦不住一个自己写文件的 Python 脚本,谁拦? 第 2 课

沙箱到底拦在哪、拦了什么?

拦在哪拦了什么
只把工作区设为可写还不够:工作区里的 .git/config 照样能写,能改 remote、植入 hook。Codex 的真策略从 deny default 起步,可写根用参数注入,.git/.codex/.agents 用锚定正则单独挖出来,默认断网。122 行白名单是被真实 bug 一条条磨出来的。沙箱挡写和网络,挡不住读;被拦后是不是沙箱干的,只能靠关键词 + 退出码启发式猜。
❓

本课一问:上一课看到沙箱能拦下 python -c 写伪 HOME。那它到底放行什么、拒绝什么?为什么连工作区里面的 .git/config 也要单独保护?

这一课先不碰:各家在沙箱 / 容器 / 不做之间怎么选(第 3 课);数据经合法命令外传(岔路 B1);SBPL 的规则优先级语义(我们没有一手核实,本课只回放录制、不做求值器)。

从已有出发

沙箱按进程实际发出的系统调用裁决。上一课我们只用到「写伪 HOME 被拒」这一条。现在把它摊开:一个沙箱策略要回答三个问题——哪里能写、能不能联网、哪里不能碰。

从已有出发 · 累计数据流 点已学环节看它回答什么
每一轮循环都经过

先自己设计:只把工作区设为可写

最省事的策略:允许写工作区(和 /tmp),其余全部只读,顺手断网。看起来够了——模型要改的就是工作区里的代码。

  1. ① 朴素方案
  2. ② 它在哪失败
  3. ③ 改进方案
朴素方案只把工作区设为可写,其余只读 + 断网

问题出在「工作区」不是铁板一块。工作区里有 .git/:

  • 写 .git/config 能改 remote.origin.url(下次 git push 推到别处)、能设 core.hooksPath(让下次 git commit 执行任意脚本)。
  • 写 .git/hooks/pre-commit 直接植入钩子。

「可写工作区」把这些一起放行了。朴素策略下,写 .git/config 没有被拦。

改进:把 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 行就定了调:

seatbelt_base_policy.sbpl codex@8cabf5a:codex-rs/sandboxing/src/seatbelt_base_policy.sbpl:7-21需联网 Apache-2.0
7; start with closed-by-default
8(deny default)
9
10; child processes inherit the policy of their parent
11(allow process-exec)
12(allow process-fork)
13(allow signal (target same-sandbox))
14
15; process-info
16(allow process-info* (target same-sandbox))
17
18(allow file-write-data
19 (require-all
20 (path "/dev/null")
21 (vnode-type CHARACTER-DEVICE)))
👆 带下划线的行可以点:点哪行,讲哪行。

② 可写根内部,把元数据目录挖成只读。这段 Rust 拼出 .git/.codex/.agents 的 require-not:

seatbelt.rs codex@8cabf5a:codex-rs/sandboxing/src/seatbelt.rs:373-407需联网 Apache-2.0
373 let mut require_parts = vec![format!("(subpath (param \"{root_param}\"))")];
374 for (excluded_index, excluded_subpath) in
375 access_root.excluded_subpaths.into_iter().enumerate()
376 {
377 let excluded_subpath =
378 normalize_path_for_sandbox(excluded_subpath.as_path()).unwrap_or(excluded_subpath);
379 let excluded_param = format!("{param_prefix}_{index}_EXCLUDED_{excluded_index}");
380 params.push((excluded_param.clone(), excluded_subpath.into_path_buf()));
381 // Exclude both the exact protected path and anything beneath it.
382 // `subpath` alone leaves a gap for first-time creation of the
383 // protected directory itself, such as `mkdir .codex`.
384 require_parts.push(format!(
385 "(require-not (literal (param \"{excluded_param}\")))"
386 ));
387 require_parts.push(format!(
388 "(require-not (subpath (param \"{excluded_param}\")))"
389 ));
390 }
391 for metadata_name in access_root.protected_metadata_names {
392 let regex =
393 seatbelt_protected_metadata_name_regex(&root, &metadata_name).replace('"', "\\\"");
394 require_parts.push(format!(r#"(require-not (regex #"{regex}"))"#));
395 }
396 policy_components.push(format!("(require-all {} )", require_parts.join(" ")));
397 }
398
399 if policy_components.is_empty() {
400 (String::new(), Vec::new())
401 } else {
402 (
403 format!("(allow {action}\n{}\n)", policy_components.join(" ")),
404 params,
405 )
406 }
407}
👆 带下划线的行可以点:点哪行,讲哪行。

③ 网络:推断不出端点就返回空策略(fail closed)。不开网络时,网络段直接是空串,由 (deny default) 兜底:

seatbelt.rs codex@8cabf5a:codex-rs/sandboxing/src/seatbelt.rs:312-335需联网 Apache-2.0
312 if proxy.has_proxy_config {
313 // Proxy configuration is present but we could not infer any valid loopback endpoints.
314 // Fail closed to avoid silently widening network access in proxy-enforced sessions.
315 return String::new();
316 }
317
318 if enforce_managed_network {
319 // Managed network requirements are active but no usable proxy endpoints
320 // are available. Fail closed for network access.
321 return String::new();
322 }
323
324 if network_policy.is_enabled() {
325 // No proxy env is configured: retain the existing full-network behavior.
326 let mut policy = String::from("(allow network-outbound)\n(allow network-inbound)\n");
327 let unix_socket_policy = unix_socket_policy(proxy);
328 if !unix_socket_policy.is_empty() {
329 policy.push_str("; allow unix domain sockets for local IPC\n");
330 policy.push_str(&unix_socket_policy);
331 }
332 format!("{policy}{MACOS_SEATBELT_NETWORK_POLICY}")
333 } else {
334 String::new()
335 }
👆 带下划线的行可以点:点哪行,讲哪行。

极小真数字:被拦之后,怎么判断是不是沙箱干的?

命令失败了,退出码非 0,可能是沙箱拦的,也可能是命令自己出错。Codex 没有确定性的办法,只做一个保守的启发式 is_likely_sandbox_denied——先在 stderr/stdout/合并输出里查 7 个关键词,命中就判「是」;再对退出码 2/126/127 快速排除;最后在 Linux seccomp 下认 128+SIGSYS。顺序很重要(勘误表 X7):带关键词的 126 也会判「是」。

denial.rs codex@8cabf5a:codex-rs/sandboxing/src/denial.rs:13-69需联网 Apache-2.0 最新版已变化 · 勘误表 X7
13pub fn is_likely_sandbox_denied(
14 sandbox_type: SandboxType,
15 exec_output: &ExecToolCallOutput,
16) -> bool {
17 if sandbox_type == SandboxType::None || exec_output.exit_code == 0 {
18 return false;
19 }
20
21 // Quick rejects: well-known non-sandbox shell exit codes
22 // 2: misuse of shell builtins
23 // 126: permission denied
24 // 127: command not found
25 const SANDBOX_DENIED_KEYWORDS: [&str; 7] = [
26 "operation not permitted",
27 "permission denied",
28 "read-only file system",
29 "seccomp",
30 "sandbox",
31 "landlock",
32 "failed to write file",
33 ];
34
35 let has_sandbox_keyword = [
36 &exec_output.stderr.text,
37 &exec_output.stdout.text,
38 &exec_output.aggregated_output.text,
39 ]
40 .into_iter()
41 .any(|section| {
42 let lower = section.to_lowercase();
43 SANDBOX_DENIED_KEYWORDS
44 .iter()
45 .any(|needle| lower.contains(needle))
46 });
47
48 if has_sandbox_keyword {
49 return true;
50 }
51
52 const QUICK_REJECT_EXIT_CODES: [i32; 3] = [2, 126, 127];
53 if QUICK_REJECT_EXIT_CODES.contains(&exec_output.exit_code) {
54 return false;
55 }
56
57 #[cfg(unix)]
58 {
59 const EXIT_CODE_SIGNAL_BASE: i32 = 128;
60 const SIGSYS_CODE: i32 = libc::SIGSYS;
61 if sandbox_type == SandboxType::LinuxSeccomp
62 && exec_output.exit_code == EXIT_CODE_SIGNAL_BASE + SIGSYS_CODE
63 {
64 return true;
65 }
66 }
67
68 false
69}
👆 带下划线的行可以点:点哪行,讲哪行。

我们把这段逐行移植成 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 条构造边界)上判定完全一致。

演进地图

隔离设计的演进:规则拦不住的,交给一层比一层硬的边界
  1. 上一章 只有进程内规则
    A3 权限规则、Pi 的确认弹窗
    解决了在执行前按「工具名 + 命令文字」裁决,拦住模型明着提出的危险调用。
    卡在规则看不见子进程实际做什么:不套沙箱时,python -m unittest …; python -c "写 ~/.ssh" 里权限层按前缀放行(rule_index 8),复合命令里的 python -c 把内容真的写进了伪 HOME(T9 r07)。
  2. allow default 再挖掉写与网络
    解决了约束进程实际发出的系统调用(含全部子进程):写工作区外、联网被内核拒。14 条命令里拦下 6 条。
    卡在工作区里面不设防:同一条写 .git/config 的命令,朴素策略下照样成功(T9 r03)——能借此改 remote、植入 git hook。
  3. 第二代 deny default + 白名单 你在这里
    Codex Seatbelt(seatbelt_base_policy.sbpl,122 行)
    解决了从 (deny default) 起步,可写根用参数注入,.git / .codex / .agents 用锚定正则单独保护,默认断网。workspace-write 下拦到 9 / 14 条。
    卡在沙箱挡写和联网,不挡读:读伪 HOME 里的假私钥,在四种策略下全部成功(T9 r12)——读到的东西还能经合法命令传出去。
  4. Claude Code(OS 沙箱 + 审批)、claude.ai(gVisor 容器)、Cowork(完整 VM)
    解决了把边界移出进程,按「爆炸半径」分档;规则管「允许调用什么」,隔离管「调用之后能碰到什么」。
    卡在私有数据 + 不可信内容 + 外发能力三者同在时,数据可经已放行的域名外传——规则与沙箱都放行(lethal trifecta,→ 岔路 B1)。
  5. 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 开的洞」

基础策略里有专门为科学计算放行的两段:

seatbelt_base_policy.sbpl codex@8cabf5a:codex-rs/sandboxing/src/seatbelt_base_policy.sbpl:92-99需联网 Apache-2.0
92; Needed for python multiprocessing on MacOS for the SemLock
93(allow ipc-posix-sem)
94
95; Needed for PyTorch/libomp on macOS to register OpenMP runtimes.
96(allow ipc-posix-shm-read-data
97 ipc-posix-shm-write-create
98 ipc-posix-shm-write-unlink
99 (ipc-posix-name-regex #"^/__KMP_REGISTERED_LIB_[0-9]+$"))
👆 带下划线的行可以点:点哪行,讲哪行。

这两条不是安全需要,是「不放行就跑不了正常代码」逼出来的。它们最能说明「白名单是磨出来的」:没人一开始就想得到要给信号量和共享内存开洞。

🔍 Linux 侧:seccomp 永远拒的三类系统调用 + 「有 deny-read 就不许摘沙箱」

macOS 用 Seatbelt(SBPL),Linux 用 bubblewrap + seccomp 两阶段(Landlock 已降为 legacy)。seccomp 这一层无论网络策略如何,永远拒三类:

landlock.rs codex@8cabf5a:codex-rs/linux-sandbox/src/landlock.rs:176-184需联网 Apache-2.0
176 // Build rule map.
177 let mut rules: BTreeMap<i64, Vec<SeccompRule>> = BTreeMap::new();
178
179 deny_syscall(&mut rules, libc::SYS_ptrace);
180 deny_syscall(&mut rules, libc::SYS_process_vm_readv);
181 deny_syscall(&mut rules, libc::SYS_process_vm_writev);
182 deny_syscall(&mut rules, libc::SYS_io_uring_setup);
183 deny_syscall(&mut rules, libc::SYS_io_uring_enter);
184 deny_syscall(&mut rules, libc::SYS_io_uring_register);
👆 带下划线的行可以点:点哪行,讲哪行。

另外,Codex 有一条克制:如果策略里存在不可读的路径,就禁止「摘掉沙箱重跑」这条提权路径——因为不套沙箱就没人执行「不许读」了。

sandboxing.rs codex@8cabf5a:codex-rs/core/src/tools/sandboxing.rs:269-279需联网 Apache-2.0
269/// Returns true when the active filesystem policy can be represented by
270/// running without a filesystem sandbox.
271///
272/// Denied reads only exist inside the sandbox. If a policy contains any
273/// denied-read paths, bypassing the sandbox would silently grant those reads,
274/// so escalation must keep the command sandboxed with the denied reads intact.
275pub(crate) fn unsandboxed_execution_allowed(
276 file_system_sandbox_policy: &FileSystemSandboxPolicy,
277) -> bool {
278 !file_system_sandbox_policy.has_denied_read_restrictions()
279}
👆 带下划线的行可以点:点哪行,讲哪行。

证据三问

问本课的回答
① 它解决什么问题?给「哪里能写、能不能联网、哪里不能碰」一个进程无法绕过的答案,包括工作区内部的 .git 这类元数据
② 为什么这样设计?(deny default) + 白名单,默认最小权限;元数据目录用锚定正则单独保护,连「创建目录」的缝都堵上
③ 实践上验证了吗?本机录制:workspace-write 下写 .git/config 被拒、读伪 HOME 不被拒、curl 被拒;拒绝判定的 JS 移植与 Rust/Python 三方一致

一句话

📌
好沙箱是「默认全拒 + 一份被真实 bug 磨出来的白名单」,连工作区里的 .git 都要单独保护;它挡写和网络,挡不住读。
🚦

先停在这里:记住「deny default + 白名单」「元数据单独保护」「挡写和网络、不挡读」「拒绝判定只是启发式」四件事。
再深一层:展开上面两个 Dig,看白名单里为 Python/PyTorch 开的洞、以及 seccomp 永远拒的系统调用。

下一个缺口

沙箱、容器、干脆不做——各家怎么选,代价是什么? → 第 3 课

Codex 把沙箱做到 122 行白名单;可 OpenCode 源码里根本没有 OS 沙箱,Pi 故意不做,Hermes 默认直接在宿主机跑。同一个问题,五家给了完全不同的答案——下一课把它们并排看,重点是各自把隔离责任交给了谁。

你可能会问