夜雨聆风学习资料网

ARTICLE · 1192298

Codex 源码-Linux bwrap 沙箱

Codex 源码-Linux bwrap 沙箱

Codex 源码解析系列

第 22 讲:Linux bwrap 沙箱

基于 OpenAI Codex 源码 · 2026-10-16

💡 本讲一句话:Codex 的 Linux 沙箱是"两阶段"的——外层用 bubblewrap(bwrap)搭一个只读文件系统视图 + 命名空间隔离,内层再进同一个二进制、验证能力已清零后套上 seccomp。读完你能看懂 bwrap.rs / linux_run_main.rs 如何把权限声明变成挂载参数,以及 WSL1 为什么被拒之门外。

一、定位:sandboxing crate 的 Linux 后端实现

上一讲(第 21 讲)拆完 Sandbox Manager 的决策与分发,Linux 分支最终落到一个辅助二进制 codex-linux-sandbox。本讲进入它的内部——linux-sandbox crate(约 7.6K LOC),核心两个文件:

🔹 bwrap.rs(2783 行):把权限画像翻译成 bubblewrap 的挂载参数——只读根、可写根、受保护子路径、网络模式;🔹 linux_run_main.rs(1599 行):辅助二进制的入口,编排"两阶段执行"——外层 bwrap 搭视图,内层套 seccomp。

先看模块头部的官方注释(linux-sandbox/src/bwrap.rs 第 1-11 行),它把整个 Linux 沙箱的组成说得很清楚:

📄 codex-rs/linux-sandbox/src/bwrap.rs (第 1-11 行)

//! Bubblewrap-based filesystem sandboxing for Linux. // 模块定位:基于 bubblewrap 的 Linux 文件系统沙箱//! This module mirrors the semantics used by the macOS Seatbelt sandbox: // 语义对齐 macOS Seatbelt——跨平台行为一致是设计目标//! - the filesystem is read-only by default, // 默认只读:白名单式放行,而非黑名单拦截//! - explicit writable roots are layered on top, // 可写根目录逐层叠加上去//! - sensitive subpaths such as `.git`, `.agents`, and `.codex` remain // 敏感子路径(.git/.agents/.codex)即使父目录可写也保持只读//!   read-only even when their parent root is writable. // ——防止模型误改仓库元数据或 Codex 自身配置//! The overall Linux sandbox is composed of: // Linux 沙箱由两部分组成://! - seccomp + `PR_SET_NO_NEW_PRIVS` applied in-process, // seccomp + no_new_privs:进程内施加,限制系统调用与提权//! - bubblewrap used to construct the filesystem view before exec. // bwrap:exec 前构建文件系统视图(命名空间隔离)

为什么这样设计:"默认只读 + 白名单叠加"是安全沙箱的经典范式——比"全盘可写 + 黑名单拦截"可靠得多,因为漏掉一个危险路径的代价完全不同。而 .git/.agents/.codex 三个受保护子路径单独点名,是因为它们承载了仓库历史、智能体状态和 Codex 自身配置——模型即使被授权写项目目录,也不应该能篡改这些"元数据"。这是把产品级安全语义直接编码进了沙箱层。

控制 bwrap 行为的选项结构体(第 60-109 行):

📄 codex-rs/linux-sandbox/src/bwrap.rs (第 60-109 行)

/// Options that control how bubblewrap is invoked. // 控制 bwrap 调用方式的选项#[derive(Debug, Clone, Copy, PartialEq, Eq)] // 可拷贝、可比较:值语义,方便在参数间传递pub(crate) struct BwrapOptions {    /// Whether to mount a fresh `/proc` inside the sandbox. // 是否在沙箱内挂载全新 /proc    /// This is the secure default, but some restrictive container environments // 安全默认值;但某些受限容器环境会拒绝 --proc /proc    /// deny `--proc /proc`. // ——所以做成可关闭的选项,而不是硬编码    pub mount_proc: bool,    /// How networking should be configured inside the bubblewrap sandbox. // 沙箱内网络如何配置    pub network_mode: BwrapNetworkMode,    /// Hide the WSL Windows interop socket from commands with restricted filesystem access. // 对受限命令隐藏 WSL 的 Windows 互操作 socket(防绕过)    pub mask_wsl_interop: bool,    /// Optional maximum depth for expanding unreadable glob patterns with ripgrep. // 用 ripgrep 展开"不可读 glob"时的最大深度    /// Keep this uncapped by default so existing nested deny-read matches are // 默认不设上限:保证嵌套的拒绝读取规则都能被 mask 掉    /// masked before the sandboxed command starts. // ——在沙箱命令启动前完成,而非运行时才发现    pub glob_scan_max_depth: Option<usize>,}impl Default for BwrapOptions { // 默认值:安全优先    fn default() -> Self {        Self {            mount_proc: true, // 挂载全新 /proc(隔离宿主进程信息)            network_mode: BwrapNetworkMode::FullAccess, // 网络默认全通——由上层策略决定是否收紧            mask_wsl_interop: false, // WSL 互操作隐藏按需开启            glob_scan_max_depth: None, // glob 展开深度默认不限        }    }}/// Network policy modes for bubblewrap. // bwrap 的三种网络模式#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]pub(crate) enum BwrapNetworkMode {    /// Keep access to the host network namespace. // FullAccess:保留宿主网络命名空间(默认)    #[default]    FullAccess,    /// Remove access to the host network namespace. // Isolated:完全移除宿主网络访问    Isolated,    /// Intended proxy-only mode. // ProxyOnly:仅允许走代理——最常用    /// Bubblewrap enforces this by unsharing the network namespace. // bwrap 通过 unshare 网络命名空间来强制    /// The proxy-routing bridge is established by the helper process after startup. // 代理路由桥由辅助进程启动后建立(第 27 讲细拆)    ProxyOnly,}impl BwrapNetworkMode {    fn should_unshare_network(self) -> bool { // 除 FullAccess 外都要 unshare 网络命名空间        !matches!(self, Self::FullAccess)    }}

