首页
/ openinterpreter shell-escalation 深度解析:execve 拦截协议如何让 Shell 命令获得 Run / Escalate / Deny 决策

openinterpreter shell-escalation 深度解析:execve 拦截协议如何让 Shell 命令获得 Run / Escalate / Deny 决策

2026-09-06 14:50:42作者:凌朦慧Richard

openinterpreter(其 Rust 实现主体位于 codex-rs/ 目录)中,codex-rs/shell-escalation crate 实现了 Unix 平台的"shell 提权(shell-escalation)"协议:模型在沙箱 Shell 中发起的每一条 execve(2) 调用都会被一个 wrapper 程序拦截,交由服务端的策略引擎判定——在沙箱内直接运行、把命令提升到沙箱外执行,或者干脆拒绝。读完本文,你将完整掌握该协议的报文结构、基于 Unix socket 与 SCM_RIGHTS 的文件描述符传递机制、codex-execve-wrapper 的三条执行路径,以及为支持 EXEC_WRAPPER 钩子而对 zsh 打的补丁,并能定位到仓库中对应的源码与测试。

一、这个 crate 解决什么问题

crate 文档 的定义看,codex-shell-escalation 包含两部分:

  • Unix shell-escalation 协议实现:一条定义在进程间(agent 服务端与沙箱 Shell 子进程之间)的命令审批协议;
  • codex-execve-wrapper 可执行文件:运行在沙箱内、被 patched shell 用来包装每一次 exec() 调用的拦截器。

codex-execve-wrapper 接收被拦截的 execve(2) 调用的参数,把"这条命令能不能跑、在哪里跑"的决策权委托给共享文件描述符另一端的 shell-escalation 服务器(该文件描述符由环境变量 CODEX_ESCALATE_SOCKET 指定)。服务器最终会回复三种动作之一:

动作 wrapper 的行为
Run wrapper 对自己发起 execve(2),在沙箱内的 Shell 中原样执行原始命令
Escalate wrapper 把当前进程的文件描述符(stdio 等)转发给服务器,命令在沙箱外被忠实地执行;进程结束后,服务器把退出码回传给 wrapper
Deny 服务器已把该命令标记为禁止,wrapper 向 stderr 打印错误并以退出码 1 退出

这套机制的工程价值在于:审批决策点被下沉到了操作系统最底层的 execve 拦截位置,而不是靠解析命令字符串。无论 Shell 里写的是管道、子 shell、exec 替换还是脚本内的间接调用,最终落到 execve(2) 的每一次真实进程替换都会经过协议判定,从源码结构看这比"命令白名单/黑名单"更不容易被绕过。

crate 仅在 Unix 平台启用:lib.rs 中所有模块与导出都被 #[cfg(unix)] 包裹,导出的公共 API 包括 EscalateServerEscalationSessionEscalationPolicyEscalateActionExecParamsmain_execve_wrapper 等。

二、总体架构:一个 wrapper、一个 server、每请求一对 socket

unix/mod.rs 的模块文档用 ASCII 时序图给出了完整协议流程,值得逐行理解:

### Escalation flow(提升执行流程)

Command  Server  Shell  Execve Wrapper
         |
         o----->o
         |      |
         |      o--(exec)-->o
         |      |           |
         |o<-(EscalateReq)--o
         ||     |           |
         |o--(Escalate)---->o
         ||     |           |
         |o<---------(fds)--o
         ||     |           |
  o<------o     |           |
  |      ||     |           |
  x------>o     |           |
         ||     |           |
         |x--(exit code)--->o
         |      |           |
         |      o<--(exit)--x
         |      |
         o<-----x

非提升(Run)流程则更短:Server 收到 EscalateReq 后回复 Run,wrapper 直接 exec() 目标命令,命令退出后 Shell 继续,整个过程命令始终待在沙箱里。

关键设计点(来自 mod.rs 的注释):

  1. EscalateRequest 中携带一个 socket 文件描述符。wrapper 每次发起请求时都新建一对 socket,把"服务端那一端"作为 FD 通过握手数据报发给服务器,响应则从这条专用 socket 读回;
  2. 所有后代进程都可以发送请求,但响应走独立通道。沙箱 Shell 的所有后代进程共享 CODEX_ESCALATE_SOCKET 环境变量里的数据报 socket 用于"发送"升级请求,而"接收"走每个请求单独创建的 socket——这使得服务器能够并发处理多个升级请求,多个子命令同时发起 exec 时不会互相串话。

