深入 Codex 沙盒

21 阅读11分钟

当你让 AI 帮你执行 rm -rf / 时,是什么在保护你的系统?

本文基于 OpenAI Codex 开源仓库的沙盒源码,从内核系统调用层面拆解其跨平台沙盒的完整实现。

为什么 AI 编程助手需要沙盒

2024 年以来,AI 编程助手(Cursor、Codex CLI、Claude Code 等)有一个共同趋势:让 AI 直接执行 shell 命令。这带来了巨大的生产力提升——AI 不仅能写代码,还能自己运行、调试、安装依赖。

但这也打开了潘多拉的盒子。

AI 生成的命令本质上是不可信的。即使模型本身没有恶意意图,它也可能:

  • 误删关键文件(一个错误的 rm 就够了)
  • 修改 .git/hooks 植入后门(下次 git commit 时自动执行)
  • 通过网络外泄代码或环境变量中的密钥
  • 安装恶意依赖包(供应链攻击)

传统的"执行前确认"对话框并不够——用户不可能审查每一条命令的每一个副作用。我们需要的是一个即使 AI 想做坏事也做不到的机制。

这就是沙盒的价值:不依赖信任,而是依赖内核强制

整体架构:三步走

Codex 的沙盒设计可以用一句话概括:在用户态计算策略,在内核态强制执行

                    用户态                              内核态
              ┌─────────────────┐              ┌─────────────────┐
                                                              
  "npm test"    SandboxManager   包装后的命令     OS 安全机制    
 ────────────►│                 │─────────────►│                 
                1. 选择沙盒类型                  macOS: Seatbelt│
                2. 计算生效策略                  Linux: seccomp 
                3. 包装命令                            + bwrap 
                                                              
              └─────────────────┘              └─────────────────┘

整个流程分三步:

  1. SelectInitial() — 根据策略和平台,决定使用哪种沙盒(Seatbelt / seccomp / 无)
  2. Transform() — 将原始命令包装成沙盒命令(生成策略、拼接参数)
  3. Exec() — 启动进程,由操作系统内核接管安全控制

以 macOS 为例,一条简单的 ls -la 会被变换成:

/usr/bin/sandbox-exec \
  -p "(version 1)(deny default)(allow process-exec)..." \
  -DWRITABLE_ROOT_0=/workspace \
  -DWRITABLE_ROOT_0_EXCLUDED_0=/workspace/.git \
  -- ls -la

从这一刻起,ls -la 进程的每一个系统调用都在内核的监视之下。

macOS:Seatbelt 的 deny-default 哲学

什么是 Seatbelt

Seatbelt 是 macOS 内核中的强制访问控制(Mandatory Access Control, MAC)框架。你可能没听过这个名字,但你每天都在用它——macOS App Sandbox、iOS 的应用隔离,底层都是 Seatbelt。

它的工作原理是在内核的系统调用路径上插入一个策略检查点:

应用程序
    │
    │  open("/etc/passwd", O_RDONLY)
    ▼
┌─────────────────────────────┐
│  内核系统调用入口             │
│    │                        │
│    ▼                        │
│  Seatbelt 策略引擎           │
│    │                        │
│    ├─ 匹配到 allow 规则 ──► 继续执行系统调用
│    │                        │
│    └─ 无匹配规则 ──────────► 返回 EPERM(权限拒绝)
│                             │
└─────────────────────────────┘

关键特性:进程自己无法关闭或修改已安装的策略。一旦 sandbox_init() 被调用,策略就固化在内核中,直到进程退出。

Codex 如何生成 Seatbelt 策略

Codex 不使用静态策略文件,而是运行时动态生成策略字符串。这是因为每次执行的命令不同,需要的权限也不同。

策略生成分三层叠加:

第一层:基础策略 — 封死一切,再逐项放行

(version 1)

; 第一行就是:默认拒绝一切
(deny default)

