Linux 沙箱 · bubblewrap 与 Landlock

Linux 上的沙箱由 codex 可执行文件以 codex-linux-sandbox 的身份再跑一次来完成:外层把 PermissionProfile 翻译成 bubblewrap 的挂载与命名空间参数,bwrap 里再次进入同一个程序,确认没有残留特权、接好代理桥,装上 no_new_privs 与 seccomp,最后 exec 用户命令。本篇讲清这条两段式流水线、挂载顺序、seccomp 的三种模式、只许连代理时的网络桥,以及 bwrap 从哪里来;Landlock 如今只剩后备代码。

作者 David更新于 第 30 篇(共 57 篇)

Linux 沙箱 · bubblewrap 与 Landlock

上一篇的 macOS 有现成的 sandbox-exec。Linux 没有一个系统级的“按策略跑命令”工具,Codex 的做法是自带一个辅助程序:文件系统视图交给 bubblewrap(bwrap)用挂载命名空间搭,网络和危险系统调用交给 seccomp 封。标题里的 Landlock 曾经是主力,现在退成了后备代码。

用户看到的样子

手册的沙箱、审批与安全建议先装系统的 bubblewrap 包。Codex 优先用 PATH 上找到的 bwrap,找不到就退回随包分发的内置版本,并在启动时提示;bwrap 建不了用户命名空间时也会提示。WSL2 走普通 Linux 路径,WSL1 不支持,需要沙箱的命令直接报错。想单独试策略同样用 codex sandbox -- <命令>。

同一个二进制的另一个身份

没有单独安装的 codex-linux-sandbox。codex-arg0 在启动早期看 argv[0] 的文件名,是这个名字就直接交给沙箱入口:

    if exe_name == CODEX_LINUX_SANDBOX_ARG0 {
        // Safety: [`run_main`] never returns.
        codex_linux_sandbox::run_main();
    } else if exe_name == APPLY_PATCH_ARG0 || exe_name == MISSPELLED_APPLY_PATCH_ARG0 {
        codex_apply_patch::main();
    }

(codex-rs/arg0/src/lib.rs:97)

这个名字从哪来:正常启动时,prepare_path_entry_for_codex_aliases 在 $CODEX_HOME/tmp/arg0/(权限设为 0700)下建一个带锁文件的临时目录,放几个指向当前可执行文件的符号链接:apply_patch 与拼错的 applypatch、Linux 上的 codex-linux-sandbox、Unix 上的 codex-execve-wrapper,并把目录加到 PATH 前面;清理程序会删掉没人持锁的旧目录。codex-linux-sandbox 那个链接的路径作为 codex_linux_sandbox_exe 传给后续的配置。

SandboxManager::transform 处理 SandboxType::LinuxSeccomp 时,就把命令改写成“这个链接 + 参数”:--sandbox-policy-cwd、--command-cwd、把整个 PermissionProfile 序列化成 JSON 的 --permission-profile,受管网络时再加 --managed-network,最后 -- 接原命令(codex-rs/sandboxing/src/landlock.rs:26)。策略就这样原样穿过进程边界,辅助程序那头反序列化后重新计算一遍。

两段式:先 bwrap,再 seccomp

图表加载中…

入口函数的注释把顺序写得很清楚:

/// Entry point for the Linux sandbox helper.
///
/// The sequence is:
/// 1. When needed, wrap the command with bubblewrap to construct the
///    filesystem view.
/// 2. Apply in-process restrictions (no_new_privs + seccomp).
/// 3. `execvp` into the final command.

(codex-rs/linux-sandbox/src/linux_run_main.rs:160)

之所以分两段,是因为 no_new_privs 会禁止 setuid 提权,而不少发行版的 bwrap 正是 setuid 程序:先装 seccomp 就起不了 bwrap。于是外层只负责拼参数、起 bwrap;bwrap 里执行的“内层命令”还是当前程序,带上隐藏参数 --apply-seccomp-then-exec 和同一份配置档 JSON。bwrap 支持 --argv0 时(0.9.0 起),内层以 codex-linux-sandbox 为 argv[0] 启动,老版本则把内层命令换成当前进程的 argv[0] 路径,保证仍能命中上面的分派。

内层先做两道自检:校验经描述符传入的挂载,再用 capget 确认有效与许可 capability 全为零,有残留就直接 panic;受管网络时接通代理桥(见下文);然后装 no_new_privs 与 seccomp,fork 出子进程 exec 用户命令,自己留下转发信号、等子进程结束,并以同样的状态退出。