三、协议消息层:EscalateRequest 与三种决策

协议的全部报文类型集中在 escalate_protocol.rs

  • 两个关键常量:ESCALATE_SOCKET_ENV_VAR = "CODEX_ESCALATE_SOCKET"(wrapper 靠它找到继承的 FD)、EXEC_WRAPPER_ENV_VAR = "EXEC_WRAPPER"(patched shell 靠它找到 wrapper 程序,见 第 11 行第 14 行);
  • EscalateRequestL18-L28):
/// The client sends this to the server to request an exec() call.
pub struct EscalateRequest {
    /// 被拦截 exec 调用的可执行文件路径(可能是相对路径,
    /// 需要相对 `workdir` 解析)。
    pub file: PathBuf,
    /// 完整 argv,包含程序名(argv[0])。
    pub argv: Vec<String>,
    pub workdir: AbsolutePathBuf,
    pub env: HashMap<String, String>,
}

注意请求里带有完整环境变量快照,服务器据此可以在沙箱外重放一个环境一致的执行现场。

  • EscalationDecisionL37-L41)是服务端策略的输出,提供 run() / escalate(execution) / deny(reason) 三个构造函数;
  • EscalationExecutionL44-L52)描述"以什么权限提升执行",这是比 README 三分法更细的一层:
变体 含义(按源码注释)
Unsandboxed 完全脱离任何沙箱包装重跑被拦截命令
TurnDefault 按当前轮次(turn)的沙箱配置重跑
Permissions(EscalationPermissions) 按请求上显式附带的沙箱/权限配置重跑,类型来自 codex_protocol::approvals
  • EscalateActionL68-L76)是真正在线上走的线协议动作:RunEscalateDeny { reason: Option<String> }reason 允许服务端附带一句拒绝理由,wrapper 会打印出来);
  • SuperExecMessage { fds: Vec<RawFd> }:wrapper 声明"这些目标 FD 号(如 stdin/stdout/stderr)的句柄将通过控制消息随附发来";
  • SuperExecResult { exit_code: i32 }:命令在服务器侧执行完毕后回传的退出码。

四、客户端:codex-execve-wrapper 的三条执行路径

4.1 入口与命令行约定

可执行目标在 Cargo.toml 中声明为 codex-execve-wrapper。其参数解析在 execve_wrapper.rs

#[derive(Parser)]
pub struct ExecveWrapperCli {
    file: String,
    #[arg(trailing_var_arg = true)]
    argv: Vec<String>,
}

即第一个位置参数是被拦截的可执行文件,其余全部原样作为 argv 透传——这与 zsh 补丁的改法(见第七节)严格对应。日志通过 tracing_subscriber 写到 stderr 且禁用 ANSI,避免污染命令的真实输出。

4.2 核心流程:握手、请求、分派

run_shell_escalation_execve_wrapper 的完整实现见 escalate_client.rs#L36-L124,按执行顺序拆解:

  1. 解析继承的 FD:从 CODEX_ESCALATE_SOCKET 解析出一个文件描述符编号(注意是 FD 号,不是 socket 路径),用 AsyncDatagramSocket::from_raw_fd 接管。该 socket 之所以能穿过 exec 存活,是因为服务端在创建时关闭了它的 CLOEXEC 标志(见 escalate_server.rs#L198-L199set_cloexec(false) 注释:"Only the client endpoint should cross exec into the wrapper process.")。

  2. 握手:新建一对 AsyncSocket::pair()(SOCK_STREAM),向共享数据报 socket 发送一个 1 字节 [0] 数据报,随附控制消息把 stream socket 的服务器端 FD 交给对方send_with_fds(&HANDSHAKE_MESSAGE, &[server]))。此后本请求的专属应答通道建立。

  3. 发送请求:收集当前环境并过滤掉 CODEX_ESCALATE_SOCKETEXEC_WRAPPER 两个内部变量(防止它们泄漏给被执行的命令),连同 fileargvworkdir 组成 EscalateRequest 发出。

  4. 分派三种响应

    • Escalate 分支L64-L92):先 try_clone_to_owned 复制 stdin/stdout/stderr 三份 FD 再转移所有权——源码注释解释了原因:wrapper 必须保留自己的 stdio 继续使用,直到提升后的子进程接管;随后把"目标 FD 号列表"装进 SuperExecMessage、把复制的句柄放进 SCM_RIGHTS 控制消息发出,最后阻塞等待 SuperExecResult 并把其中的 exit_code 作为 wrapper 自己的退出码返回。另有一处 TODO 注释表明信号转发尚未实现("also forward signals over the super-exec socket")。
    • Run 分支L93-L115):直接调用 libc::execv 而不是 std::process::Command。源码注释点明了动机:"std::os::unix::process::CommandExt.exec() 会对信号掩码和标准 FD 做一些我们不需要的事情(signal masks and dup2())"。wrapper 的目标是对进程状态尽可能透明——exec 之后当前进程镜像被原样替换为目标命令,退出即完成。若 exec 失败则返回 OS 错误。
    • Deny 分支L116-L122):有理由时打印 Execution denied: {reason},否则打印 Execution denied,返回退出码 1

