openinterpreter shell-escalation 深度解析:execve 拦截协议如何让 Shell 命令获得 Run / Escalate / Deny 决策
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 包括 EscalateServer、EscalationSession、EscalationPolicy、EscalateAction、ExecParams、main_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 的注释):
EscalateRequest中携带一个 socket 文件描述符。wrapper 每次发起请求时都新建一对 socket,把"服务端那一端"作为 FD 通过握手数据报发给服务器,响应则从这条专用 socket 读回;- 所有后代进程都可以发送请求,但响应走独立通道。沙箱 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 行); EscalateRequest(L18-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>,
}
注意请求里带有完整环境变量快照,服务器据此可以在沙箱外重放一个环境一致的执行现场。
EscalationDecision(L37-L41)是服务端策略的输出,提供run()/escalate(execution)/deny(reason)三个构造函数;EscalationExecution(L44-L52)描述"以什么权限提升执行",这是比 README 三分法更细的一层:
| 变体 | 含义(按源码注释) |
|---|---|
Unsandboxed |
完全脱离任何沙箱包装重跑被拦截命令 |
TurnDefault |
按当前轮次(turn)的沙箱配置重跑 |
Permissions(EscalationPermissions) |
按请求上显式附带的沙箱/权限配置重跑,类型来自 codex_protocol::approvals |
EscalateAction(L68-L76)是真正在线上走的线协议动作:Run、Escalate、Deny { 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,按执行顺序拆解:
-
解析继承的 FD:从
CODEX_ESCALATE_SOCKET解析出一个文件描述符编号(注意是 FD 号,不是 socket 路径),用AsyncDatagramSocket::from_raw_fd接管。该 socket 之所以能穿过exec存活,是因为服务端在创建时关闭了它的CLOEXEC标志(见 escalate_server.rs#L198-L199 的set_cloexec(false)注释:"Only the client endpoint should cross exec into the wrapper process.")。 -
握手:新建一对
AsyncSocket::pair()(SOCK_STREAM),向共享数据报 socket 发送一个 1 字节[0]数据报,随附控制消息把 stream socket 的服务器端 FD 交给对方(send_with_fds(&HANDSHAKE_MESSAGE, &[server]))。此后本请求的专属应答通道建立。 -
发送请求:收集当前环境并过滤掉
CODEX_ESCALATE_SOCKET与EXEC_WRAPPER两个内部变量(防止它们泄漏给被执行的命令),连同file、argv、workdir组成EscalateRequest发出。 -
分派三种响应:
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_session(escalate_server.rs#L189-L224)做了四件事:
-
创建一对
AsyncDatagramSocket::pair(),取客户端端点; -
对该端点执行
set_cloexec(false),使其可以跨exec传递给后代进程; -
tokio::spawn一个escalate_task事件循环; -
返回
EscalationSession,其env()暴露一个环境叠加层(overlay,不是完整环境),只含两个键:变量 值 CODEX_ESCALATE_SOCKET客户端端点的 FD 编号 EXEC_WRAPPERwrapper 可执行文件的绝对路径
会话的 Drop 实现会关闭客户端 socket、取消取消令牌并 abort 事件循环任务,从而清理整条链路。
5.2 一次完整执行的参数与结果
EscalateServer::exec(L149-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>,
}
login 为 Some(false) 时用 -c,否则一律 -lc——这意味着默认走登录 Shell,命令能看到登录环境的 PATH。ExecResult(L81-L89)则汇总 exit_code、stdout、stderr、合并的 output、耗时与 timed_out。
注意 ShellCommandExecutor trait(L36-L63)的职责边界:shell-escalation 拥有 Unix 协议本身,而进程拉起、输出捕获、沙箱集成由调用方实现——run() 负责启动 Shell 本身,prepare_escalated_exec() 负责按 EscalationExecution(无沙箱 / 轮次默认 / 指定权限)准备一条提升执行计划,返回 PreparedExec { command, cwd, env, arg0 }。
5.3 请求事件循环与决策处理
escalate_task(L226-L262)循环接收握手数据报:
- 严格校验控制消息必须恰好携带 1 个 FD(每个请求一条专用 stream socket),否则记录错误并继续;
- 每收到一个请求 FD 就
tokio::spawn一个handle_escalate_session_with_policy任务——这正是"每请求独立应答通道"带来的并发处理能力的落点; - 所有等待点都
tokio::select!了父级/会话两级CancellationToken,父进程取消时整个链路优雅退出。
handle_escalate_session_with_policy(L264-L379)是协议的心脏,其处理顺序:
- 相对路径解析:用
AbsolutePathBuf::resolve_path_against_base(file, workdir)把可能相对的file解析成绝对路径,再交给策略; - 策略决策:调用
EscalationPolicy::determine_action(file, argv, workdir)得到EscalationDecision; Run:回发EscalateResponse { action: Run },结束;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。
- 先
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时提取控制消息。流控用AsyncFd的writable()/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_fds,L49-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.py 与 scripts/codex_package/codex-zsh,可作为进一步核对 manifest 内容的入口。
八、测试如何钉住协议行为
escalate_server.rs 的测试模块(L381-L1115)用"确定性策略 + 假执行器"把协议各分支钉死,关键用例与结论:
| 测试 | 验证的事实 |
|---|---|
start_session_exposes_wrapper_env_overlay |
start_session 只导出 EXEC_WRAPPER 与 CODEX_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_exec 的 dup2 循环仍正确工作 |
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 中一条需要提升的命令的完整回路是:
EscalateServer::start_session创建数据报 socket 对,导出CODEX_ESCALATE_SOCKET(FD 号,CLOEXEC已关闭)与EXEC_WRAPPER(wrapper 路径)两个环境变量,spawn 事件循环任务;- 调用方(实现
ShellCommandExecutor的一方)用该环境叠加层启动 patched zsh,spawn 完成后回调关闭父级 socket 副本; - zsh 每次
zexecve前检测到EXEC_WRAPPER,把 argv 改写为"wrapper + 原程序 + 原参数"再execve; - wrapper 从
CODEX_ESCALATE_SOCKET找到共享 FD,用新 socket 对握手,把EscalateRequest(含环境快照,内部变量已过滤)发出; - 服务器解析相对路径、询问
EscalationPolicy,回Run/Escalate/Deny; Run时 wrapper 直接libc::execv透明替换;Escalate时 wrapper 复制并移交 stdio,服务器dup2还原句柄、拉起子进程、等待结束并把退出码(缺省 127)回传;Deny时 wrapper 打印理由并以 1 退出;- 会话销毁时取消令牌联动 abort 事件循环并 kill 未完成的提升子进程,不留孤儿。
对想进一步阅读源码的开发者,建议的入口顺序是:协议定义 → 客户端流程 → 服务端处理 → socket 层,最后对照 zsh 补丁 理解拦截点本身;crate 的 README 则给出了发布 patched zsh 的完整流程约定。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00