bubblewrap 的挂载布局

bwrap 的参数由 create_bwrap_command_args 生成:先是 --new-session、--die-with-parent,接着文件系统挂载,然后是命名空间:总是 --unshare-user(注释说明,调用方是 uid 0 时 bwrap 不会自动开用户命名空间,所以显式要求)、--unshare-ipc,默认还有 --unshare-pid(执行 bwrap 前会补上 --as-pid-1)与新的 --proc /proc,网络不是完全放开时再加 --unshare-net,最后 --cap-drop ALL。挂载顺序写在注释里:

/// The mount order is important:
/// 1. Full-read policies, and restricted policies that explicitly read `/`,
///    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
///    (including `/dev/urandom`) even under a read-only root.
/// 3. Unreadable ancestors of writable roots are masked before their child
///    mounts are rebound so nested writable carveouts can be reopened safely.
/// 4. `--bind <root> <root>` re-enables writes for allowed roots, including
///    writable subpaths under `/dev` (for example, `/dev/shm`).
/// 5. `--ro-bind <subpath> <subpath>` re-applies read-only protections under
///    those writable roots so protected subpaths win.
/// 6. Nested unreadable carveouts under a writable root are masked after that
///    root is bound, and unrelated unreadable roots are masked afterward.

(codex-rs/linux-sandbox/src/bwrap.rs:410)

这正是权限模型里“最深者胜”在挂载层面的实现:后挂的覆盖先挂的,所以按路径深度排序后逐层叠加。几个细节:

  • 遮蔽:拒绝读的目录挂一个权限 000 的空 tmpfs 再改成只读(里面还有要重新开放的可写子目录时用 111,只能穿过不能列目录);拒绝读的文件用 --ro-bind-data 把 /dev/null 的内容盖上去。拒绝路径穿过沙箱内可写的符号链接时,直接报错,注释解释说解析后再遮蔽是“检查到使用”之间的竞态。
  • glob 读禁区:bwrap 只认具体路径,所以起沙箱前先用 rg --files --hidden --no-ignore --glob 把 glob 展开(没有 rg 时退回内置的 globset 遍历),超过 8192 个匹配就失败,深度可由 glob_scan_max_depth 限制。
  • 不存在的路径:bwrap 的挂载目标必须存在,不存在的可写根直接跳过;可写根下还不存在的 .git、.codex 等保护路径,在第一个缺失的路径分量上挂一个只读的空目录或空文件占位,并登记为“合成挂载目标”,命令结束后由外层清理,这时外层不再直接 exec bwrap,而是 fork 出来等它结束。
  • :minimal:只读特定路径时从空的 tmpfs 根起步,再只读挂上 /bin、/sbin、/usr、/etc、/lib、/lib64、/nix/store、/run/current-system/sw 中存在的那些。
  • 守护进程套接字与 WSL:app-server 控制套接字所在目录在沙箱里被遮住;文件系统受限时,/run/WSL 被一层 tmpfs 盖掉,沙箱里的命令没法借 WSL interop 启动 Windows 程序。

seccomp:网络与危险系统调用

