第三部分 A2:沙箱真实执行(批准之后,命令怎么被跑起来)
这一篇回答 :单元 7-11 讲完了「要不要问、问了谁、答了怎么翻译」;可是批准之后,那条 rm -rf 到底是怎么被「关进笼子」跑起来的?答案不是运行时拦截,而是启动前把 argv 换掉。
基线:OpenAI Codex 仓库检出 d58d0e5841(2026-08-31,工作区干净)。行号是快照,复用前先 git log -1 核对。
1. 核心认知:沙箱的物理形态是 argv,不是拦截器
一句话理解:Codex 不改写命令的业务含义,而是在原命令前加上沙箱启动器,并附带文件、网络等权限策略,再启动一个受限子进程。
例如:
text
["python", "build.py"]
→ ["sandbox-exec", "-p", "<策略>", "-D...", "--", "python", "build.py"]
原命令仍然是 python build.py;变化的是启动方式和可访问范围。当前用户身份通常不变,最终权限是"用户本身权限"和"沙箱允许范围"的交集。
Codex 自身不逐个拦截系统调用;真正的运行时访问控制由操作系统沙箱执行。Codex 在 spawn 之前把命令重写成「沙箱启动器 + 原命令」:
- macOS →
/usr/bin/sandbox-exec -p '<整段策略>' -D<目录参数>... -- <原命令> - Linux →
codex-linux-sandbox可执行文件(内部 bwrap / landlock + seccomp) - Windows → Restricted Token / Elevated 两个后端(
windows.rs) SandboxType::None→ 原样启动、不包装(正是「用户批准不用沙箱再试一次」那条路)
在 macOS 上,执行器可以使用系统提供的 sandbox-exec;它是操作系统能力,不是 Codex 自己实现的沙箱。

