当你让 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 │
│ │ │ │
└─────────────────┘ └─────────────────┘
整个流程分三步:
SelectInitial()--- 根据策略和平台,决定使用哪种沙盒(Seatbelt / seccomp / 无)Transform()--- 将原始命令包装成沙盒命令(生成策略、拼接参数)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 会:
- 解析环境变量中的代理 URL
- 提取 loopback 地址的端口号
- 只允许连接这些特定端口
- 其他所有网络连接仍然被拒绝
这意味着即使开启了"网络访问",流量也必须经过代理,代理可以做审计和过滤。
一个容易忽略的安全细节
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/ │ (不存在)
关键技巧是挂载顺序:
- 先
--ro-bind / /把整个根目录只读挂载 - 再
--bind /home/user/project /home/user/project覆盖为可写 - 最后
--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-commit、post-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 │ │
│ └─────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────┘
五个关键安全属性:
- 内核强制 --- 不是用户态模拟或 hook,进程无法绕过(除非有内核漏洞)
- 默认拒绝 --- 只有明确允许的操作才能执行,遗漏一条规则只会导致功能缺失,不会导致安全漏洞
- 继承性 --- 子进程自动继承父进程的沙盒限制,无法通过 fork+exec 逃逸
- 不可提升 ---
no_new_privs防止通过 setuid 逃逸,策略一旦安装不可修改 - 纵深防御 --- 文件系统隔离 + 系统调用过滤 + 网络命名空间,任何单一机制被绕过都不会导致完全失守
写在最后
Codex 的沙盒实现给我最大的启发是:安全不是一个功能,而是一个约束。
它不是在问"如何让 AI 安全地做更多事",而是在问"如何确保 AI 即使想做坏事也做不到"。这两个问题看起来相似,但导向完全不同的设计。
前者会让你去做行为检测、意图分析、输出过滤------这些都是概率性的,总有漏网之鱼。后者让你去用内核强制访问控制、namespace 隔离、系统调用过滤------这些是确定性的,要么放行要么拒绝,没有灰色地带。
在 AI 越来越多地获得"执行权"的今天,这种思路值得每一个构建 AI 应用的工程师认真思考。