4.3 FD 复制不关闭原句柄

escalate_client.rs#L132-L143 的测试 duplicate_fd_for_transfer_does_not_close_original 专门验证 duplicate_fd_for_transfer 复制出的新句柄编号不同于原句柄,且 drop 新句柄后原句柄依然有效——这是"复制后再转移"语义的回归保障。

五、服务端:EscalateServer、会话与策略接口

5.1 会话建立:两个环境变量

EscalateServer::start_sessionescalate_server.rs#L189-L224)做了四件事:

  1. 创建一对 AsyncDatagramSocket::pair(),取客户端端点;

  2. 对该端点执行 set_cloexec(false),使其可以跨 exec 传递给后代进程;

  3. tokio::spawn 一个 escalate_task 事件循环;

  4. 返回 EscalationSession,其 env() 暴露一个环境叠加层(overlay,不是完整环境),只含两个键:

    变量
    CODEX_ESCALATE_SOCKET 客户端端点的 FD 编号
    EXEC_WRAPPER wrapper 可执行文件的绝对路径

会话的 Drop 实现会关闭客户端 socket、取消取消令牌并 abort 事件循环任务,从而清理整条链路。

5.2 一次完整执行的参数与结果

EscalateServer::execL149-L182)以 ExecParams 为输入组装 Shell 命令行:

pub struct ExecParams {
    /// 传给 shell `-c` 或 `-lc` 的命令字符串。
    pub command: String,
    /// 工作目录,必须是绝对路径。
    pub workdir: String,
    /// 命令超时(毫秒)。
    pub timeout_ms: Option<u64>,
    /// 是否以 -lc 启动 shell(登录 shell):默认 true。
    pub login: Option<bool>,
}

loginSome(false) 时用 -c,否则一律 -lc——这意味着默认走登录 Shell,命令能看到登录环境的 PATH。ExecResultL81-L89)则汇总 exit_codestdoutstderr、合并的 output、耗时与 timed_out

注意 ShellCommandExecutor trait(L36-L63)的职责边界:shell-escalation 拥有 Unix 协议本身,而进程拉起、输出捕获、沙箱集成由调用方实现——run() 负责启动 Shell 本身,prepare_escalated_exec() 负责按 EscalationExecution(无沙箱 / 轮次默认 / 指定权限)准备一条提升执行计划,返回 PreparedExec { command, cwd, env, arg0 }

5.3 请求事件循环与决策处理

escalate_taskL226-L262)循环接收握手数据报:

  • 严格校验控制消息必须恰好携带 1 个 FD(每个请求一条专用 stream socket),否则记录错误并继续;
  • 每收到一个请求 FD 就 tokio::spawn 一个 handle_escalate_session_with_policy 任务——这正是"每请求独立应答通道"带来的并发处理能力的落点;
  • 所有等待点都 tokio::select! 了父级/会话两级 CancellationToken,父进程取消时整个链路优雅退出。

handle_escalate_session_with_policyL264-L379)是协议的心脏,其处理顺序:

  1. 相对路径解析:用 AbsolutePathBuf::resolve_path_against_base(file, workdir) 把可能相对的 file 解析成绝对路径,再交给策略;
  2. 策略决策:调用 EscalationPolicy::determine_action(file, argv, workdir) 得到 EscalationDecision
  3. Run:回发 EscalateResponse { action: Run },结束;
  4. Escalate(execution):回发 Escalate → 接收 SuperExecMessage 及其控制消息里的 FD,校验控制消息 FD 数量与消息体声明一致 → 调用 prepare_escalated_exec 得到执行计划 → 用 tokio::process::Command 拉起子进程。其中细节值得留意:
    • stdin/stdout/stderr(Stdio::null()),再在 pre_exec 钩子里按 msg.fds(目标 FD 号)与 fds(接收到的句柄)成对 dup2,把 wrapper 传来的真实 stdio 装回原编号(L344-L350);
    • kill_on_drop(true) 加上取消令牌驱动的 child.start_kill(),保证会话销毁或取消时提升的子进程不会成为孤儿;
    • 子进程结束后回发 SuperExecResult,退出码取 exit_status.code().unwrap_or(127)——无退出码(如被信号杀死)时回 127。
  5. Deny:原样把含 reason 的响应发回。

