深入 Codex 沙盒

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

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

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

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

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

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

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

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

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

整体架构:三步走

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

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

整个流程分三步:

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

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

bash 复制代码
/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。

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

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

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

Codex 如何生成 Seatbelt 策略

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

策略生成分三层叠加:

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

scheme 复制代码
(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" 策略为例:

scheme 复制代码
; 允许读取整个磁盘
(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 参数传入:

bash 复制代码
-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 注入,但后果是沙盒被绕过。参数化传递从根本上消除了这个风险。

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

scss 复制代码
完全禁止网络:不添加任何网络规则,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. 其他所有网络连接仍然被拒绝

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

一个容易忽略的安全细节

go 复制代码
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。

scss 复制代码
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 创建一个全新的文件系统视图。进程看到的文件系统和宿主机完全不同:

scss 复制代码
宿主机                          沙盒内
/                               /  (只读)
├── 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 程序会检查系统调用号和参数,决定放行还是拒绝:

css 复制代码
进程: 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 模式 --- 只允许通过代理:

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

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

PR_SET_NO_NEW_PRIVS:被低估的安全基石

c 复制代码
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 引入的安全模块,允许非特权进程自我限制文件系统访问:

c 复制代码
// 创建 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(统一策略)

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

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

go 复制代码
// 精细到每个路径、每种访问模式
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 机制解决这个问题:

yaml 复制代码
基础策略(保守)          附加权限(按需)           生效策略
┌──────────────┐    ┌──────────────┐    ┌──────────────┐
│ 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 时执行任意代码------而且是在沙盒外执行。

安全边界的全景图

java 复制代码
┌──────────────────────────────────────────────────────────┐
│                        用户空间                           │
│                                                          │
│   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 应用的工程师认真思考。

相关推荐
架构谨制@涛哥2 小时前
架构谨制:重新定义软件从业者的本质
后端·系统架构·软件构建
ん贤2 小时前
Go GC 非玄学,而是 CPU 和内存的权衡
开发语言·后端·golang·性能调优·gc
码事漫谈10 小时前
当AI开始“思考”:我们是否真的准备好了?
前端·后端
铁东博客12 小时前
Go实现周易大衍筮法三变取爻
开发语言·后端·golang
oak隔壁找我13 小时前
SpringBoot中MyBatis的Mapper的原理
后端
oak隔壁找我13 小时前
Spring Boot 自动配置(Auto-configuration)的核心原理
后端
oak隔壁找我13 小时前
Java的JAR包
后端
GetcharZp13 小时前
告别 TCP 握手延迟!让你的 Go 服务瞬间拥抱 HTTP/3 时代
后端
oak隔壁找我13 小时前
SpringBoot 将项目打包成 Fat JAR(肥包),核心原理
后端