2. 一次真实的 argv 变形
原始请求:command = ["ls", "-la"],cwd = <workspace>
SandboxManager::transform 之后:
bash
argv[0] = "/usr/bin/sandbox-exec"
argv[1] = "-p"
argv[2] = "<策略全文>"
argv[3..n-1] = "-Dfile_read_0=/path" ... # 每个目录一条 -D,策略里用 (param "...") 引用
argv[n] = "--" # ★ 原命令从这里开始,原样不动
argv[n+1..] = "ls" "-la"
策略正文由几段模板拼成(seatbelt.rs:992-1019):base → 读 → 写 → 网络 → 平台默认 → 拒读 glob → 保护祖先的 deny。第一行是 (deny default) (seatbelt_base_policy.sbpl:8)------ 白名单模型:默认全拒,再逐条开。
3. 关键函数:SandboxManager::transform
codex-rs/sandboxing/src/manager.rs:335
rust
// 输入:SandboxCommand{program,args,cwd,env} + PermissionProfile + SandboxType
let mut argv = [program] + args; // :365-367
let (argv, arg0_override, pending) = match sandbox {
None => (argv, None, None), // :370 不包装,直接跑
MacosSeatbelt => { // :372
let pending = prepare_paths()?; // 校验 cwd、算可读/可写根
let (fs_policy, net_policy) = pending.profile.to_runtime_permissions();
let args = create_seatbelt_command_args_with_profile(...)?; // :382 → seatbelt.rs:851
([MACOS_PATH_TO_SEATBELT_EXECUTABLE] + args, None, Some(pending)) // :404-407 ★换 argv
}
LinuxSeccomp => { // :411
let exe = codex_linux_sandbox_exe.ok_or(MissingLinuxSandboxExecutable)?; // :413 缺则报错
let args = create_linux_sandbox_command_args_for_permission_profile(...);
([exe] + args, Some(linux_sandbox_arg0_override(exe)), Some(pending)) // :433-440
}
WindowsRestrictedToken => ..., // :443 走单独后端
};
Ok(SandboxExecRequest { command: argv, .. }) // :473 新的 argv 交给执行层
回到 core/src/exec.rs,一串薄壳函数把「命令」送到 OS:
process_exec_tool_call(exec.rs:293)------ 只做两件事:build_exec_request()→sandboxing::execute_env()build_exec_request(exec.rs:317)------select_process_exec_tool_sandbox_type()选沙箱类型,然后SandboxManager::transform(),把结果装回ExecRequestsandboxing::execute_env(sandboxing/mod.rs:210)→execute_exec_request(exec.rs:403)→exec()(exec.rs:881)spawn_child_async(spawn.rs:52)→Command::new(program).arg0(..).kill_on_drop(true).spawn()(spawn.rs:70 / 72 / 136)consume_output(exec.rs:942)------ 开两个 task 并行读 stdout/stderr,tokio::select!等「进程退出」或「超时/取消」(exec.rs:985)
4. 三个设计点
① 上不上沙箱,是「权限档案」算出来的,不是审批决定的 select_initial(:297)→ should_require_platform_sandbox(policy_transforms.rs:543)只看三件事:有托管网络需求 → 上;网络被禁且不是外部沙箱 → 上;文件系统受限且没有全盘写权限 → 上;文件系统不受限 / 外部沙箱 → 不上。审批链决定的是「问不问人」,不是「要不要隔离」。
② 没有「沙箱不可用就悄悄脱沙箱跑」这条路 非 macOS 平台拿到 MacosSeatbelt → Err(SeatbeltUnavailable)(:410);Linux 缺 codex_linux_sandbox_exe → Err(MissingLinuxSandboxExecutable)(:414);Windows 托管网络 + 非 Elevated → 直接报错(:446)。失败是显式错误,不降级。
③ 能改的只有「怎么启动」,不能改「跑什么」 策略是从 PermissionProfile 编译出来的(读写根 → (allow file-read* ...) / (allow file-write* ...) + -D 目录参数),原命令原样落在 -- 之后。唯一被换掉的首参数是 Linux 上的 arg0(manager.rs:728,换成 codex-linux-sandbox)。
5. 与端侧的对照
- 端侧的「隔离」往往也是一层 wrapper,但要分开两件事:策略编译 (谁可读谁可写,产品逻辑)与启动改写(平台能力)。混在一起就会出现「策略改了但没生效」。
(deny default)的默认全拒 + 逐条开,比「默认允许 + 黑名单」安全:漏配的后果是「不可用」,而不是「失控」。- 「不可用即报错、不静默降级」值得照抄。端侧最常见的事故恰恰是隔离组件加载失败 → 静默 fallback 到无隔离继续跑。
6. 源码证据清单
codex-rs/core/src/exec.rs:293process_exec_tool_call;:317build_exec_request;:122select_process_exec_tool_sandbox_type;:403execute_exec_request;:881exec;:942consume_output;:985select 超时/退出codex-rs/core/src/sandboxing/mod.rs:210execute_env;:120from_sandbox_exec_request(macOS 上塞CODEX_SANDBOX=seatbelt、网络禁用标记:174-183)codex-rs/sandboxing/src/manager.rs:37SandboxType;:62get_platform_sandbox;:297select_initial;:314should_sandbox;:335transform;:728linux_sandbox_arg0_overridecodex-rs/sandboxing/src/policy_transforms.rs:543should_require_platform_sandboxcodex-rs/sandboxing/src/seatbelt.rs:63MACOS_PATH_TO_SEATBELT_EXECUTABLE;:851create_seatbelt_command_args_with_profile;:992-1019策略分段拼装;:1029-1038["-p", policy, "-D...", "--", cmd...]codex-rs/sandboxing/src/seatbelt_base_policy.sbpl:8(deny default)codex-rs/core/src/spawn.rs:52spawn_child_async;:70/72/136Command::new/arg0/kill_on_drop(true).spawn()
7. 输出回流:命令结果如何回到 Turn
这一步是 A2 的收口点。它不负责决定"要不要再问模型",只负责把子进程的运行结果整理出来。
text
子进程启动
↓
分别读取 stdout / stderr
├─ 有实时订阅:按块发布输出事件,供界面显示
└─ 同时保留有限长度的输出,避免大日志撑爆内存和上下文
↓
正常退出 / 超时 / 用户取消
↓
RawExecToolCallOutput
= exit_status + stdout + stderr + aggregated_output + timed_out
↓
工具层包装为 tool result,写入当前 Turn
↓
下一轮模型把它当作工具结果继续判断
代码对应 core/src/exec.rs:
read_output持续读取管道;每次读到一块数据,可发送ExecCommandOutputDelta,同时按上限保留内容。consume_output用tokio::select!等待三类结果:进程正常结束、到达超时时间、收到取消/中断。- 超时或取消时,先终止进程组;必要时再强制 kill,避免子进程脱离父进程继续运行。
- 最后组装
RawExecToolCallOutput。因此超时、取消也会有明确的退出状态,不会被伪装成正常成功。
这里有一个容易混淆的点:实时输出事件 和最终工具结果是两条用途不同的路径。前者用于观察过程,后者才是写入 Turn、供模型下一轮读取的完整结果(且可能已被截断)。
8. A2 与主循环的边界
text
A2 沙箱执行层:启动受限进程 → 收集输出 → 返回执行结果
↓
Turn / Agent Loop:写入工具结果 → 决定继续调用、压缩上下文或结束
所以,沙箱层到 RawExecToolCallOutput 就结束;"是否重试、是否继续下一次模型调用"属于上层 Turn/Loop,不在 consume_output 里做。
9. 尚未展开
- 沙箱拒绝后的升级重试落地链(包括
SandboxType::None重跑) - Linux 侧
landlock.rs/bwrap.rs细节(当前环境为 macOS)
源码基线:OpenAI Codex
d58d0e5841e0de08e251673db2d5af8cf3a1ad51。文中的流程图用于标出本篇所处的运行阶段。