Codex 源码导读:第三部分——沙箱真实执行

第三部分 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 自己实现的沙箱。

flowchart TB A[&#34;批准后的 ExecParams&#34;] --> B[&#34;build_exec_request<br/>选类型 + 拼 argv&#34;] B --> C[&#34;SandboxManager::transform<br/>argv 换成启动器&#34;] C --> D[&#34;sandboxing::execute_env&#34;] D --> E[&#34;execute_exec_request / exec&#34;] E --> F[&#34;spawn_child_async<br/>Command::new + spawn&#34;] F --> G[&#34;consume_output<br/>双管道 + 超时/取消&#34;]

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:

  1. process_exec_tool_call(exec.rs:293)------ 只做两件事:build_exec_request() → sandboxing::execute_env()
  2. build_exec_request(exec.rs:317)------ select_process_exec_tool_sandbox_type() 选沙箱类型,然后 SandboxManager::transform(),把结果装回 ExecRequest
  3. sandboxing::execute_env(sandboxing/mod.rs:210)→ execute_exec_request(exec.rs:403)→ exec()(exec.rs:881)
  4. spawn_child_async(spawn.rs:52)→ Command::new(program).arg0(..).kill_on_drop(true).spawn()(spawn.rs:70 / 72 / 136)
  5. 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:293 process_exec_tool_call;:317 build_exec_request;:122 select_process_exec_tool_sandbox_type;:403 execute_exec_request;:881 exec;:942 consume_output;:985 select 超时/退出
  • codex-rs/core/src/sandboxing/mod.rs:210 execute_env;:120 from_sandbox_exec_request(macOS 上塞 CODEX_SANDBOX=seatbelt、网络禁用标记 :174-183)
  • codex-rs/sandboxing/src/manager.rs:37 SandboxType;:62 get_platform_sandbox;:297 select_initial;:314 should_sandbox;:335 transform;:728 linux_sandbox_arg0_override
  • codex-rs/sandboxing/src/policy_transforms.rs:543 should_require_platform_sandbox
  • codex-rs/sandboxing/src/seatbelt.rs:63 MACOS_PATH_TO_SEATBELT_EXECUTABLE;:851 create_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:52 spawn_child_async;:70/72/136 Command::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。文中的流程图用于标出本篇所处的运行阶段。

相关推荐
美好世界1 小时前
Codex 源码导读:第四部分——上下文压缩与继续执行
架构
美好世界1 小时前
Codex 源码导读:第十部分——Session、Thread 持久化与 Memory
架构
据说幸运很容易1 小时前
TestNG 分组接入现有框架:实现、踩坑与解法
后端·架构
farerboy1 小时前
WEB 项目如何禁用 F12 等功能
前端·vue.js·架构
美好世界1 小时前
Codex 源码导读:第六部分——模型请求与流式网络层
架构
mldong1 小时前
引擎里没有 setStatus:状态迁移收口,不用状态机框架
后端·架构
她的男孩1 小时前
开放接口限流从 20 改到 200 还是每分钟 20 次:拆完防重放+幂等+限流,我找到 5 个静默失效的坑
java·后端·架构
据说幸运很容易1 小时前
接口测试框架重构:YAML 用例驱动 + 分层设计
后端·架构
mldong1 小时前
聚合边界:为什么 ProcessTask 没有自己的 Repository
后端·架构