ARTICLE · 1157066
Codex 源码-Shell 命令执行与进程管理——拦截每一次 execve
Codex 源码解析系列
第 17 讲:Shell 命令执行与进程管理——拦截每一次 execve
基于 OpenAI Codex 源码 · 2026-10-11
💡 本讲一句话:Codex 给 zsh 打了一个小补丁,把 shell 里每一次 execve(2) 都变成一次"必须过审批门"的请求——沙箱内放行(Run)、带 stdio 文件描述符转移的沙箱外提权执行(Escalate)、或直接拒绝(Deny)。读完你会明白:AI agent 能跑任意命令,但每一条命令的生死都被卡在了一个 Unix socket 上。
一、为什么要在 execve 层拦截?
第 16 讲我们看了 Unified Exec 怎么把进程当"资源"管理。但有一个更根本的问题还没回答:模型让 shell 跑一条命令,这条命令在沙箱里执行时,谁来决定它能不能跑、要不要出沙箱?
最直觉的做法是解析命令字符串——Codex 确实有 shell-command/src/parse_command.rs(2766 行)把命令归类成 Read/ListFiles/Search/Unknown。但那是给摘要和审批提示用的,不是安全边界:子 shell、eval、别名、管道里的嵌套命令都能绕过字符串解析。真正所有路径都绕不过去的咽喉点只有一个:execve(2) 系统调用——任何"执行另一个程序"的动作最终都要经过它。
Codex 的选择很激进:不改内核、不用 seccomp,而是直接给 zsh 打补丁。补丁只有十几行 C 代码(shell-escalation/patches/zsh-exec-wrapper.patch),改的是 zsh 的 zexecve():
📄 shell-escalation/patches/zsh-exec-wrapper.patch(Src/exec.c,第 526-534 行附近)
exec_argv = argv; // 默认保持原样:没有 EXEC_WRAPPER 时,zsh 行为和原版完全一致if ((exec_wrapper = getenv("EXEC_WRAPPER")) && // 读 Codex 注入的 EXEC_WRAPPER 环境变量——拦截开关就藏在这里 *exec_wrapper && !inblank(*exec_wrapper)) { // 必须非空且首字符不是空白:防止空字符串/空格把 exec 劫持成"执行空路径" exec_argv = argv - 2; // 指针回退两个槽位(zsh 内部缓冲区预留了空间),腾出位置构造新 argv exec_argv[0] = exec_wrapper; // 新 argv[0] = wrapper 程序路径,即 codex-execve-wrapper exec_argv[1] = orig_pth; // 原本要执行的目标路径,降级成 wrapper 的第一个参数 pth = exec_wrapper; // execve 的目标从"原命令"换成"wrapper"——劫持完成}winch_unblock(); // 恢复 SIGWINCH 处理(与拦截无关的原有逻辑,保持不动)execve(pth, exec_argv, newenvp); // 真正执行:设了 EXEC_WRAPPER 时跑的是 wrapper,否则和原版 zsh 一模一样为什么这样设计:这个补丁的巧妙之处在于"零配置、可关闭、无感"。没有 EXEC_WRAPPER 时 zsh 行为与原版完全一致(exec_argv = argv 直接短路);有它时,shell 里每一次 execve——包括 bash -c "rm -rf /" 这种嵌套、子 shell、别名展开后的最终执行——都会先变成对 wrapper 的调用。字符串解析看到的是"外层命令",而 execve 拦截看到的是"最终要执行的程序",后者才是安全语义上正确的粒度。代价是必须用 Codex 自己构建的打过补丁的 zsh(shell-escalation/README.md 里记录了完整的重编译流程,CI 按 tag 发布二进制)。
| zsh 补丁 + EXEC_WRAPPER(本讲) |
二、升级协议:一个 socketpair + 两个环境变量
wrapper 被 execve 之后,它需要把"我想执行 X"这个请求发给 Codex 主进程里的审批服务。通道是什么?答案是文件描述符继承:Codex 在启动 zsh 之前就创建好一对 Unix socket,把客户端端点的 fd 号写进环境变量,让 wrapper 跨 exec 后还能找到它。
看 shell-escalation/src/unix/escalate_server.rs(第 190-224 行)的 start_session():
📄 shell-escalation/src/unix/escalate_server.rs(第 190-224 行)
pub fn start_session( // 开启一个"升级会话":不启动 shell,只准备好拦截通道 &self, // 服务端的策略/路径配置(EscalateServer::new 注入) parent_cancellation_token: CancellationToken, // 上层取消令牌:用户一取消,服务端任务立刻退出 command_executor: Arc<dyn ShellCommandExecutor>, // core 注入的执行器:真正跑"提权命令"的是它(core/src/tools/runtimes/zsh_fork/unix_escalation.rs)) -> anyhow::Result<EscalationSession> { // 返回会话句柄:env 给 shell,其余字段管理服务端生命周期 let cancellation_token = CancellationToken::new(); // 会话级令牌:单独控制本升级服务的生命周期 let (escalate_server, escalate_client) = AsyncDatagramSocket::pair()?; // 创建一对 Unix 数据报 socket:服务端留一个,客户端端点要跨 exec 传给 wrapper let client_socket = escalate_client.into_inner(); // 取出客户端端点的原始 fd(socket2::Socket) let client_socket_fd = client_socket.as_raw_fd(); // 拿到数字 fd——这个"号码"才是能写进环境变量、跨进程传递的东西 client_socket.set_cloexec(false)?; // 关键一步:清掉 CLOEXEC,否则 execve 时内核会自动关掉它,wrapper 就找不到了(源码注释:只有客户端端点需要跨 exec) let client_socket = Arc::new(Mutex::new(Some(client_socket))); // 持有一份引用:spawn 完成后由生命周期钩子 close_client_socket() 释放父进程侧的 fd let task = tokio::spawn(escalate_task(escalate_server, Arc::clone(&self.policy), Arc::clone(&command_executor), parent_cancellation_token, cancellation_token.clone())); // 启动服务端循环:收握手 → 派发给策略决策(见下一节) let mut env = HashMap::new(); // 组装要注入 shell 环境变量的 overlay env.insert(ESCALATE_SOCKET_ENV_VAR.to_string(), client_socket_fd.to_string()); // CODEX_ESCALATE_SOCKET=fd号:wrapper 靠它找到升级 socket(escalate_protocol.rs 第 13 行定义) env.insert(EXEC_WRAPPER_ENV_VAR.to_string(), self.execve_wrapper.to_string_lossy().to_string()); // EXEC_WRAPPER=wrapper路径:zsh 补丁的 zexecve() 读它决定是否劫持 execve Ok(EscalationSession { env, task, client_socket, cancellation_token }) // 返回会话句柄:env 给 shell,task/socket/token 保证服务端活着且可被取消}为什么这样设计:这里有两个容易忽略的细节。第一,set_cloexec(false)——Unix socket fd 默认带 CLOEXEC 标志,execve 时会被内核自动关闭;不清掉的话 wrapper 拿到的是一个死号码。第二,为什么用"数据报 socket 做握手 + 流式 socket 做会话"两段式?因为一次 shell 会话里会有多次 execve(一条命令链、子进程),每次都要新开一个独立的请求通道:wrapper 每被调用一次就 AsyncSocket::pair() 一对新 socket,把服务端端点通过 SCM_RIGHTS(Unix socket 传 fd)附在握手数据报里发过去——这样并发请求互不干扰,而共享的 datagram 端点只有一个、fd 号固定,wrapper 永远找得到。
| CODEX_ESCALATE_SOCKET | ||
| EXEC_WRAPPER | ||
三、服务端决策循环:从请求到裁决
wrapper 发来的每个请求,都会落到 handle_escalate_session_with_policy()(escalate_server.rs 第 265-382 行)。它的骨架是:收请求 → 问策略 → 按裁决分三路。先看请求接收和 Run 分支:
📄 shell-escalation/src/unix/escalate_server.rs(第 272-300 行,节选)
let EscalateRequest { file, argv, workdir, env } = tokio::select! { // 收 wrapper 的完整请求:目标路径、argv(含 argv[0])、工作目录、环境快照 request = socket.receive::<EscalateRequest>() => request?, // serde 反序列化;失败直接报错,不猜 _ = parent_cancellation_token.cancelled() => return Ok(()), // 等请求期间被取消:安静退出,不留半开连接};let program = AbsolutePathBuf::resolve_path_against_base(file, workdir.as_path()); // 相对路径按 workdir 解析成绝对路径——策略匹配必须用规范化形式(协议注释也强调这一点)let decision = tokio::select! { // 把裁决权交给 core:execpolicy 规则 + Guardian 审批,可能阻塞等用户点确认 decision = policy.determine_action(&program, &argv, &workdir) => decision.context("failed to determine escalation action")?, // 把裁决权交给 core:execpolicy + Guardian,可能阻塞等用户确认(见第五节) _ = parent_cancellation_token.cancelled() => return Ok(()), // 审批等待期间同样可被取消——用户反悔的窗口一直开着};tracing::debug!("decided {decision:?} for {program:?} {argv:?} {workdir:?}"); // 记录裁决:整条链路里唯一能回答"谁放行了它"的地方match decision { // 按裁决分三路(Run/Escalate/Deny)——下面只展示 Run 分支 EscalationDecision::Run => { // 分支一:沙箱内放行 socket.send(EscalateResponse { action: EscalateAction::Run }).await?; // 回一个 Run,服务端到此无事可做——wrapper 会自己 execv 原命令为什么这样设计:注意 policy.determine_action() 是一个 trait 调用(EscalationPolicy),shell-escalation crate 完全不知道裁决逻辑长什么样——它只负责协议和传输,core 注入 CoreShellActionProvider 实现策略。这是典型的"协议层/决策层分离":同一个升级服务器可以服务不同的审批策略(测试里就注入了 DeterministicEscalationPolicy)。另外每个 await 点都挂了取消分支,保证"用户取消"在任何阶段都能立刻生效。
| Run | ||
| Escalate(execution) | ||
| Deny{reason} |
Escalate 分支是整条链路里最精巧的部分——它要解决"沙箱外执行的进程,怎么还能用用户正在看的这套终端":
📄 shell-escalation/src/unix/escalate_server.rs(第 301-371 行,节选)
EscalationDecision::Escalate(execution) => { // 分支二:提权执行——命令要出沙箱,但 stdio 必须保持"同一套终端" socket.send(EscalateResponse { action: EscalateAction::Escalate }).await?; // 先告知 wrapper "我来代跑",wrapper 收到后才会把 stdio fd 发过来(顺序不能反) let (msg, fds) = tokio::select! { // 收 SuperExecMessage + 附带的文件描述符(SCM_RIGHTS 控制消息传 fd) message = socket.receive_with_fds::<SuperExecMessage>() => message.context("failed to receive SuperExecMessage")?, // 收 wrapper 发来的 stdio fd(SCM_RIGHTS)+ 目标号码声明 _ = parent_cancellation_token.cancelled() => return Ok(()), // 等 stdio 期间被取消:直接退出,fd 随连接关闭自动回收 }; if fds.len() != msg.fds.len() { // 协议完整性校验:实际收到的 fd 数必须等于消息里声明的目标号码个数 return Err(anyhow::anyhow!("mismatched number of fds in SuperExecMessage: {} in the message, {} from the control message", msg.fds.len(), fds.len())); // 不一致 = 管道损坏或协议被篡改,宁可报错也不猜着跑 } let PreparedExec { command, cwd, env, arg0 } = tokio::select! { // core 按 execution 模式(Unsandboxed/TurnDefault/Permissions)准备"沙箱外执行环境" prepared = command_executor.prepare_escalated_exec(&program, &argv, &workdir, env, execution) => prepared?, // 可能重写命令、注入网络代理凭据等(见 unix_escalation.rs prepare_escalated_exec) }; let (program, args) = command.split_first().ok_or_else(|| anyhow::anyhow!("prepared escalated command must not be empty"))?; // 拆出程序与参数——prepare 阶段可能已经改写过命令 let mut command = Command::new(program); // tokio 子进程构建器:下面逐行配置"干净环境 + 精确注入" command.args(args).arg0(arg0.unwrap_or_else(|| program.clone())) // arg0 可覆盖:有些程序会检查 argv[0](如 python -m 场景) .env_clear().envs(&env) // 先清空整个环境再注入:Codex 自己的环境变量(含代理凭据、内部 token)一律不泄漏给提权进程 .current_dir(&cwd).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()) // stdio 暂时置空——真正的终端 fd 在下面 pre_exec 里用 dup2 还原 .kill_on_drop(true); // 本任务被 drop 时立刻杀子进程:提权进程绝不能变成孤儿 scrub_non_inheritable_env_vars(command.as_std_mut()); // 双保险:再剥一遍不可继承的敏感变量(protocol/shell_environment.rs) unsafe { // pre_exec 回调在 fork 后、execve 前执行——dup2 必须在这个窗口做 command.pre_exec(move || { // execve 之前、fork 之后的窗口里做 fd 重映射——这是唯一能安全 dup2 的位置 for (dst_fd, src_fd) in msg.fds.iter().zip(&fds) { // dst=wrapper 声明的目标号码(0/1/2),src=刚收到的真实 fd libc::dup2(src_fd.as_raw_fd(), *dst_fd); // dup2:让子进程的 stdin/out/err 恰好是用户正在用的那套终端 } Ok(()) // 全部 dup2 成功:execve 后子进程直接继承这套 fd }); // pre_exec 注册完毕——spawn 的 fork/exec 窗口内自动执行 } let mut child = command.spawn()?; // 真正 spawn——这就是"super-exec":沙箱外、带原终端、环境干净的执行 let exit_status = tokio::select! { // 等它结束,同时保持可取消 status = child.wait() => status?, // 正常路径:等待退出 _ = parent_cancellation_token.cancelled() => { let _ = child.start_kill(); child.wait().await? } // 等待中被取消:先杀再收尸,不留僵尸进程 }; socket.send(SuperExecResult { exit_code: exit_status.code().unwrap_or(127) }).await?; // 把退出码发回 wrapper——被信号杀死时拿不到 code,用 127 兜底(exec 失败惯例码)为什么这样设计:这段代码回答了"提权执行"最难的问题:沙箱外进程如何无缝接入用户的终端。Unix socket 的 SCM_RIGHTS 机制可以把 fd 本身(不只是号码)跨进程传递——wrapper 把自己的 stdin/out/err dup 一份发给服务端,服务端子进程在 pre_exec 里用 dup2 把它们映射回 0/1/2。为什么必须在 pre_exec 而不是 spawn 前做?因为 fd 号码在 fork 后的子进程里才稳定,且 env_clear() + Stdio::null() 先保证"默认什么都没有",再精确注入——这是 fail-closed 的写法:任何一步失败,子进程都拿不到终端、跑不起来,而不是带着半套环境跑起来。
四、wrapper 客户端:一次"透明"的 execv
再看另一端。codex-execve-wrapper 的全部逻辑在 shell-escalation/src/unix/escalate_client.rs(第 36-124 行),入口是 run_shell_escalation_execve_wrapper():
📄 shell-escalation/src/unix/escalate_client.rs(第 36-124 行,节选)
pub async fn run_shell_escalation_execve_wrapper(file: String, argv: Vec<String>) -> anyhow::Result<i32> { // file=被拦截的原命令路径,argv=原始参数(zsh 补丁把两者都传过来了) let handshake_client = get_escalate_client()?; // 读 CODEX_ESCALATE_SOCKET 环境变量拿到继承的 fd,包成数据报 socket(第 19-28 行:fd<0 直接报错) let (server, client) = AsyncSocket::pair()?; // 为本次请求新开一对流式 socket:client 端自己留着收发,server 端要交给 EscalateServer handshake_client.send_with_fds(&[0u8], &[server.into_inner().into()]).await?; // 握手:1 字节数据报 + SCM_RIGHTS 附带 server fd——服务端据此知道"这次会话走哪条通道" let env = std::env::vars().filter(|(k, _)| !matches!(k.as_str(), ESCALATE_SOCKET_ENV_VAR | EXEC_WRAPPER_ENV_VAR)).collect(); // 快照当前环境,但剥掉两个协议变量——否则提权进程会递归触发拦截(无限套娃) client.send(EscalateRequest { file: file.clone().into(), argv: argv.clone(), workdir: AbsolutePathBuf::current_dir()?, env }).await?; // 发完整请求:目标、argv、cwd、环境——服务端裁决需要全部上下文 let message = client.receive::<EscalateResponse>().await?; // 阻塞等裁决(Run/Escalate/Deny) match message.action { // wrapper 侧按裁决分三路(与上表一一对应) EscalateAction::Escalate => { // 分支二:服务端代跑——wrapper 的任务变成"交出终端、等结果" let destination_fds = [io::stdin().as_raw_fd(), io::stdout().as_raw_fd(), io::stderr().as_raw_fd()]; // 记录自己的 stdio fd 号码——告诉服务端"子进程该用这几个号码" let fds_to_send = [duplicate_fd_for_transfer(io::stdin(), "stdin")?, duplicate_fd_for_transfer(io::stdout(), "stdout")?, duplicate_fd_for_transfer(io::stderr(), "stderr")?]; // 每个都 dup 一份再发:所有权交给服务端,自己这份继续用(源码注释:wrapper 必须保留自己的 stdio 直到子进程接管) client.send_with_fds(SuperExecMessage { fds: destination_fds.into_iter().collect() }, &fds_to_send).await?; // 目标号码 + 真实 fd 一起发过去 let SuperExecResult { exit_code } = client.receive::<SuperExecResult>().await?; // 等服务端跑完,取回退出码 Ok(exit_code) // 原样返回:对 shell 来说这就是一条"正常执行、正常退出"的命令——完全透明 } EscalateAction::Run => { // 分支一:沙箱内放行——wrapper 退化成一次纯粹的 execv let file = CString::new(file).context("NUL in file")?; // 转 C 字符串(execv 要求 NUL 结尾);路径里带 NUL 直接报错 let argv_cstrs: Vec<CString> = argv.iter().map(|s| CString::new(s.as_str()).context("NUL in argv")).collect::<Result<Vec<_>, _>>()?; // argv 全部转 C 字符串 let mut argv: Vec<*const libc::c_char> = argv_cstrs.iter().map(|s| s.as_ptr()).collect(); // 取指针数组 argv.push(std::ptr::null()); // execv 约定:argv 以 NULL 结尾 let err = unsafe { libc::execv(file.as_ptr(), argv.as_ptr()); std::io::Error::last_os_error() }; // 直接调 libc::execv——源码注释明说不用 std::process::Command,因为它对信号掩码和 std fd 的 dup2 "做了些奇怪的事",这里要最大透明 Err(err.into()) // 能走到这行说明 execv 失败了(正常不会返回):把 errno 转成错误上报 } EscalateAction::Deny { reason } => { // 分支三:拒绝——wrapper 只负责"体面地失败" match reason { Some(reason) => eprintln!("Execution denied: {reason}"), None => eprintln!("Execution denied") } // 把服务端给的拒绝原因打到 stderr,模型/用户能看到为什么被拒 Ok(1) // 退出码 1:shell 看到一次普通命令失败,错误信息在 stderr 里 } }}为什么这样设计:三个细节值得咂摸。其一,Run 分支用裸 libc::execv:execv 会直接替换当前进程映像——没有 fork、没有中间层,从内核视角看"这条命令就是 shell 自己 exec 的",信号处理、fd 继承全部保持原样。源码注释特意解释了为什么不用 Rust 标准库的 Command::exec():它会对信号掩码和 std fd 做额外处理,破坏"透明性"。其二,环境快照时剥掉两个协议变量——这是防递归的关键:如果 EXEC_WRAPPER 泄漏进提权进程的环境,那个进程再 exec 任何东西都会再次被拦截,形成套娃。其三,Escalate 分支的退出码转发让整条链路对 shell 完全无感:zsh 只看到"我 exec 了一个程序,它退出了,退出码是 N"——审批、提权、fd 转移全部发生在 wrapper 和服务端之间。
五、策略侧:CoreShellActionProvider 的裁决映射
服务端把 determine_action() 委托给 core 的 CoreShellActionProvider(core/src/tools/runtimes/zsh_fork/unix_escalation.rs,第 410-475 行)。它做两件事:跑 execpolicy 规则匹配,再根据"是否需要提权"把裁决映射成 Run/Escalate/Deny:
📄 core/src/tools/runtimes/zsh_fork/unix_escalation.rs(第 410-475 行,节选)
async fn determine_action(&self, program: &AbsolutePathBuf, argv: &[String], workdir: &AbsolutePathBuf) -> anyhow::Result<EscalationDecision> { // EscalationPolicy trait 的实现体:core 注入给 shell-escalation 的"大脑" let evaluation = { // 作用域块:读完策略立刻释放锁——规则支持热更新(amend),不能跨 await 持有旧引用 let policy = self.policy.read().await; // RwLock 读锁:同一会话里多条命令并发裁决时互不阻塞 evaluate_intercepted_exec_policy(&policy, program, argv, InterceptedExecPolicyContext { approval_policy: self.approval_policy, permission_profile: self.permission_profile.clone(), windows_sandbox_level: ..., sandbox_permissions: self.approval_sandbox_permissions, enable_shell_wrapper_parsing: ENABLE_INTERCEPTED_EXEC_POLICY_SHELL_WRAPPER_PARSING }) // execpolicy 匹配:前缀规则优先,未命中走沙箱默认策略兜底(第 490-534 行) }; let decision_driven_by_policy = Self::decision_driven_by_policy(&evaluation.matched_rules, evaluation.decision); // 判断裁决是否来自"显式规则"而非启发式/兜底——这决定后面走哪个审批开关(第 225-230 行) let unsandboxed_allowed = unsandboxed_execution_allowed(&self.permission_profile.file_system_sandbox_policy()); // 当前权限档案是否允许"无沙箱执行"——不允许的话,Escalate 只能退化为 TurnDefault let needs_escalation = match self.sandbox_permissions { // 核心判断:这条命令必须出沙箱吗? SandboxPermissions::UseDefault => unsandboxed_allowed && decision_driven_by_policy, // 默认模式:只有"规则明确放行 + 档案允许无沙箱"才提权——双条件,fail-closed SandboxPermissions::RequireEscalated => unsandboxed_allowed, // 模型显式请求提权(如需要网络):只要档案允许就提权 SandboxPermissions::WithAdditionalPermissions => true, // 已授予附加权限(如网络白名单):必须在带这些权限的环境里跑,必然提权 }; let decision_source = if decision_driven_by_policy { DecisionSource::PrefixRule } else { DecisionSource::UnmatchedCommandFallback }; // 记录裁决来源:显式规则 vs 兜底——两种来源适用的审批开关不同(见下表) self.process_decision(evaluation.decision, needs_escalation, program, argv, workdir, self.prompt_permissions.clone(), escalation_execution, decision_source).await // 把 (策略裁决 × 是否需提权) 映射成最终的 EscalationDecision}为什么这样设计:needs_escalation 的三个分支体现了"最小提权"原则:默认情况下,即使规则允许一条命令,只要它不需要额外权限,就在沙箱内跑(Run);只有当模型明确请求了更高权限、或档案本身禁止无沙箱执行时,才走 Escalate。而 decision_source 的区分很微妙——同样是"需要审批",来自显式规则的裁决受 Granular.rules 开关控制,来自兜底策略的裁决受 Granular.sandbox_approval 开关控制(第 204-222 行 execve_prompt_is_rejected_by_policy())。用户可以在配置里精确表达"我信任规则库,但不想被兜底策略反复询问"。
| Never | ||
| Granular.rules = false | ||
| Granular.sandbox_approval = false | ||
| 其他(允许询问) |
process_decision()(第 319-401 行)把策略裁决 Decision(Allow/Prompt/Forbidden)映射成协议层的 EscalationDecision,核心逻辑是:
📄 core/src/tools/runtimes/zsh_fork/unix_escalation.rs(第 331-396 行,节选)
let action = match decision { // 策略裁决 → 协议动作的总映射 Decision::Forbidden => EscalationDecision::deny(Some("Execution forbidden by policy".to_string())), // 规则明令禁止:直接拒绝,连询问都不发——省得用户反复点"拒绝" Decision::Prompt => { // 策略要求人工确认 if execve_prompt_is_rejected_by_policy(self.approval_policy, &decision_source).is_some() { // 先查用户配置:该来源的审批开关是否被显式关闭(第 204-222 行) EscalationDecision::deny(Some("Execution forbidden by policy".to_string())) // 用户配置了"从不询问":Prompt 等价于 Deny——宁可拒绝也不静默放行(fail loud) } else { // 开关未关闭:真正发起审批请求(prompt(),stopwatch 暂停计时) let decision = self.prompt(program, argv, workdir, &self.stopwatch, prompt_permissions).await?; // 向用户/Guardian 发审批请求;stopwatch.pause_for() 让超时计时在等待期间暂停——等用户不算"命令执行时间" match decision { // ReviewDecision → EscalationDecision 的逐条映射 ReviewDecision::Approved | ReviewDecision::ApprovedForSession | ReviewDecision::ApprovedExecpolicyAmendment { .. } => if needs_escalation { EscalationDecision::escalate(escalation_execution.clone()) } else { EscalationDecision::run() }, // 批准:按 needs_escalation 决定沙箱内跑还是提权跑 ReviewDecision::NetworkPolicyAmendment { network_policy_amendment } => match network_policy_amendment.action { NetworkPolicyRuleAction::Allow => if needs_escalation { EscalationDecision::escalate(escalation_execution.clone()) } else { EscalationDecision::run() }, // 用户批准了"网络策略修订":视同放行 NetworkPolicyRuleAction::Deny => EscalationDecision::deny(Some("User denied execution".to_string())) }, // 用户在审批里拒绝了网络访问 = 拒绝执行 ReviewDecision::Denied { rejection } => EscalationDecision::deny(Some(rejection)), // 用户拒绝:把用户的原话作为 reason 传回 wrapper,最终出现在 stderr ReviewDecision::TimedOut => EscalationDecision::deny(Some(crate::guardian::guardian_timeout_message(self.review_context.turn().model_info()))), // 审批超时:按拒绝处理,并生成一条说明超时的消息(含模型信息) ReviewDecision::Abort => EscalationDecision::deny(Some("User cancelled execution".to_string())), // 用户主动取消会话/操作 } } } Decision::Allow => if needs_escalation { EscalationDecision::escalate(escalation_execution) } else { EscalationDecision::run() }, // 规则直接放行:同样按 needs_escalation 分流——允许 ≠ 必须出沙箱};为什么这样设计:整个映射表只有一条铁律:任何"不确定"都收敛到 Deny,而不是 Run。Forbidden 不询问、Never 配置下 Prompt 变 Deny、超时变 Deny、取消变 Deny——审批链路上不存在"默认放行"的缝隙。反过来,Allow 也不等于提权:即使规则允许,只要不需要额外权限就在沙箱内执行。这就是"最小提权 + fail-closed"在代码里的具体形状。
六、装配:prepare_unified_exec_zsh_fork
前面所有零件在 prepare_unified_exec_zsh_fork()(unix_escalation.rs 第 85-179 行)里被装配起来。它是 zsh-fork 后端的入口,返回 Ok(None) 表示"这条命令不走 zsh-fork,用普通 spawn 路径":
📄 core/src/tools/runtimes/zsh_fork/unix_escalation.rs(第 85-179 行,节选)
pub(crate) async fn prepare_unified_exec_zsh_fork(req: ..., attempt: &SandboxAttempt<'_>, ctx: &ToolCtx, exec_request: ExecRequest, shell_zsh_path: &std::path::Path, main_execve_wrapper_exe: &std::path::Path) -> Result<Option<PreparedUnifiedExecZshFork>, ToolError> { // zsh-fork 后端入口:返回 Ok(None) = "不适用,走普通 spawn" let parsed = match extract_shell_script(&exec_request.command) { // 先确认这是 zsh -c/-lc 命令——不是就放弃本后端(见下方 extract_shell_script) Ok(parsed) => parsed, // 解析成功:拿到 (zsh路径, -c/-lc脚本, login标志) Err(err) => { tracing::warn!("ZshFork unified exec fallback: {err:?}"); return Ok(None); } // 注意是 Ok(None) 而不是 Err:不是错误,只是"不适用"——调用方回退普通 spawn }; if parsed.program != shell_zsh_path.to_string_lossy() { // 程序必须恰好是 Codex 自带的那个打过补丁的 zsh——系统 zsh 没有 EXEC_WRAPPER 支持,拦截会静默失效 tracing::warn!("ZshFork backend specified, but unified exec command targets `{}` instead of `{}`.", parsed.program, shell_zsh_path.display()); // 留痕:后端被指定但程序不匹配——排查"拦截为何没生效"的关键日志 return Ok(None); // 不匹配 → 回退普通路径并留警告日志:宁可少一层拦截,也不能假装拦住了 } let exec_policy = Arc::new(RwLock::new(ctx.session.services.exec_policy.current_for_environment(req.turn_environment.config().exec_policy.as_ref(), ctx.step_context.turn.allow_prefix_rules()).as_ref().clone())); // 快照当前环境的 execpolicy 规则进 RwLock——会话期间规则可热更新,读锁保证并发裁决安全 let command_executor = CoreShellCommandExecutor { sandbox_manager: attempt.manager.clone(), command: exec_request.command.clone(), cwd, permission_profile: exec_request.permission_profile.clone(), sandbox: exec_request.sandbox, env: exec_request.env.clone(), network: exec_request.network.clone(), ... }; // 构建执行器:Escalate 时由它准备并运行"沙箱外命令"(第 569-827 行) let escalation_policy = CoreShellActionProvider { policy: Arc::clone(&exec_policy), session: Arc::clone(&ctx.session), review_context: GuardianReviewContext::from(&ctx.step_context), call_id: ctx.call_id.clone(), source: GuardianCommandSource::UnifiedExec, approval_policy: ctx.step_context.settings.approval_policy(), ... }; // 构建决策器:execpolicy + Guardian 审批全部挂在这个结构上 let escalate_server = EscalateServer::new(shell_zsh_path.to_path_buf(), main_execve_wrapper_exe.to_path_buf(), escalation_policy); // 组装服务端:补丁 zsh 路径 + wrapper 二进制路径 + 策略实现 let escalation_session = escalate_server.start_session(CancellationToken::new(), Arc::new(command_executor)).map_err(|err| ToolError::Rejected(err.to_string()))?; // 启动会话:socketpair、spawn 服务任务、拿到 env overlay(第二节已拆) exec_request.env.extend(escalation_session.env().clone()); // 把 CODEX_ESCALATE_SOCKET + EXEC_WRAPPER 注入 shell 环境——从这一刻起,这个 zsh 里的每次 execve 都被拦截 Ok(Some(PreparedUnifiedExecZshFork { exec_request, escalation_session })) // 返回:改过 env 的请求 + 会话句柄(Drop 时取消服务任务、关 socket——zsh_fork.rs 第 47-60 行的生命周期钩子)}为什么这样设计:两处"防御性回退"值得注意。第一,extract_shell_script()(第 834-857 行)用 command.windows(3).find_map(...) 在 argv 里滑动查找第一个 -c/-lc 三元组,而不是假设它在前两位——因为沙箱包装器(bwrap、网络代理)可能已经在 zsh 前面垫了参数。第二,zsh 路径必须精确匹配 Codex 自带的那个:如果误用了系统 zsh,EXEC_WRAPPER 环境变量会被无视,拦截静默失效——命令照常执行但没有任何审批。与其"假装拦住了",不如 Ok(None) 回退到普通 spawn(沙箱本身仍然生效),并留下 warn 日志。这是安全系统里典型的"降级要显式、失效要响亮"。
七、进程组与清理:不留孤儿
拦截解决"能不能跑",进程管理解决"怎么干净地停"。shell 命令经常 fork 出子孙进程(npm install、构建工具链),只杀直接子进程会留下一堆孤儿。Codex 的解法在 utils/pty/src/process_group.rs:spawn 时让子进程自成一组,清理时整组信号。
📄 utils/pty/src/process_group.rs(第 27-39、90-112 行,节选)
pub fn set_parent_death_signal(parent_pid: libc::pid_t) -> io::Result<()> { // Linux 专用:注册"父进程死了就给我 SIGTERM"——防 Codex 本体崩溃后留下孤儿 if unsafe { libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) } == -1 { return Err(io::Error::last_os_error()); } // prctl 设置死亡信号;失败是硬错误(在 pre_exec 里调用) if unsafe { libc::getppid() } != parent_pid { // 复查父进程 PID:fork 和 exec 之间,原父进程可能恰好退出了(竞态窗口) unsafe { libc::raise(libc::SIGTERM); } // 若已"无主",立刻自杀——源码注释明说这是为了避免 fork/exec 期间的竞态 } Ok(()) // prctl 成功且父进程仍在:PDEATHSIG 已挂上}pub fn kill_process_group_by_pid(pid: u32) -> io::Result<()> { // 按 PID 杀掉整个进程组(含子孙),而不是单个进程 let pid = pid as libc::pid_t; // u32 → i32(libc 接口用 C 的 pid_t) let pgid = unsafe { libc::getpgid(pid) }; // 先查该进程的组 ID——spawn 时 setpgid(0,0) 让它成为组长,所以 pgid == pid if pgid == -1 { /* ESRCH/NotFound 视为成功 */ return Ok(()); } // 进程已不存在:best-effort 语义,清理"没东西可清"不算错误 let result = unsafe { libc::killpg(pgid, libc::SIGKILL) }; // SIGKILL 整组——不给任何成员存活机会(超时/取消的最后一道) if result == -1 { /* ESRCH 同样视为成功 */ } // 组已消失:同上,不算错误 Ok(()) // killpg 完成(或组本就不存在):整组清理结束}而"取消"路径在 core/src/exec.rs(第 1010-1040 行)里体现为两段式清理:先 SIGTERM 给宽限,再 SIGKILL 兜底:
📄 core/src/exec.rs(第 1010-1040 行,节选)
Some(ExecExpirationOutcome::Cancelled) => { // 用户取消(或会话结束):两段式清理,源码注释"让懂 TERM 的进程先跑完清理" let process_group_id = child.id(); // tokio Child 的 PID——spawn 时 setpgid(0,0) 让它成为组长,所以 PGID == 这个 PID let should_escalate = if let Some(process_group_id) = process_group_id { // 记录"TERM 是否真的发出去了"——决定宽限期后要不要补 KILL codex_utils_pty::process_group::terminate_process_group(process_group_id)? // 第一段:SIGTERM 整组——给进程机会 flush 缓冲、删临时文件(git、npm 都响应 TERM) } else { false }; // child.id() 为 None(极端情况):没发过 TERM,后面无需升级杀组 match tokio::time::timeout(CANCELLATION_TERMINATION_GRACE_PERIOD, child.wait()).await { // 等一个宽限期(常量定义在 exec.rs 顶部)——TERM 之后不是立刻杀 Ok(status) => { status?; if should_escalate && let Some(process_group_id) = process_group_id { codex_utils_pty::process_group::kill_process_group(process_group_id)?; } } // 宽限期内没退干净:SIGKILL 整组兜底(should_escalate=true 表示 TERM 确实发出去了) Err(_) => { kill_child_process_group(&mut child)?; child.start_kill()?; } // wait 本身超时:进程组 + 直接子进程双杀,确保没有漏网之鱼 } (synthetic_exit_status_for_code(/*code*/ 1), false) // 合成退出码 1 上报给模型——"被取消"不是"超时"(timed_out=false),两种失败语义严格区分为什么这样设计:"TERM → 宽限 → KILL"是 Unix 进程管理的标准礼仪,但 Codex 把它做成了对整组生效:因为 shell 命令的子孙进程才是真正持有资源(文件句柄、网络连接)的主体。而 set_parent_death_signal() 补上了最后一块拼图——即使 Codex 主进程自己崩溃(panic、OOM),子进程也会因为 PDEATHSIG 收到 SIGTERM,不会变成无主孤儿。三层防线:正常取消走两段式清理;异常退出走 PDEATHSIG;极端情况(组里还有顽固成员)走 killpg SIGKILL。
八、组件总览
| zsh 补丁 | ||
| EscalateServer | ||
| codex-execve-wrapper | ||
| CoreShellActionProvider | ||
| process_group 工具 |
九、一条被拦截命令的完整旅程
execve 拦截全链路(zsh-fork 后端)
① 劫持:zexecve() 检查 EXEC_WRAPPER
补丁把 execve 目标换成 codex-execve-wrapper,原命令降级为参数——shell 无感。
▼
② 握手:wrapper → EscalateServer
读 CODEX_ESCALATE_SOCKET fd,新开 socketpair,数据报 + SCM_RIGHTS 把会话通道交给服务端。
▼
③ 请求:EscalateRequest{file, argv, workdir, env}
环境快照已剥掉两个协议变量(防递归拦截);wrapper 阻塞等裁决。
▼
④ 裁决:CoreShellActionProvider.determine_action
execpolicy 规则匹配 + Guardian 审批(可阻塞等用户);needs_escalation 决定要不要出沙箱。
▼
⑤ 分流:Run / Escalate / Deny
🔹 Run:wrapper 裸 execv 原命令,沙箱内执行,链路结束🔹 Deny:stderr 打印原因,退出码 1——shell 看到一次普通失败
▼
⑥ Escalate:stdio fd 转移 + 沙箱外执行
wrapper dup stdio 经 SCM_RIGHTS 发出;core env_clear+注入、pre_exec 里 dup2 还原 fd 号码,spawn 出沙箱。
▼
⑦ 回传:SuperExecResult{exit_code}
子进程退出码发回 wrapper,原样转发给 shell——整条审批链路对 zsh 完全透明。
十、小结:把"执行权"变成一次可审计的握手
这一讲拆的是 Codex shell 执行的"安全咽喉"。它的核心思想可以概括成一句话:不信任命令字符串,只信任 execve 这个事实。zsh 补丁把每次执行变成一次握手(socketpair + SCM_RIGHTS),服务端用策略+审批决定 Run/Escalate/Deny,Escalate 时连 stdio 都通过 fd 转移无缝接入——而 wrapper 的裸 execv 又让整条链路对 shell 完全透明。配合进程组两段式清理和 PDEATHSIG,"能不能跑、怎么跑、怎么停"三个问题都有了确定性的答案。
下一讲(第 18 讲)我们进入 Apply Patch:模型不直接写文件,而是生成补丁——Codex 如何解析、校验并安全地应用这些补丁。
📚 系列导航
← 第 16 讲:Unified Exec 统一执行运行时
→ 第 18 讲:Apply Patch 补丁应用
关注公众号「AI技术推荐官」获取更多源码解析内容