; 然后逐项放行进程运行的最低需求
(allow process-exec)          ; 允许 exec,否则什么都跑不了
(allow process-fork)          ; 允许 fork,子进程继承沙盒策略
(allow signal (target same-sandbox))  ; 允许同沙盒内的信号

; PTY 支持——没有这个,交互式 shell 无法工作
(allow pseudo-tty)
(allow file-read* file-write* (literal "/dev/ptmx"))
(allow file-read* file-write*
  (require-all
    (regex #"^/dev/ttys[0-9]+")
    (extension "com.apple.sandbox.pty")))

; Python multiprocessing 需要 POSIX 信号量
(allow ipc-posix-sem)

; PyTorch/libomp 需要共享内存
(allow ipc-posix-shm-read-data
  ipc-posix-shm-write-create
  ipc-posix-shm-write-unlink
  (ipc-posix-name-regex #"^/__KMP_REGISTERED_LIB_[0-9]+$"))

这个基础策略的设计灵感来自 Chromium 的沙盒策略(代码注释中直接引用了 Chromium 源码链接)。Chrome 的渲染进程沙盒是业界最成熟的实践之一,Codex 站在了巨人的肩膀上。

第二层:文件系统规则 — 动态生成的精细控制

这是最有意思的部分。以一个典型的 "workspace-write" 策略为例:

; 允许读取整个磁盘
(allow file-read*)

; 允许写入 /workspace,但排除 .git 和 .codex
(allow file-write*
  (require-all
    (subpath (param "WRITABLE_ROOT_0"))
    ; 排除 .git 目录本身
    (require-not (literal (param "WRITABLE_ROOT_0_EXCLUDED_0")))
    ; 排除 .git 下的所有内容
    (require-not (subpath (param "WRITABLE_ROOT_0_EXCLUDED_0")))
    ; 排除 .codex 目录本身
    (require-not (literal (param "WRITABLE_ROOT_0_EXCLUDED_1")))
    ; 排除 .codex 下的所有内容
    (require-not (subpath (param "WRITABLE_ROOT_0_EXCLUDED_1")))
  )
)

注意这里用了 param 而不是硬编码路径。实际路径通过命令行 -D 参数传入:

-DWRITABLE_ROOT_0=/Users/dev/my-project
-DWRITABLE_ROOT_0_EXCLUDED_0=/Users/dev/my-project/.git
-DWRITABLE_ROOT_0_EXCLUDED_1=/Users/dev/my-project/.codex

为什么要参数化?因为路径中可能包含特殊字符(空格、引号、括号),如果直接拼接到策略字符串中,可能导致策略注入——类似 SQL 注入,但后果是沙盒被绕过。参数化传递从根本上消除了这个风险。

第三层:网络规则 — 三种模式

完全禁止网络:不添加任何网络规则,deny default 自动拒绝一切
    │
代理模式:只允许连接 loopback 的特定端口
    │  (allow network-outbound (remote ip "localhost:3128"))
    │
完全开放:(allow network-outbound) + TLS/DNS 相关服务

代理模式特别值得一提。当配置了 HTTP_PROXY=http://localhost:3128 时,Codex 会:

  1. 解析环境变量中的代理 URL
  2. 提取 loopback 地址的端口号
  3. 只允许连接这些特定端口
  4. 其他所有网络连接仍然被拒绝

这意味着即使开启了"网络访问",流量也必须经过代理,代理可以做审计和过滤。

一个容易忽略的安全细节

const MacOSPathToSeatbeltExecutable = "/usr/bin/sandbox-exec"

Codex 硬编码了 sandbox-exec 的绝对路径,而不是从 $PATH 搜索。原因很简单:如果攻击者能修改 $PATH,就能让 Codex 执行一个假的 sandbox-exec,这个假程序什么限制都不加就直接运行命令。

/usr/bin 受 macOS SIP(System Integrity Protection)保护,即使 root 也无法修改。如果 SIP 已经被关闭,那攻击者已经有了比沙盒更高的权限,沙盒本来就不是为这种场景设计的。

Linux:三层防御的组合拳

Linux 没有 Seatbelt 这样的统一方案,Codex 组合了三个独立的内核安全机制,形成了一个两阶段的沙盒管道。

为什么需要两阶段

一个关键的工程约束决定了这个设计:bubblewrap 可能需要 setuid 权限,而 seccomp 需要 no_new_privs,两者互斥

no_new_privs 是一个进程标志,设置后进程不能通过 execve 获得额外权限(比如执行 setuid 二进制)。seccomp 要求必须先设置这个标志。但如果先设置了 no_new_privs,setuid 版本的 bubblewrap 就无法工作了。

解决方案:先用 bubblewrap 建立文件系统视图(可能需要 setuid),然后在沙盒内部再设置 no_new_privs + seccomp。

codex-linux-sandbox (helper 二进制)
    │
    │  第一阶段:文件系统隔离
    ▼
bubblewrap (bwrap)
    │  - 创建新的 mount namespace
    │  - 只读绑定挂载 /
    │  - 可写绑定挂载允许的目录
    │  - 可选:unshare network namespace
    │
    │  第二阶段:系统调用过滤
    ▼
codex-linux-sandbox --apply-seccomp-then-exec
    │  - prctl(PR_SET_NO_NEW_PRIVS)
    │  - 安装 seccomp BPF 过滤器
    │  - execvp 用户命令
    ▼
用户命令在双重沙盒中运行

第一阶段:bubblewrap — 重塑文件系统

bubblewrap 利用 Linux 的 mount namespace 创建一个全新的文件系统视图。进程看到的文件系统和宿主机完全不同:

宿主机                          沙盒内
/                               /  (只读)
├── usr/                        ├── usr/  (只读)
├── etc/                        ├── etc/  (只读)
├── home/user/                  │
│   └── project/                ├── home/user/project/  (可写)
│       ├── src/                │   ├── src/  (可写)
│       ├── .git/               │   ├── .git/  (只读!覆盖父级)
│       └── .codex/             │   └── .codex/  (只读!覆盖父级)
├── tmp/                        ├── tmp/  (可写)
├── home/user/.ssh/             │   (不存在)
└── home/user/.aws/             │   (不存在)

关键技巧是挂载顺序

  1. --ro-bind / / 把整个根目录只读挂载
  2. --bind /home/user/project /home/user/project 覆盖为可写
  3. 最后 --ro-bind /home/user/project/.git /home/user/project/.git 再覆盖回只读

后挂载的覆盖先挂载的,就像 CSS 的层叠规则一样。这样就实现了"目录可写但子目录只读"的精细控制。

对于网络隔离,bubblewrap 使用 --unshare-net 创建一个空的网络命名空间——里面没有任何网卡,连 loopback 都没有。进程尝试任何网络操作都会失败。

第二阶段:seccomp — 系统调用级别的最后防线

即使 bubblewrap 隔离了文件系统,进程仍然可以通过某些系统调用做危险的事情。seccomp 是最后一道防线。

seccomp(Secure Computing Mode)在内核中安装一个 BPF(Berkeley Packet Filter)程序。每次系统调用发生时,BPF 程序会检查系统调用号和参数,决定放行还是拒绝:

进程: connect(fd, {AF_INET, 8.8.8.8:53}, ...)
    │
    ▼
内核 seccomp BPF 过滤器
    │
    │  检查: syscall == SYS_connect
    │  规则: Restricted 模式下拒绝 connect
    │
    ▼
返回 EPERM — 连接被拒绝

Codex 的 seccomp 过滤器有两种模式:

Restricted 模式(默认)— 封死所有网络:

系统调用为什么拒绝
connect阻止所有出站连接
bind阻止绑定端口
listen / accept阻止接受入站连接
sendto / sendmmsg阻止 UDP 等无连接发送
socket(非 AF_UNIX)只允许 Unix 域 socket
ptrace防止通过调试器逃逸沙盒
io_uring_*io_uring 可以绕过 seccomp,必须封死

注意一个有趣的例外:recvfrom 被故意保留了。注释中解释说,cargo clippy 等工具通过 socketpair + 子进程管理来工作,需要 recvfrom。这是安全性和可用性之间的典型权衡。

ProxyRouted 模式 — 只允许通过代理:

socket(AF_INET, ...)  → 放行(需要连接本地代理)
socket(AF_INET6, ...) → 放行
socket(AF_UNIX, ...)  → 拒绝(防止绕过代理)

这个模式配合 bubblewrap 的网络命名空间隔离使用:先隔离网络,然后通过 veth pair 建立一个到宿主机代理的桥接。进程只能通过这个桥接访问网络,所有流量都经过代理审计。

PR_SET_NO_NEW_PRIVS:被低估的安全基石

prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);

这一行代码看起来不起眼,但它是整个 Linux 沙盒的安全基石。设置后:

  • 进程不能通过 execve 执行 setuid/setgid 二进制来提权
  • 子进程继承这个标志,无法取消
  • 这是安装 seccomp 过滤器的前提条件

没有它,沙盒内的进程可以执行 /usr/bin/sudo 或其他 setuid 程序来逃逸。

Landlock:优雅降级的后备方案

在某些受限的容器环境中(比如 Docker 默认配置),bubblewrap 可能无法工作(需要 CAP_SYS_ADMIN 或 setuid)。这时 Codex 退回到 Landlock。

Landlock 是 Linux 5.13 引入的安全模块,允许非特权进程自我限制文件系统访问:

// 创建 Landlock ruleset
int ruleset_fd = landlock_create_ruleset(&attr, sizeof(attr), 0);

// 添加规则:只允许读取 /usr
landlock_add_rule(ruleset_fd, LANDLOCK_RULE_PATH_BENEATH, &rule, 0);

// 应用到当前进程
landlock_restrict_self(ruleset_fd, 0);

Landlock 的优势是不需要任何特权,但能力比 bubblewrap 弱——不支持受限读取模式(只能限制写入),所以只作为后备。

策略系统:灵活性与安全性的平衡

两套策略的历史包袱

Codex 有两套策略系统并存,这不是设计失误,而是演进的结果:

第一代:SandboxPolicy(统一策略)

// 四种预设模式,简单直观
SandboxPolicy{Type: "danger-full-access"}  // 完全不限制
SandboxPolicy{Type: "read-only"}           // 只读
SandboxPolicy{Type: "workspace-write"}     // 可写工作区
SandboxPolicy{Type: "external-sandbox"}    // 外部沙盒

第二代:FileSystemSandboxPolicy + NetworkSandboxPolicy(分离策略)

// 精细到每个路径、每种访问模式
FileSystemSandboxPolicy{
    Kind: "restricted",
    Entries: []FileSystemSandboxEntry{
        {Path: "/workspace",       Access: "write"},
        {Path: "/workspace/.git",  Access: "read"},   // 覆盖父级
        {Path: "/workspace/.codex", Access: "none"},   // 完全禁止
    },
}

第二代策略支持路径级别的 none(完全禁止访问),支持 minimal(只包含最小平台默认路径),灵活性远超第一代。但为了向后兼容,两套并存,通过 FromLegacySandboxPolicy() 桥接。

运行时权限合并:只增不减

AI 执行的不同命令可能需要不同的权限。比如 npm install 需要网络和写入 node_modules,但 npm test 只需要读取。

Codex 通过 AdditionalPermissions 机制解决这个问题:

基础策略(保守)          附加权限(按需)           生效策略
┌──────────────┐    ┌──────────────┐    ┌──────────────┐
 FS: 只写 cwd   +   FS.Write:     =   FS:  cwd + 
 Net: 禁止           /node_modules│      /node_modules│
                    Net: 允许          Net: 允许     
└──────────────┘    └──────────────┘    └──────────────┘

合并规则设计得很保守:

  • 文件系统路径:取并集(只增不减,不能通过附加权限缩小基础策略的范围)
  • 网络权限:取或(任一允许即允许)
  • DangerFullAccess:不受附加权限影响(已经是最大权限,无法再扩大)

还有一个 IntersectPermissionProfiles(取交集),用于相反的场景:确保请求的权限不超过已授权的范围。

自动保护:.git 和 .codex 为什么特殊

在所有可写目录下,Codex 自动将以下子目录设为只读:

路径为什么保护
.git防止修改 git hooks(pre-commitpost-checkout 等会自动执行)
.git(文件)防止修改 worktree/submodule 的 gitdir 指针
.codex防止 AI 修改自身配置来提权
.agents防止修改 agent 配置

这个保护是自动的、无条件的。即使策略说"整个 /workspace 可写",.git 仍然只读。这是一个深思熟虑的设计决策:git hooks 是一个经典的权限提升向量,AI 如果能修改 .git/hooks/pre-commit,就能在用户下次 git commit 时执行任意代码——而且是在沙盒外执行。

安全边界的全景图

┌──────────────────────────────────────────────────────────┐
│                        用户空间                           │
│                                                          │
│   AI 模型生成命令                                         │
│       │                                                  │
│       ▼                                                  │
│   SandboxManager                                         │
│       │  策略计算 + 命令包装                                │
│       │                                                  │
│       ├──────────────────┬───────────────────┐           │
│       │                  │                   │           │
│       ▼                  ▼                   ▼           │
│   macOS              Linux               Windows        │
│   sandbox-exec       bwrap + seccomp     Restricted     │
│                                          Token          │
│                                                          │
├──────────────────────────────────────────────────────────┤
│                        内核态                             │
│                                                          │
│   macOS:                Linux:                           │
│   Seatbelt MAC 引擎     mount namespace (文件系统隔离)     │
│   (每个 syscall 检查)    network namespace (网络隔离)      │
│                         seccomp BPF (syscall 过滤)        │
│                         Landlock LSM (后备 FS 限制)       │
│                                                          │
│   ┌─────────────────────────────────────────────────┐   │
│   │  所有系统调用在到达实际内核逻辑之前被拦截检查        │   │
│   │  不匹配任何 allow 规则 → EPERM                    │   │
│   └─────────────────────────────────────────────────┘   │
│                                                          │
└──────────────────────────────────────────────────────────┘

五个关键安全属性:

  1. 内核强制 — 不是用户态模拟或 hook,进程无法绕过(除非有内核漏洞)
  2. 默认拒绝 — 只有明确允许的操作才能执行,遗漏一条规则只会导致功能缺失,不会导致安全漏洞
  3. 继承性 — 子进程自动继承父进程的沙盒限制,无法通过 fork+exec 逃逸
  4. 不可提升no_new_privs 防止通过 setuid 逃逸,策略一旦安装不可修改
  5. 纵深防御 — 文件系统隔离 + 系统调用过滤 + 网络命名空间,任何单一机制被绕过都不会导致完全失守

写在最后

Codex 的沙盒实现给我最大的启发是:安全不是一个功能,而是一个约束

它不是在问"如何让 AI 安全地做更多事",而是在问"如何确保 AI 即使想做坏事也做不到"。这两个问题看起来相似,但导向完全不同的设计。

前者会让你去做行为检测、意图分析、输出过滤——这些都是概率性的,总有漏网之鱼。后者让你去用内核强制访问控制、namespace 隔离、系统调用过滤——这些是确定性的,要么放行要么拒绝,没有灰色地带。

在 AI 越来越多地获得"执行权"的今天,这种思路值得每一个构建 AI 应用的工程师认真思考。