内层按网络策略选一种 seccomp 模式:网络关闭时 Restricted;受管代理时 ProxyRouted;网络开着但文件系统受限时 VmSocketRestricted;全盘可写且网络开着则不装。

    if mode != NetworkSeccompMode::VmSocketRestricted {
        deny_syscall(&mut rules, libc::SYS_ptrace);
        deny_syscall(&mut rules, libc::SYS_process_vm_readv);
        deny_syscall(&mut rules, libc::SYS_process_vm_writev);
    }
    // io_uring can create AF_VSOCK sockets without a socket() syscall, so
    // keep it unavailable in every mode with socket-family restrictions.
    deny_syscall(&mut rules, libc::SYS_io_uring_setup);
    deny_syscall(&mut rules, libc::SYS_io_uring_enter);
    deny_syscall(&mut rules, libc::SYS_io_uring_register);

    match mode {
        NetworkSeccompMode::Restricted => {
            deny_syscall(&mut rules, libc::SYS_connect);
            deny_syscall(&mut rules, libc::SYS_accept);
            deny_syscall(&mut rules, libc::SYS_accept4);
            deny_syscall(&mut rules, libc::SYS_bind);
            deny_syscall(&mut rules, libc::SYS_listen);
            // ...

(codex-rs/linux-sandbox/src/landlock.rs:190)

过滤器默认放行,命中规则返回 EPERM。Restricted 还封了 sendto、getsockopt 等一串调用,socket 与 socketpair 只许 AF_UNIX,注释特意说明留着 recvfrom,因为 cargo clippy 这类工具靠 socketpair 管理子进程。ProxyRouted 只许创建 AF_INET、AF_INET6 套接字(托管配置放开全部 Unix 套接字时再加 AF_UNIX),socketpair 仍限于 AF_UNIX;VmSocketRestricted 只拒 AF_VSOCK:虚拟机套接字能直通宿主服务,在 WSL2 里还能借 interop 的别名启动 Windows 进程。这个文件名还叫 landlock.rs,开头的模块注释却写明:文件系统限制由 bubblewrap 负责,Landlock 只是遗留和后备工具。

网络:命名空间加一座桥

网络关闭时,--unshare-net 给命令一个只有回环接口的新网络命名空间,再加上 seccomp,双保险。受管代理(见网络代理)时情况更复杂:命令必须能连到宿主机上的代理,又不能连别处。proxy_routing.rs 的做法是:

  1. 外层读取 HTTP_PROXY、HTTPS_PROXY、ALL_PROXY 等十几个代理环境变量,只接受指向回环地址的端点,然后为每个端点 fork 一个桥接进程,彼此用 socketpair 相连。桥接进程分离标准输入输出、设置父进程死亡信号、标记为不可转储。
  2. bwrap 以 --unshare-net 启动;内层在新命名空间里拉起回环接口,绑定 127.0.0.1:0,把这个监听套接字本身经 socketpair 以 SCM_RIGHTS 交给宿主侧的桥接进程。
  3. 桥接进程在这个套接字上 accept(套接字仍属于沙箱的命名空间),再用宿主命名空间去连真正的代理端口,双向转发,必要时先写一帧归属令牌,让代理知道流量来自哪次执行。
  4. 内层把代理环境变量改写成新的本地端口,装上 ProxyRouted 模式的 seccomp,再 exec 命令。

于是命令唯一能连通的出口就是代理,是否放行交给代理按域名裁决。crate 的 README 仍把这座桥描述为 “TCP->UDS->TCP”,而 0.158.0 的代码传递的是监听套接字本身,以代码为准。

bwrap 从哪来,Landlock 去哪了

preferred_bwrap_launcher 先在 PATH 里找 bwrap,位于当前工作目录之下的候选一律跳过,再用 --help 的输出探测能力:必须支持 --as-pid-1 与 --perms,--argv0、--ro-bind-fd 可选。系统版本不合格或不存在时,用随包分发的 codex-resources/bwrap:构建时若嵌入了 CODEX_BWRAP_SHA256,执行前先校验 SHA-256,不符就以退出码 8 结束;校验与执行用的是同一个打开的文件描述符(经 /proc/self/fd/N 执行)。这个内置版本来自 codex-bwrap crate:build.rs 用 cc 把 codex-rs/vendor/bubblewrap 下的 C 源码编进来,把 main 改名为 bwrap_main,由一个很薄的 Rust main 调用。

至于 Landlock:features.use_legacy_landlock 已标为弃用,而且在 0.158.0 里,只要策略限制了文件系统写入,辅助程序遇到 --use-legacy-landlock 就直接报错退出,理由是 Landlock 隔离不了 app-server 的 Unix 套接字(codex-rs/linux-sandbox/src/linux_run_main.rs:412)。安装 Landlock 规则的函数还留在代码里,注释写着目前没有使用。

另一个常被放在一起提的 crate 是 codex-process-hardening:pre_main_hardening 在 Linux 上关掉进程可转储(挡住同用户的 ptrace)、把 core 文件上限设为 0、清掉 LD_ 开头的环境变量。它目前被 responses-api-proxy 与 voice-host 两个独立二进制调用;Linux 沙箱只用了其中的 disable_process_dumping,给上面的桥接进程加固,主程序 codex 并不调用它。

和《从 LLM 到 Coding Agent》对照

那本书的权限系统讲“fail-closed”:模式没设就取最保守。Linux 沙箱把这一点贯彻到每个分支:glob 展开过多、拒绝路径穿过可写符号链接、残留 capability、代理端点解析不出来,统统报错退出,而不是降级成无沙箱。Grok Build 的沙箱走的是“Landlock 为主、需要读拒绝时改用 bwrap 重新 exec 自己”;Codex 则反过来,bwrap 是默认路径,Landlock 只剩后备代码。


上一篇:macOS 沙箱 · Seatbelt 策略生成 · 下一篇:Windows 沙箱 · 沙箱用户、ACL 与防火墙

本页目录