为什么这样设计:三种网络模式对应三种安全等级:FullAccess(开发调试)、Isolated(完全断网)、ProxyOnly(生产默认——流量必须过受管代理,白名单在代理侧执行)。注意 ProxyOnly 的注释:"bridge is established by the helper process after startup"——bwrap 只负责把网络命名空间切走,真正的路由桥接是辅助进程在内层阶段做的。这种"机制与策略分离"让 bwrap 参数保持简单,复杂逻辑留在 Rust 侧。

二、两阶段执行:外层搭视图,内层锁系统调用

codex-linux-sandbox 的入口 run_main()(linux_run_main.rs 第 160-278 行)是整个 Linux 沙箱的编排核心。它的关键设计是同一个二进制跑两遍:外层负责 bwrap,内层负责 seccomp——通过 --apply-seccomp-then-exec 标志区分阶段。

📄 codex-rs/linux-sandbox/src/linux_run_main.rs (第 195-278 行)

// Inner stage: apply seccomp/no_new_privs after bubblewrap has already // 内层阶段:bwrap 已建立文件系统视图后,再施加 seccomp / no_new_privs// established the filesystem view. // ——顺序不能反:seccomp 过滤器一旦装上就无法卸载if apply_seccomp_then_exec { // --apply-seccomp-then-exec 标志 = "我是内层"    if let Err(err) = crate::fd_mount::verify_fd_mounts(&verify_fd_mounts) { // 先验证 fd 挂载(bwrap 通过文件描述符传递的挂载)确实生效        panic!("failed to verify descriptor-backed bubblewrap mount: {err}"); // 验证失败 → fail loud,绝不带病运行    }    let mut capability_header = [LINUX_CAPABILITY_VERSION_3, 0]; // capget 系统调用的 ABI v3 头:[版本, pid]    let mut capability_sets = [[0_u32; 3]; 2]; // 两组能力集:[effective, permitted, inheritable] × 2(线程/进程)    let result = unsafe { // SAFETY: capget 是只读查询,不修改内核状态        libc::syscall(            libc::SYS_capget, // 查询当前进程的能力位图            capability_header.as_mut_ptr(),            capability_sets.as_mut_ptr(),        )    };    if result < 0 { // capget 失败(极罕见)→ 无法确认能力状态,直接 panic        panic!("failed to verify Linux sandbox capabilities: {}", std::io::Error::last_os_error());    }    if capability_sets.into_iter().any(|[effective, permitted, _]| effective != 0 || permitted != 0) { // 任何一组还有非零能力 → 说明外层 --cap-drop ALL 没生效        panic!("Linux sandbox retained effective or permitted capabilities"); // fail loud:宁可崩溃也不带着残留能力继续跑    }    if allow_network_for_proxy { // ProxyOnly 模式:在内层建立代理路由桥(此时网络命名空间已隔离)        let spec = proxy_route_spec.as_deref().unwrap_or_else(|| panic!("managed proxy mode requires --proxy-route-spec")); // 必须有路由规格,否则 fail loud        if let Err(err) = activate_proxy_routes_in_netns(spec) { // 在新网络命名空间里激活代理路由(loopback 桥接)            panic!("error activating Linux proxy routing bridge: {err}");        }    }    let proxy_routing_active = allow_network_for_proxy; // 记录代理是否已激活,传给后续策略应用    if let Err(e) = apply_permission_profile_to_current_thread( // 施加 seccomp + no_new_privs(文件系统已由 bwrap 处理,此处不再套 Landlock)        &permission_profile,        &sandbox_policy_cwd,        /*apply_landlock_fs*/ false, // 新路径不用 Landlock——bwrap 已覆盖文件系统隔离        allow_network_for_proxy,        proxy_routing_active,    ) {        panic!("error applying Linux sandbox restrictions: {e:?}");    }    let signal_mask = ForwardedSignalMask::block(); // fork 前屏蔽转发信号,避免竞态    let command_pid = unsafe { libc::fork() }; // fork 出子进程执行目标命令(而非直接 exec——父进程要负责信号转发)    if command_pid < 0 { panic!("failed to fork sandboxed command: {}", std::io::Error::last_os_error()); }    if command_pid == 0 { // 子进程:恢复默认信号处理,exec 目标命令        reset_forwarded_signal_handlers_to_default();        signal_mask.restore();        exec_or_panic(command); // execvp 进入最终命令;失败则 panic(不会返回)    }    let signal_forwarders = install_bwrap_signal_forwarders(command_pid); // 父进程:安装信号转发器(Esc/Ctrl-C 能传到沙箱内子进程)    signal_mask.restore();    loop { // 等待子进程退出,透传其退出码/信号        let mut status = 0;        let reaped_pid = unsafe { libc::waitpid(-1, &mut status, 0) };        if reaped_pid == command_pid { // 目标命令退出 → 恢复所有屏蔽,以相同状态退出(第 21 讲的违规归因就靠这个退出码)            let exit_signal_mask = ForwardedSignalMask::block();            signal_forwarders.restore();            exit_signal_mask.restore();            exit_with_wait_status(status);        }        if reaped_pid >= 0 { continue; } // 其他子进程(如代理桥)退出,继续等        let err = std::io::Error::last_os_error();        if err.raw_os_error() != Some(libc::EINTR) { panic!("failed to reap sandboxed child: {err}"); } // EINTR 可重试,其他错误 fatal    }}