策略侧只有最小接口(escalation_policy.rs):

pub trait EscalationPolicy: Send + Sync {
    fn determine_action<'a>(
        &'a self,
        file: &'a AbsolutePathBuf,
        argv: &'a [String],
        workdir: &'a AbsolutePathBuf,
    ) -> EscalationPolicyFuture<'a>;
}

返回 BoxFuture<anyhow::Result<EscalationDecision>>,即"策略可以是异步的、可以带上下文做审批(例如询问用户确认)"是从接口设计上被明确支持的。

六、Socket 层:FD 传递与帧格式

socket.rs 实现了协议所需的两种 Unix socket 抽象,全部参数值得记录:

常量 含义
MAX_FDS_PER_MESSAGE 16 单条控制消息最多携带的 SCM_RIGHTS FD 数
LENGTH_PREFIX_SIZE 4 字节 stream 帧的 u32 小端长度前缀
MAX_DATAGRAM_SIZE 8192 数据报接收缓冲上限
  • AsyncSocket(SOCK_STREAM,带帧):帧 = 4 字节长度前缀 + 长度编码的 JSON 载荷;FD 只随第一次 sendmsg 的控制消息发送include_fds 在首块后置 false),接收侧同样只在读取帧头的首个 recvmsg 时提取控制消息。流控用 AsyncFdwritable()/readable() 等待实现非阻塞多块传输。
  • AsyncDatagramSocket(SOCK_DGRAM,握手通道)sendmsg/recvmsg 单包收发,from_raw_fd 用于接管 CODEX_ESCALATE_SOCKET 里继承来的 FD。
  • 两个 pair() 都刻意用 Socket::pair_raw 而非 socket2::Socket::pair——源码注释说明后者会给 AF_UNIX socket 附加 SO_NOSIGPIPE 等"公共标志",在平台上可能失败,随后显式对两端恢复 CLOEXEC
  • FD 提取(extract_fdsL49-L79)遍历 cmsghdr 链,识别 SOL_SOCKET + SCM_RIGHTS 控制消息,按 cmsg_len - CMSG_LEN(0) 除以 sizeof(fd) 逐个取出。

socket.rs 末尾的测试群(L412-L523)覆盖了协议正确性的基础:载荷+FD 往返一致性、10KB 大载荷、数据报往返、超过 16 个 FD 被拒、超大长度编码报错、对端提前关闭返回 UnexpectedEof

七、Patched zsh:EXEC_WRAPPER 钩子如何生效

协议要成立,Shell 必须愿意在 exec 时先走 wrapper。shell-escalation 自带一个对 zsh Src/exec.c 的小补丁:patches/zsh-exec-wrapper.patch,其核心改动落在 zexecve()

exec_argv = argv;
if ((exec_wrapper = getenv("EXEC_WRAPPER")) &&
    *exec_wrapper && !inblank(*exec_wrapper)) {
    exec_argv = argv - 2;
    exec_argv[0] = exec_wrapper;
    exec_argv[1] = orig_pth;
    pth = exec_wrapper;
}
winch_unblock();
execve(pth, exec_argv, newenvp);

要点:

  • 只有当 EXEC_WRAPPER 非空且首字符不是空白时改写目标,否则行为与原版 zsh 完全一致;
  • 利用 execve 在 exec 失败返回后 argv 缓冲区仍可读的惯例,把 argv 前移两个槽位:argv[0] 换成 wrapper 路径,argv[1] 换成原始程序路径——恰好匹配 ExecveWrapperCli { file, argv } 的"第一个参数是 file"的约定;
  • execve 失败(正常不应发生),补丁还会把 pth 恢复为 orig_pth,保证后续 zsh 原有的 ENOEXEC 处理逻辑看到的仍是原始路径。

手动重建 patched zsh(摘自 README,补丁基线为 zsh 源码树的提交 77045ef899e53b9598bebc5a41db93a548a40ca6):

