# Linux 沙箱 · bubblewrap 与 Landlock

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

- 作者：David（道雾轩）
- 专栏：Codex 源码解读（https://daiw.net/manual/codex-source.md）
- 最后更新：2026-09-29
- 原文：https://daiw.net/manual/codex-source/linux-sandbox
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

# Linux 沙箱 · bubblewrap 与 Landlock

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

## 用户看到的样子

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

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

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

```rust
    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

```mermaid
flowchart TB
  CORE[SandboxManager transform<br/>SandboxType LinuxSeccomp] --> OUT[codex-linux-sandbox 外层<br/>解析 JSON 配置档]
  OUT -->|全盘可写且无代理| S0[只装 seccomp 然后 exec]
  OUT -->|其余情况| BW[拼 bwrap 参数<br/>挂载 命名空间 丢弃 capability]
  BW --> BX[bwrap<br/>系统自带或内置]
  BX --> IN[codex-linux-sandbox 内层<br/>apply-seccomp-then-exec 模式]
  IN --> CK[确认没有残留 capability<br/>接通代理桥]
  CK --> SC[no_new_privs 加 seccomp]
  SC --> CMD[fork 后 exec 用户命令]
```

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

```rust
/// 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`。挂载顺序写在注释里：

```rust
/// 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`）

这正是[权限模型](https://daiw.net/manual/codex-source/permissions-model)里“最深者胜”在挂载层面的实现：后挂的覆盖先挂的，所以按路径深度排序后逐层叠加。几个细节：

- **遮蔽**：拒绝读的目录挂一个权限 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`；全盘可写且网络开着则不装。

```rust
    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，双保险。受管代理（见[网络代理](https://daiw.net/manual/codex-source/network-proxy)）时情况更复杂：命令必须能连到宿主机上的代理，又不能连别处。`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》对照

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

---

上一篇：[macOS 沙箱 · Seatbelt 策略生成](https://daiw.net/manual/codex-source/seatbelt) · 下一篇：[Windows 沙箱 · 沙箱用户、ACL 与防火墙](https://daiw.net/manual/codex-source/windows-sandbox)