为什么这样设计:三个关键点。其一,两阶段的顺序是硬约束:seccomp 过滤器一旦 SECCOMP_SET_MODE_FILTER 装上就不可卸载、不可修改(除非 no_new_privs 未设且用 SECCOMP_RET_TRACE)。所以必须"先搭好文件系统视图,再锁系统调用"——反过来做,seccomp 可能把 bwrap 需要的 mount 类 syscall 也拦掉。其二,能力验证是防御纵深:外层已经 --cap-drop ALL,内层再用 capget 复核一遍——如果任何一组能力位非零就 panic。这不是多此一举:容器环境(Docker-in-Docker、K8s pod)的能力继承行为千奇百怪,"假设外层做对了"在嵌套场景下经常不成立。其三,fork + exec 而非直接 exec:父进程要活着负责信号转发——用户按 Esc 时,SIGINT/SIGTERM 必须能穿透 bwrap 的 PID 命名空间边界传到目标命令,否则沙箱内进程会变成"杀不死的孤儿"。

外层阶段(第 293-321 行)则相反:它不直接执行命令,而是把"内层命令"作为参数塞给 bwrap:

📄 codex-rs/linux-sandbox/src/linux_run_main.rs (第 293-334 行)

if !use_legacy_landlock { // 新路径(默认):bwrap + seccomp 两阶段    // Outer stage: bubblewrap first, then re-enter this binary in the // 外层:先 bwrap,然后在沙箱内"重新进入本二进制"施加 seccomp    // sandboxed environment to apply seccomp. This path never falls back // ——失败绝不回退到旧 Landlock(避免安全降级)    // to legacy Landlock on failure.    let (proxy_route_spec, proxy_controls) = if allow_network_for_proxy { // ProxyOnly 模式:先准备宿主侧代理路由规格        let (proxy_route_spec, controls) = prepare_host_proxy_route_spec() // 规划哪些 loopback 端口要桥接(第 27 讲细拆)            .unwrap_or_else(|err| panic!("failed to prepare host proxy routing bridge: {err}"));        (Some(proxy_route_spec), controls)    } else {        (None, Vec::new()) // 非代理模式:无路由规格    };    let inner = build_inner_seccomp_command(InnerSeccompCommandArgs { // 构造"内层命令"= 本二进制 + --apply-seccomp-then-exec + 全部参数        sandbox_policy_cwd: &sandbox_policy_cwd,        command_cwd: command_cwd.as_deref(),        permission_profile: &permission_profile,        allow_network_for_proxy,        proxy_route_spec,        command, // 最终要执行的用户命令(作为内层的参数传递)    });    run_bwrap_with_proc_fallback( // 调用 bwrap;若 --proc /proc 被容器拒绝则降级重试(不带 --proc)        &sandbox_policy_cwd,        command_cwd.as_deref(),        &file_system_sandbox_policy,        bwrap_network_mode(network_sandbox_policy, allow_network_for_proxy), // 网络策略 → BwrapNetworkMode 映射        inner, // 内层命令(bwrap exec 的目标)        proxy_controls,        !no_proc, // mount_proc 开关    );}// Legacy path: Landlock enforcement only, when bwrap sandboxing is not enabled. // 旧路径:仅 Landlock(兼容老内核,无 bwrap 时回退)if let Err(e) = apply_permission_profile_to_current_thread(    &permission_profile,    &sandbox_policy_cwd,    /*apply_landlock_fs*/ true, // 旧路径用 Landlock 做文件系统隔离    allow_network_for_proxy,    /*proxy_routed_network*/ false,) {    panic!("error applying legacy Linux sandbox restrictions: {e:?}");}exec_or_panic(command); // execvp 进入目标命令

为什么这样设计:"re-enter this binary"是 Unix 沙箱的经典手法——bwrap exec 的目标不是用户命令,而是 codex-linux-sandbox 自己(带 --apply-seccomp-then-exec)。好处:内层阶段运行在 bwrap 已经建好的命名空间里,seccomp 施加时文件系统视图已就位;且内层不需要任何额外二进制依赖。注释里"never falls back to legacy Landlock on failure"值得强调:安全路径失败时绝不静默降级——回退到更弱的隔离机制等于把门拆了说"没关系有窗户",所以直接 panic。

三、命名空间隔离:bwrap 的"笼子参数"

create_bwrap_flags()(bwrap.rs 第 312-380 行)负责拼装 bwrap 的完整参数列表。命名空间隔离部分是固定模板:

📄 codex-rs/linux-sandbox/src/bwrap.rs (第 331-379 行)

let normalized_command_cwd = normalize_command_cwd_for_bwrap(command_cwd); // 规范化命令 cwd(解析符号链接别名)let mut args = Vec::new();args.push("--new-session".to_string()); // 创建新会话:脱离控制终端,防止沙箱内进程操纵宿主 ttyargs.push("--die-with-parent".to_string()); // 父进程死亡时自动终止——防孤儿进程(用户 Ctrl-C Codex 后命令不会残留)args.extend(filesystem_args); // 插入文件系统挂载参数(第四节细讲)if options.mask_wsl_interop { // WSL 环境:隐藏 Windows 互操作 socket    // WSL's Windows-process bridge uses Unix sockets under /run/WSL. // WSL 通过 /run/WSL 下的 Unix socket 启动 Windows 进程    // Mask them after all filesystem binds so network access cannot bypass // 在所有文件系统绑定之后 mask——防止沙箱内命令绕过 Linux 策略直接拉起无限制的 Windows 进程    // the Linux filesystem policy by launching an unrestricted Windows process.    args.push("--tmpfs".to_string()); // 用空 tmpfs 覆盖 /run/WSL(socket 消失)    args.push(WSL_INTEROP_DIR.to_string());    if !options.mount_proc { // 若不挂载全新 /proc,宿主 procfs 仍可见 → 也要遮住        // Without a fresh procfs, the root bind retains the host procfs. // 否则 /proc/<host-pid>/root 能别名到互操作 socket        args.push("--tmpfs".to_string());        args.push("/proc".to_string());    }}// Request a user namespace explicitly rather than relying on bubblewrap's // 显式请求用户命名空间,而非依赖 bwrap 的自动启用行为// auto-enable behavior, which is skipped when the caller runs as uid 0. // ——uid 0(容器内 root)时 bwrap 会跳过自动 unshare-userargs.push("--unshare-user".to_string()); // 新用户命名空间:沙箱内"root"只是映射的普通用户,无真实特权args.push("--unshare-pid".to_string()); // 新 PID 命名空间:沙箱内进程看不到宿主进程(/proc 隔离)args.push("--unshare-ipc".to_string()); // 新 IPC 命名空间:共享内存/信号量隔离if options.network_mode.should_unshare_network() { // Isolated / ProxyOnly → unshare 网络命名空间    args.push("--unshare-net".to_string()); // 沙箱内无宿主网卡,只有 loopback(代理桥接后才有出口)}// Mount a fresh /proc unless the caller explicitly disables it. // 挂载全新 /proc(只含沙箱内进程)if options.mount_proc {    args.push("--proc".to_string());    args.push("/proc".to_string());}if normalized_command_cwd.as_path() != command_cwd { // cwd 被规范化过(符号链接解析)→ 显式 chdir    // Bubblewrap otherwise inherits the helper's logical cwd, which can be // bwrap 默认继承辅助进程的逻辑 cwd,可能是沙箱内不存在的符号链接别名    // a symlink alias that disappears once the sandbox only mounts // ——显式进入规范化后的 cwd,保证相对路径与挂载视图对齐    // canonical roots. Enter the canonical command cwd explicitly so    args.push("--chdir".to_string());    args.push(path_to_string(normalized_command_cwd.as_path()));}args.push("--cap-drop".to_string()); // 丢弃所有 Linux capabilities(CAP_SYS_ADMIN 等)args.push("ALL".to_string()); // ——沙箱内进程无法 mount/ptrace/改网络配置args.push("--".to_string()); // bwrap 参数结束分隔符args.extend(command); // 后面跟要执行的命令(新路径下 = codex-linux-sandbox --apply-seccomp-then-exec ...)

为什么这样设计:五个命名空间/会话标志各有明确职责:--new-session(脱离 tty,防沙箱内进程劫持终端)、--die-with-parent(防孤儿——这是用户体验问题:用户杀掉 Codex 后,沙箱内的 npm install 不应该还在跑)、--unshare-user(核心安全机制:沙箱内"root"只是 uid 映射的假 root,真实内核权限为零)、--unshare-pid(进程隔离 + /proc 视图隔离)、--unshare-ipc(共享内存隔离)。注意 --cap-drop ALL 放在最后——bwrap 的参数顺序有语义:先建命名空间、再挂载、最后降权,确保降权后不再需要任何特权操作。WSL interop mask 的细节尤其值得品味:沙箱内命令可以通过 /run/WSL 的 Unix socket 直接启动 Windows 进程——那条路径完全绕过 Linux 文件系统策略,所以必须用 tmpfs 盖住。

"全盘可写 + 网络隔离"这种组合走的是简化分支(第 272-309 行)——文件系统不需要 bwrap,但网络命名空间仍要切:

📄 codex-rs/linux-sandbox/src/bwrap.rs (第 272-309 行)

fn create_bwrap_flags_full_filesystem(command: Vec<String>, options: BwrapOptions) -> BwrapArgs { // 全盘可写场景:文件系统原样保留,只隔离网络    let mut args = vec![        "--new-session".to_string(), // 同主路径:脱离 tty        "--die-with-parent".to_string(), // 防孤儿        "--bind".to_string(), // 注意是 --bind(可写)而非 --ro-bind——全盘可写场景        "/".to_string(),        "/".to_string(),        // Preserve nodev on the root bind while exposing only standard devices. // 根绑定保留 nodev,只暴露标准设备节点        "--dev".to_string(),        "/dev".to_string(),        // Restore shared memory without exposing other host devices. // /dev/shm 单独绑回(IPC 需要),不暴露其他宿主设备        "--bind-try".to_string(),        "/dev/shm".to_string(),        "/dev/shm".to_string(),        // Always enter a fresh user namespace so root inside a container does // 始终进入新用户命名空间:容器内 root 无需 CAP_SYS_ADMIN 就能创建其余命名空间        // not need ambient CAP_SYS_ADMIN to create the remaining namespaces.        "--unshare-user".to_string(),        "--unshare-pid".to_string(),        "--unshare-ipc".to_string(),    ];    if options.network_mode.should_unshare_network() { // 网络隔离仍生效(这是走 bwrap 的唯一理由)        args.push("--unshare-net".to_string());    }    if options.mount_proc {        args.push("--proc".to_string());        args.push("/proc".to_string());    }    args.push("--cap-drop".to_string()); // 同样降权:全盘可写 ≠ 全特权    args.push("ALL".to_string());    args.push("--".to_string()); // bwrap 参数结束分隔符(后面跟要执行的命令)    args.extend(command);    BwrapArgs { args, preserved_files: Vec::new(), synthetic_mount_targets: Vec::new(), protected_create_targets: Vec::new() }}

为什么这样设计:这个分支体现了"按需最小化"原则——文件系统既然全盘可写,就不做只读挂载(省掉几百个 mount 操作的启动开销),但网络隔离和降权仍然执行。注意 --bind / /(可写)与主路径的 --ro-bind / /(只读)的区别:同一个 bwrap,参数不同,安全语义完全不同。而"容器内 root 无需 CAP_SYS_ADMIN"这条注释解释了为什么显式 --unshare-user 如此重要——在 Docker/K8s 里以 root 运行时,bwrap 的自动用户命名空间启用会被跳过(因为 uid 0 不需要映射),不显式请求就会静默失去隔离。

四、文件系统视图:挂载分层的"洋葱模型"

create_filesystem_args()(bwrap.rs 第 398-526 行)是 bwrap.rs 里最复杂的函数——它把权限画像翻译成有序的挂载参数序列。官方注释(第 384-397 行)明确列出了六步顺序,这是理解整个函数的钥匙:

📄 codex-rs/linux-sandbox/src/bwrap.rs (第 398-526 行,节选)

/// Build the bubblewrap filesystem mounts for a given filesystem policy. // 为给定文件系统策略构建 bwrap 挂载参数/// The mount order is important: // 挂载顺序至关重要(bwrap 按序执行,后挂的覆盖先挂的):/// 1. Full-read policies, and restricted policies that explicitly read `/`, // ① 全盘可读 / 受限但显式读 / → --ro-bind / /;其他受限读 → --tmpfs / + 逐根 ro-bind///    use `--ro-bind / /`; other restricted-read policies start from///    `--tmpfs /` and layer scoped `--ro-bind` mounts./// 2. `--dev /dev` mounts a minimal writable `/dev` with standard device nodes // ② --dev /dev:最小可写设备树(null/zero/random/urandom/tty)///    (including `/dev/urandom`) even under a read-only root./// 3. Unreadable ancestors of writable roots are masked before their child // ③ 可写根的"不可读祖先"先 mask——否则子挂载会被父级只读挡住///    mounts are rebound so nested writable carveouts can be reopened safely./// 4. `--bind <root> <root>` re-enables writes for allowed roots, including // ④ --bind 重新启用可写根(含 /dev/shm 等 /dev 下子路径)///    writable subpaths under `/dev` (for example, `/dev/shm`)./// 5. `--ro-bind <subpath> <subpath>` re-applies read-only protections under // ⑤ --ro-bind 在可写根下重新施加只读保护(.git/.agents/.codex 在此生效)///    those writable roots so protected subpaths win./// 6. Nested unreadable carveouts under a writable root are masked after that // ⑥ 可写根下的嵌套不可读区域最后 mask;无关的不可读根也在此阶段处理///    root is bound, and unrelated unreadable roots are masked afterward.fn create_filesystem_args(    file_system_sandbox_policy: &FileSystemSandboxPolicy, // 文件系统策略(读写根 + 不可读 glob)    cwd: &Path, // 策略基准目录(相对路径以此为根解析)    glob_scan_max_depth: Option<usize>, // glob 展开深度上限) -> Result<BwrapArgs> {    let unreadable_globs = file_system_sandbox_policy.get_unreadable_globs_with_cwd(cwd); // 取出"不可读 glob"规则(如 ~/.ssh/**)    let mut writable_roots = file_system_sandbox_policy // 可写根清单        .get_writable_roots_with_cwd(cwd)        .into_iter()        .filter(|writable_root| writable_root.root.as_path().exists()) // bwrap 要求 bind 目标必须存在——过滤掉不存在的路径(跨平台配置的无害残留)        .collect::<Vec<_>>();    if writable_roots.is_empty() && file_system_sandbox_policy.has_full_disk_write_access() && !unreadable_globs.is_empty() { // 全盘可写但有不可读 glob → 需要把 / 作为可写根来承载 mask        writable_roots.push(WritableRoot { root: AbsolutePathBuf::from_absolute_path("/")?, read_only_subpaths: Vec::new(), protected_metadata_names: Vec::new() });    }    let unreadable_roots = file_system_sandbox_policy // 不可读根(具体路径)        .get_unreadable_roots_with_cwd(cwd)        .into_iter().map(AbsolutePathBuf::into_path_buf).collect::<Vec<_>>();    // Bubblewrap can only mask concrete paths. Expand unreadable glob patterns // bwrap 只能 mask 具体路径 → 用 ripgrep 把 glob 展开成现有文件清单    // to the existing matches we can see before constructing the mount overlay; // (在构建挂载层之前完成;核心工具助手仍在读取时直接评估原始 pattern)    unreadable_roots.extend(        expand_unreadable_globs_with_ripgrep(&unreadable_globs, cwd, glob_scan_max_depth)? // 展开失败(如 ripgrep 缺失)→ 返回 Err,fail loud            .into_iter().map(AbsolutePathBuf::into_path_buf),    );    unreadable_roots.sort(); // 排序保证确定性(bwrap 参数顺序影响覆盖关系)    unreadable_roots.dedup();    let args = if file_system_sandbox_policy.has_full_disk_read_access() { // 分支 A:全盘可读 → ro-bind / + dev        vec!["--ro-bind".to_string(), "/".to_string(), "/".to_string(), "--dev".to_string(), "/dev".to_string()]    } else { // 分支 B:受限读 → tmpfs / + 逐根 ro-bind(白名单式)        let mut args = vec!["--tmpfs".to_string(), "/".to_string(), "--dev".to_string(), "/dev".to_string()];        let mut readable_roots: BTreeSet<PathBuf> = file_system_sandbox_policy // 可读根集合(BTreeSet 保证有序)            .get_readable_roots_with_cwd(cwd).into_iter().map(PathBuf::from).collect();        if file_system_sandbox_policy.include_platform_defaults() { // :minimal 策略:补上系统级只读根            readable_roots.extend( // /bin /usr /etc /lib... + Nix store——保证动态链接器能找到 .so                LINUX_PLATFORM_DEFAULT_READ_ROOTS.iter().map(|path| PathBuf::from(*path)).filter(|path| path.exists()),            );        }        if readable_roots.iter().any(|root| root == Path::new("/")) { // 受限策略显式请求 / → 退化为 ro-bind /(宽读基线)            args = vec!["--ro-bind".to_string(), "/".to_string(), "/".to_string(), "--dev".to_string(), "/dev".to_string()];        } else {            for root in readable_roots { // 逐根 ro-bind(不存在的跳过——bwrap 要求目标存在)                if !root.exists() { continue; }                args.extend(["--ro-bind".to_string(), path_to_string(&root), path_to_string(&root)]);            }        }    }    // …后续步骤:mask 不可读祖先 → bind 可写根 → ro-bind 受保护子路径(.git/.agents/.codex)→ mask 嵌套不可读

为什么这样设计:"洋葱模型"的核心是bwrap 按参数顺序执行挂载,后挂的覆盖先挂的——所以顺序就是安全语义。全盘可读走 --ro-bind / /(一个 mount 搞定);受限读走 --tmpfs / + 逐根 ro-bind(白名单式:没列出来的路径根本不存在)。LINUX_PLATFORM_DEFAULT_READ_ROOTS(/bin、/usr、/etc、/lib64、/nix/store...)是 :minimal 策略的"系统级豁免"——注释特意强调"intentionally system-level paths only ... so include_platform_defaults does not silently widen access to user data":平台默认只补系统二进制,绝不碰用户数据目录。而 glob 展开用 ripgrep 而非 Rust 原生遍历,是因为它快(并行 + 跳过 .gitignore)且能处理 ** 语义——但 bwrap 本身不认识 glob,所以必须在构建挂载层之前把 pattern 物化成具体路径清单。

三种策略类型对应的挂载起点一目了然:

策略类型
文件系统挂载起点
网络命名空间
全盘可读 + 受限写(默认)
--ro-bind / /
 → --dev /dev → bind 可写根 → ro-bind .git/.agents/.codex
按 BwrapNetworkMode(FullAccess 不 unshare)
受限读(白名单)
--tmpfs /
 → --dev /dev → 逐根 ro-bind + 平台默认只读根
同上
全盘可写 + 网络隔离
--bind / /
(可写)→ --dev /dev → bind-try /dev/shm
--unshare-net + ProxyOnly 桥接
全盘可写 + 无不可读 glob + FullAccess
不套 bwrap(原样透传,零开销)
宿主网络命名空间

五、WSL1 检测与用户命名空间探测

bwrap 的硬依赖是用户命名空间(--unshare-user)。WSL1 的内核不支持创建用户命名空间,所以 Codex 在启动前就检测并给出明确指引。检测逻辑在 sandboxing/src/bwrap.rs(第 138-159 行):

📄 codex-rs/sandboxing/src/bwrap.rs (第 138-159 行)

pub(crate) fn is_wsl1() -> bool { // 判断当前是否 WSL1(不支持 bwrap)    std::fs::read_to_string("/proc/version") // 读内核版本字符串(WSL 会在此留下 "Microsoft" + "wslN" 标记)        .is_ok_and(|proc_version| proc_version_indicates_wsl1(&proc_version))}fn proc_version_indicates_wsl1(proc_version: &str) -> bool { // 解析 /proc/version 里的 WSL 版本号    let proc_version = proc_version.to_ascii_lowercase(); // 统一小写("WSL2"/"wsl2" 都要认)    let mut remaining = proc_version.as_str();    while let Some(marker) = remaining.find("wsl") { // 逐个查找 "wsl" 标记(字符串里可能出现多处)        let version_start = marker + "wsl".len();        let version_digits: String = remaining[version_start..] // 取 wsl 后面的连续数字作为版本号            .chars().take_while(char::is_ascii_digit).collect();        if let Ok(version) = version_digits.parse::<u32>() {            return version == 1; // 显式标注 wsl1 → WSL1;wsl2/3 → 不是        }        remaining = &remaining[version_start..]; // 没跟数字(如 "microsoft-standard")→ 继续找下一个 wsl    }    proc_version.contains("microsoft") && !proc_version.contains("microsoft-standard") // 兜底:有 microsoft 但无 standard → 老 WSL1 内核签名}

为什么这样设计:WSL1 和 WSL2 的内核完全不同——WSL1 是"翻译层"(Linux syscall → Windows NT),根本没有用户命名空间;WSL2 是真 Linux 内核,支持。检测不能靠 /proc/sys/kernel/osrelease 的简单字符串匹配(不同发行版格式各异),而是解析 /proc/version 里的 "wsl" + 数字组合——这是微软在两种 WSL 里都保留的标记。兜底规则(microsoft 但非 microsoft-standard)覆盖早期 WSL1 内核不写版本号的情况。

除了 WSL1,还有一类更隐蔽的问题:用户命名空间被系统策略禁用(某些企业 Linux、Docker 默认 seccomp profile)。Codex 用一次"真实探测"来检测——实际跑一个最小 bwrap 命令看它能不能创建用户命名空间(sandboxing/src/bwrap.rs 第 74-136 行,节选):

📄 codex-rs/sandboxing/src/bwrap.rs (第 74-90, 161-166 行)

fn system_bwrap_has_user_namespace_access(system_bwrap_path: &Path, timeout: Duration) -> bool { // 探测:bwrap 能否创建用户命名空间    let mut child = match Command::new(system_bwrap_path) // 用系统 bwrap(非 bundled)做真实探测        .args([            "--unshare-user", // 请求用户命名空间——这正是我们要测的能力            "--unshare-net", // 顺带 unshare 网络(最小化副作用)            "--ro-bind", "/", "/", // 只读绑定根(无写操作,安全)            "/bin/true", // 执行 true:成功 = 命名空间创建成功        ])        .stdout(Stdio::null())        .stderr(Stdio::piped()) // 捕获 stderr——失败原因在里面        .spawn()    {        Ok(child) => child,        Err(_) => return true, // spawn 都失败(bwrap 不存在/不可执行)→ 不在此处报警,交给 MISSING_BWRAP_WARNING    };    let deadline = Instant::now() + timeout; // 500ms 超时:探测不能拖慢启动    loop {        match child.try_wait() { // 非阻塞轮询(每 50ms 一次)            Ok(Some(status)) => { /* 进程结束 → 读 stderr 判断失败原因 */ }            Ok(None) => { if Instant::now() >= deadline { let _ = child.kill(); return true; } thread::sleep(50ms); } // 超时 → kill,视为"可用"(不误报)            Err(_) => { let _ = child.kill(); return true; } // wait 出错 → 保守处理        }    }}fn is_user_namespace_failure(output: &Output) -> bool { // 判断 stderr 是否包含"用户命名空间被拒"的特征文本    let stderr = String::from_utf8_lossy(&output.stderr);    USER_NAMESPACE_FAILURES.iter().any(|failure| stderr.contains(failure)) // 四种已知失败签名(见下方常量表)}const USER_NAMESPACE_FAILURES: [&str; 4] = [ // 用户命名空间被拒的四种典型 stderr 文本    "loopback: Failed RTM_NEWADDR", // unshare-net 后配置 loopback 地址失败    "loopback: Failed RTM_NEWLINK", // unshare-net 后创建 loopback 链路失败    "setting up uid map: Permission denied", // 写 /proc/self/uid_map 被拒(内核禁用了 user namespace)    "No permissions to create a new namespace", // bwrap 自己的报错话术];

为什么这样设计:"真实探测"优于"配置检查"——用户命名空间的可用性取决于内核编译选项 + sysctl(kernel.unprivileged_userns_clone)+ seccomp profile + AppArmor/SELinux 策略,没有任何单一配置文件能准确回答"能不能用"。所以 Codex 直接跑一次最小 bwrap 命令:成功 = 可用;失败且 stderr 命中四种签名之一 = 不可用(给出明确指引);超时或 spawn 失败 = 保守视为可用(不误报,让后续真实执行去暴露问题)。500ms 超时 + 50ms 轮询的取舍是"启动延迟 vs 探测可靠性"——沙箱初始化在每次 shell 命令前发生,不能让用户等太久。

六、数据流:从权限声明到沙箱内 exec

Linux 沙箱两阶段执行(完整链路)

① 决策:SandboxManager.transform()(第 21 讲)

should_sandbox + get_platform_sandbox → LinuxSeccomp;生成 codex-linux-sandbox argv。

▼

② 外层:run_main() → create_bwrap_flags()

🔹 命名空间:--new-session + --die-with-parent + unshare user/pid/ipc[/net]🔹 文件系统:ro-bind /(或 tmpfs /)→ dev → bind 可写根 → ro-bind .git/.agents/.codex🔹 降权:--cap-drop ALL;内层命令 = codex-linux-sandbox --apply-seccomp-then-exec

▼

③ bwrap exec:进入沙箱

新命名空间 + 挂载视图生效;exec codex-linux-sandbox(内层阶段)。

▼

④ 内层:verify → capget → seccomp → fork+exec

🔹 verify_fd_mounts + capget(能力必须全零,否则 panic)🔹 ProxyOnly:activate_proxy_routes_in_netns(loopback 桥接)🔹 apply_permission_profile_to_current_thread(seccomp + no_new_privs)→ fork → execvp 用户命令

▼

⑤ 运行 + 退出:信号转发 → waitpid → exit_with_wait_status

🔹 父进程安装 signal forwarders(Esc/Ctrl-C 穿透 PID 命名空间)🔹 waitpid 透传退出码 → violation.rs 三层归因(第 21 讲)→ 模型据此决策

本讲拆完了 Linux 沙箱的"两阶段 + 洋葱挂载"。三个值得记住的设计:① seccomp 必须在 bwrap 之后施加——过滤器不可卸载,顺序是硬约束;内层用 capget 复核能力全零,防御纵深覆盖嵌套容器场景;② 挂载顺序即安全语义——ro-bind / → dev → bind 可写根 → ro-bind .git/.agents/.codex,后挂的覆盖先挂的,受保护子路径永远赢;③ WSL1 与用户命名空间探测——不查配置文件,直接跑一次最小 bwrap 命令做真实探测,500ms 超时保守处理。下一讲(第 23 讲)进入 Landlock + Seatbelt 双引擎:Landlock 在内核侧限制文件访问的机制细节,以及 macOS Seatbelt 策略文件的生成逻辑。

📚 系列导航

← 第 21 讲:Sandbox Manager 与策略转换

→ 第 23 讲:Landlock + Seatbelt 双引擎

关注公众号「AI技术推荐官」获取更多源码解析内容

相关学习资料