git clone https://git.code.sf.net/p/zsh/code
git checkout 77045ef899e53b9598bebc5a41db93a548a40ca6
git apply /path/to/patches/zsh-exec-wrapper.patch
./Util/preconfig
./configure
make -j"$(nproc)"

按 README 的发布流程说明:当 codex-zsh-vX.Y.Z tag 被推送时,CI 工作流(README 指向 .github/workflows/rust-release-zsh.yml)会构建发布产物;一旦 zsh 提交或补丁本身变更,就必须发布下一个版本 tag,并更新仓库中检查入的 DotSlash manifest 指向新 release。仓库中与这条发布链相关的文件包括 scripts/codex_package/dotslash.pyscripts/codex_package/codex-zsh,可作为进一步核对 manifest 内容的入口。

八、测试如何钉住协议行为

escalate_server.rs 的测试模块(L381-L1115)用"确定性策略 + 假执行器"把协议各分支钉死,关键用例与结论:

测试 验证的事实
start_session_exposes_wrapper_env_overlay start_session 只导出 EXEC_WRAPPERCODEX_ESCALATE_SOCKET 两个键,socket FD 在 close_client_socket() 前保持有效
exec_closes_parent_socket_after_shell_spawn exec() 通过 after_spawn 钩子在 Shell spawn 之后关闭父级 socket 副本
handle_escalate_session_resolves_relative_file_against_request_workdir 相对路径 ./bin/tool 被相对请求中的 workdir 解析后再交给策略
handle_escalate_session_executes_escalated_command Escalate(Unsandboxed) 全链路:发送请求 → 收到 Escalate → 发送 SuperExecMessage → 收到 SuperExecResult { exit_code: 42 }(测试命令特意退出 42 以证明退出码忠实回传)
handle_escalate_session_accepts_received_fds_that_overlap_destinations 覆盖"接收到的 FD 恰好落在目标 FD 号(如 0)上"的 src_fd == dst_fd 边界:临时关闭 stdin 迫使内核把收到的句柄分配到 0 号,pre_execdup2 循环仍正确工作
handle_escalate_session_passes_permissions_to_executor EscalationExecution::Permissions(...)(带 NetworkPermissions { enabled: Some(true) })被原样透传给执行器的 prepare_escalated_exec
dropping_session_aborts_intercept_workers_and_kills_spawned_child 会话销毁(drop(session))会中断拦截工作并杀死已提升的子进程(测试中一个 sleep 100 子进程确实在会话销毁后退出)

这些测试共同说明:协议层不依赖任何真实 Shell 即可被验证,而生命周期清理(socket 关闭、任务 abort、子进程 kill)都有显式的回归保障。

九、小结:从一次 exec 到退出码的完整回路

把各层拼起来,openinterpreter 中一条需要提升的命令的完整回路是:

  1. EscalateServer::start_session 创建数据报 socket 对,导出 CODEX_ESCALATE_SOCKET(FD 号,CLOEXEC 已关闭)与 EXEC_WRAPPER(wrapper 路径)两个环境变量,spawn 事件循环任务;
  2. 调用方(实现 ShellCommandExecutor 的一方)用该环境叠加层启动 patched zsh,spawn 完成后回调关闭父级 socket 副本;
  3. zsh 每次 zexecve 前检测到 EXEC_WRAPPER,把 argv 改写为"wrapper + 原程序 + 原参数"再 execve
  4. wrapper 从 CODEX_ESCALATE_SOCKET 找到共享 FD,用新 socket 对握手,把 EscalateRequest(含环境快照,内部变量已过滤)发出;
  5. 服务器解析相对路径、询问 EscalationPolicy,回 Run / Escalate / Deny
  6. Run 时 wrapper 直接 libc::execv 透明替换;Escalate 时 wrapper 复制并移交 stdio,服务器 dup2 还原句柄、拉起子进程、等待结束并把退出码(缺省 127)回传;Deny 时 wrapper 打印理由并以 1 退出;
  7. 会话销毁时取消令牌联动 abort 事件循环并 kill 未完成的提升子进程,不留孤儿。

对想进一步阅读源码的开发者,建议的入口顺序是:协议定义客户端流程服务端处理socket 层,最后对照 zsh 补丁 理解拦截点本身;crate 的 README 则给出了发布 patched zsh 的完整流程约定。

登录后查看全文
热门项目推荐
相关项目推荐