夜雨聆风学习资料网

ARTICLE · 1157066

Codex 源码-Shell 命令执行与进程管理——拦截每一次 execve

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 发布二进制)。

拦截方案
能看到什么
弱点
命令字符串解析(parse_command.rs)
顶层命令文本,可归类 Read/Search/Unknown
子 shell、eval、别名全部绕过;只适合做摘要,不适合当安全边界
LD_PRELOAD hook libc
动态链接进程的 execve
setuid/静态二进制可剥离;跨平台脆弱,且对 shell 自身不透明
zsh 补丁 + EXEC_WRAPPER(本讲)
shell 内每一次 execve,含嵌套/子 shell 的最终目标
必须用打过补丁的 zsh;非 shell 路径由沙箱(bwrap/Landlock)另行兜底

二、升级协议:一个 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
EscalateServer::start_session()
codex-execve-wrapper:继承的 fd 号,找到升级 socket 的入口
EXEC_WRAPPER
EscalateServer::start_session()
zsh 补丁(zexecve):非空时把 execve 目标替换成该程序
stdio fd(stdin/out/err)
wrapper 在 Escalate 分支 dup 后发送
服务端子进程:dup2 还原成原号码,沙箱外执行时用户看到的还是同一套终端

三、服务端决策循环:从请求到裁决

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 点都挂了取消分支,保证"用户取消"在任何阶段都能立刻生效。

裁决(EscalationDecision)
服务端做什么
wrapper 做什么
Run
回 Run,结束
直接 execv 原命令——在沙箱内执行,服务端零参与
Escalate(execution)
收 stdio fd → 按 execution 模式准备沙箱外环境 → spawn → 回传退出码
dup stdio 发给服务端,等退出码并原样转发——对 shell 透明
Deny{reason}
回 reason,结束
stderr 打印 "Execution denied: ...",退出码 1——shell 看到一次普通失败

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())。用户可以在配置里精确表达"我信任规则库,但不想被兜底策略反复询问"。

AskForApproval 设置
裁决来源
策略要求 Prompt 时的行为
Never
任意
直接 Deny("approval required by policy, but AskForApproval is set to Never")——fail loud,绝不静默放行
Granular.rules = false
PrefixRule(显式规则)
直接 Deny——用户明确关闭了"按规则询问"
Granular.sandbox_approval = false
UnmatchedCommandFallback(兜底)
直接 Deny——用户明确关闭了"按沙箱策略询问"
其他(允许询问)
任意
发审批请求(prompt(),stopwatch 暂停计时);批准→Run/Escalate,拒绝/超时→Deny

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 补丁
shell-escalation/patches/zsh-exec-wrapper.patch
zexecve() 里检查 EXEC_WRAPPER,把 execve 目标替换成 wrapper——拦截的"扳机"
EscalateServer
shell-escalation/src/unix/escalate_server.rs(1118 行)
socketpair + 服务循环:收请求、派发给策略、Escalate 时沙箱外代跑并回传退出码
codex-execve-wrapper
shell-escalation/src/bin/main_execve_wrapper.rs + unix/escalate_client.rs(144 行)
客户端:握手、发请求;Run→裸 execv,Escalate→交 stdio 等退出码,Deny→打印原因退 1
CoreShellActionProvider
core/src/tools/runtimes/zsh_fork/unix_escalation.rs(873 行)
execpolicy 规则 + Guardian 审批 → Run/Escalate/Deny;needs_escalation 最小提权判断
process_group 工具
utils/pty/src/process_group.rs(305 行)+ core/src/exec.rs
setpgid/PDEATHSIG/killpg:整组清理、防孤儿;取消路径 TERM→宽限→KILL

九、一条被拦截命令的完整旅程

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技术推荐官」获取更多源码解析内容

相关学习资料