Pi 与沙箱
为什么只靠正则拦截命令不够
在之前介绍 pi 的扩展系统时,我展示了一个 permission-gate.ts 的例子,用正则表达式拦截危险命令:
// permission-gate 的思路:正则匹配,匹配到就弹窗确认
const rules = [
{ pattern: /\bfind\s+\/(?![a-zA-Z0-9_./])/i, label: "find-root" },
];
这种基于模式匹配的"权限门"看似有用,但在现代大模型面前形同虚设。一个足够聪明的 LLM 可以轻松绕过
# 绕过正则的方式:
find . -path '*' # 等价 find /
x="f"; y="ind"; $x$y / # 变量拼接绕过
exec 3<>/dev/tcp; ... # 使用完全不同的原语
python -c "import os; ..." # 用解释器写文件
Claude Code 和 Codex 等主流 agent 的做法是:在权限规则之外,再加一层操作系统层面的沙箱隔离。权限规则控制"能不能执行",沙箱控制"执行后能碰什么"两者互补。一旦沙箱生效,不管 LLM 想出多诡异的命令,它也破坏不了沙箱允许范围之外的东西。
沙箱做什么
讲沙箱之前,先从最熟悉的 Docker 开始。
从 Docker 到沙箱
Docker 大家应该都用过docker run 起一个容器,里面有独立的文件系统,端口要显式映射才能访问。这其实就是一个比较重的沙箱实现。
但当前主流的 agent 的沙箱一般不用 Docker。原因是 agent 要把你的项目目录挂进去让 LLM 改代码,还要实时拿到 stdout/stderr 反馈给 LLM,每次跑一条命令就起一个容器实在太慢了。
所以 agent 沙箱要解决的问题是能不能在不到 Docker 的复杂度下,获得接近 Docker 的隔离效果?
主流方案沿着两条路线演进:
路线一:容器化内核特性(轻量但需要 root 或特权)
Docker 底层用的是 Linux namespace + cgroup。其中 namespace 可以把一个进程关进独立的"视窗",它看到自己的 /、自己的网络栈、自己的 PID 树,但其实和宿主共享同一个内核。
bubblewrap(简称 bwrap)就是 namespace 的精简版。它不需要 dockerd,不需要镜像,不需要 cgroup,只做一件事,给一个进程创建一个新的 mount namespace 和 network namespace,然后 exec 它。
# 手动用 bubblewrap 跑一个隔离的 bash
bwrap \
--ro-bind /usr /usr \ # 只读挂载 /usr
--bind /tmp /tmp \ # 读写挂载 /tmp
--dev /dev \ # 挂载设备
--proc /proc \ # 挂载 /proc
/bin/bash
# 这个 bash 只能访问 /usr、/tmp 和 /dev ,
# 宿主上的 ~/、/etc/passwd 等全部不可见
agent 沙箱就是围绕 bubblewrap(或 macOS 的 Seatbelt)这种级别的能力构建的。挂载你要改的项目目录为可写,其余全部隔离。
路线二:微型虚拟机(隔离最强但更重)
如果连内核共享都觉得不安全,那就上真正的虚拟机,也就是在宿主上起一个独立的 Linux 内核,硬件级的隔离。
传统虚拟机软件(VirtualBox、VMware)当然不行,光是启动就几十秒。所以有了微型 VM(microVM)这个方向:把传统 VM 里用不到的东西全砍掉,只留一个极简内核 + 极少的虚拟设备,启动时间压到毫秒级。
这个方向最出名的产品是 Firecracker,AWS 为 Lambda 写的,Rust 实现,启动 <100ms,每个 Lambda 函数调用都在独立的 microVM 里跑。但它只能在 Linux 上跑,而且场景非常专一(serverless 函数执行)。
另一个更通用的 microVM 方案是 QEMU。
QEMU 是 2003 年 Fabrice Bellard(也是 FFmpeg 的作者)写的一个开源虚拟机。你可以把它当成一个纯命令行的、跨平台的、可编程的 VirtualBox。它有两套工作模式:
- 模拟模式:用软件模拟 CPU、内存、硬盘等完整硬件,可以在 x86 机器上跑 ARM 系统(比如 Android 模拟器底层就是 QEMU)
- 虚拟化模式:借用宿主 CPU 的硬件虚拟化能力(Linux 的 KVM、macOS 的 HVF),让 guest 系统直接在物理 CPU 上跑,性能接近裸机
agent 沙箱显然用虚拟化模式,我们不关心跨架构模拟,要的是尽量快的执行速度。
为什么 QEMU 在沙箱场景下比 Firecracker 更合适?Firecracker 快是快,但它被 AWS 锁定在 serverless 场景,它只支持 Linux KVM,只暴露极少的虚拟设备,你没法在 macOS 上用,也没法灵活定制网络行为。QEMU 则相反:跨平台是它的核心能力之一,macOS 用户装个 brew install qemu 就能跑,而且设备模型丰富(virtio-net 可以让 Gondolin 在宿主侧拦截和重写所有网络流量)。虽然比 Firecracker 重一些(冷启动约 1 秒),但对 agent 这种"跑一条命令等结果"的场景,1 秒启动完全在可接受范围内。
两条路线的对比
| 路线一:容器化 | 路线二:microVM | |
|---|---|---|
| 代表技术 | bubblewrap、Seatbelt | Firecracker、QEMU |
| 隔离原理 | 共享宿主内核,靠 namespace/profile 限制 | 独立内核,硬件级隔离 |
| 启动速度 | 毫秒级 | 百毫秒 ~ 1 秒 |
| 平台限制 | 每平台不同 API | QEMU 跨平台,Firecracker 仅 Linux |
| 配置复杂度 | 低 | 需要准备内核镜像 |
| 适合场景 | 日常开发,快速隔离 | 执行不可信代码,需要强隔离 |
三层隔离
不管是 bubblewrap、Seatbelt 还是 Firecracker 风格的微 VM,沙箱的目标都是控制三个层面:
| 层面 | 要控制什么 | 怎么控制 |
|---|---|---|
| 文件系统 | 能读哪些路径、能写哪些路径 | mount namespace / Seatbelt profile / chroot |
| 网络 | 可以连哪些域名和 IP | 代理(SOCKS5)或用户态网络栈 |
| 系统调用 | 进程能调用哪些内核函数 | seccomp-bpf 过滤器 / no_new_privs |
文件系统决定"能碰什么文件",网络决定"能连什么地址",系统调用决定"能做什么操作"。
不同平台的实现
同一套思路,三个平台走了不同的底层 API:
- macOS:Seatbelt(也叫
sandbox-exec)是 macOS 内建的沙箱框架。App Store 里每个应用都跑在 Seatbelt 下面。它的配置是声明式的 profile 文件,写好允许哪些路径、哪些网络操作,系统内核自动执行。好处是开箱即用,坏处是控制粒度没 Linux 细(不能逐条禁系统调用) - Linux:没有大一统的沙箱框架,需要自己组装,用
bubblewrap创建 mount namespace 隔离文件系统,用seccomp-bpf写 Berkeley Packet Filter 规则限制系统调用,用no_new_privs禁止 setuid 提权。组合起来最灵活,也最复杂 - Windows:用 Restricted Token 给进程创建一个被削权过的 token(去掉 SeShutdownPrivilege 等危险特权),配合 Windows Filtering Platform 过滤网络,ACL 控制文件访问。
两种主流 agent 的做法
了解了这些底层机制,再看 Claude Code 和 Codex 各自的选型,它们都走路线一(容器化 OS 沙箱),但实现深度不同。
Claude Code 用的是 npm 包 @anthropic-ai/sandbox-runtime:macOS 调 Seatbelt,Linux 调 bubblewrap + socat。特点是"够用就好",只隔离 bash 命令,网络通过 SOCKS5 代理做域名白名单,文件系统默认只能写工作目录。额外支持 credential masking(把真实密钥替换为 sentinel,代理层在出站时动态注入,沙箱内始终看不到真值)。
Codex(OpenAI)则激进得多。全 Rust 重写,把 bubblewrap 的 C 源码直接编译进自己的二进制里(不依赖系统安装),Linux 上 bubblewrap + seccomp-bpf 双保险,macOS 走 Seatbelt,Windows 走 Restricted Token + WFP。还多了一层进程加固pre_main() 阶段就禁止 ptrace attach、禁用 core dump、清理 LD_PRELOAD 等危险环境变量。可以说 Codex 目前是 agent 沙箱实现得最深的一个。
两者都还没走路线二(microVM),因为对大部分日常开发场景,OS 级沙箱的速度和隔离度已经够用了。路线二的 microVM 更多是给高安全场景(执行完全不受信任的第三方代码)准备的。
Pi 示例中的两个沙箱方案
Pi 在 examples/extensions/ 下提供了两个沙箱扩展,代表了两种不同的技术路线:
Sandbox(进程级 OS 沙箱)
基于 @anthropic-ai/sandbox-runtime,这也是 Claude Code 的沙箱内核。在 macOS 上调 Seatbelt,在 Linux 上调 bubblewrap + socat。
┌─────────────── Pi 主进程 ───────────────┐
│ │
│ ┌─────────────────────────────────────┐ │
│ │ bash 命令 │ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ OS 沙箱 (Seatbelt/bubblewrap)│ │ │
│ │ │ • 只写 当前目录 + /tmp │ │ │
│ │ │ • 只连 白名单域名 │ │ │
│ │ │ • 不能读 ~/.ssh ~/.aws │ │ │
│ │ └─────────────────────────────┘ │ │
│ └─────────────────────────────────────┘ │
│ │
│ read / write / edit → 不受沙箱限制 │
└───────────────────────────────────────────┘
特点:只隔离 bash 命令,read/write/edit 等其他工具不受影响。配置简单,性能开销几乎为零。
Gondolin(VM 级微虚拟机)
基于 @earendil-works/gondolin,在本地启动一个精简 Linux VM(QEMU),把所有工具,不只是 bash,都路由进 VM。
┌─────── Pi 主进程(宿主)───────┐
│ Node.js / 扩展 │
└────────────┬───────────────────┘
│ virtio-serial / virtio-net
┌────────────▼─── VM 边界 ───────┐
│ Linux VM │
│ read / write / edit / bash │ 全部在 VM 里执行
│ ls / find / grep │
│ /workspace ↔ 宿主 cwd │ 文件变动穿透回宿主
│ │
│ 网络:通过 virtio-net 出站 │ 协议分类后由宿主代发
└─────────────────────────────────┘
特点:整体隔离更强,LLM 的每一行代码都在独立 Linux VM 里跑。需要装 QEMU,启动有轻微延迟。
注:Gondolin 本身支持密钥注入(通过
createHttpHooks({ secrets })在宿主侧注入 HTTP header),但 pi 的 Gondolin 示例扩展没有配置密钥注入,仅做了工具路由和文件系统穿透。
对比总结
| Sandbox 扩展 | Gondolin | |
|---|---|---|
| 隔离级别 | 进程级(OS 沙箱) | VM 级(完整虚拟机) |
| 覆盖范围 | 只隔离 bash | 隔离 read/write/edit/bash 全部 |
| 密钥保护 | denyRead 禁止读密钥文件 | 独立内核,逻辑隔离 |
| macOS 依赖 | 无(Seatbelt 内建) | 需要 QEMU |
| Linux 依赖 | bubblewrap + socat | QEMU |
| 启动开销 | 毫秒级 | 秒级 |
| 适用场景 | 日常开发防护 | 高安全需求 |
实践中大多数用户用 Sandbox 扩展就足够,它在 Claude Code 同款沙箱内核上覆盖了最危险的攻击面(bash 命令执行),配置直观,开箱即用。
Sandbox 扩展:安装、配置与使用
安装
# 找到 pi 安装路径下的示例
PI_EXAMPLES=$(dirname $(dirname $(which pi)))/lib/node_modules/@earendil-works/pi-coding-agent/examples/extensions
# 拷贝到扩展目录
cp -r "$PI_EXAMPLES/sandbox" ~/.pi/agent/extensions/
# 安装依赖
cd ~/.pi/agent/extensions/sandbox && npm install --ignore-scripts
重启 pi 即生效。
默认配置
沙箱默认开启,配置内嵌在 index.ts 中:
DEFAULT_CONFIG = {
enabled: true,
network: {
allowedDomains: [
"npmjs.org", "*.npmjs.org",
"registry.npmjs.org", "registry.yarnpkg.com",
"pypi.org", "*.pypi.org",
"github.com", "*.github.com",
"api.github.com", "raw.githubusercontent.com",
],
deniedDomains: [],
},
filesystem: {
denyRead: ["~/.ssh", "~/.aws", "~/.gnupg"],
allowWrite: [".", "/tmp"],
denyWrite: [".env", ".env.*", "*.pem", "*.key"],
},
};
解读:
- 网络:白名单模式。只允许连 npm、GitHub、PyPI 等开发常用域名。不在白名单的域名(如
google.com)连接会被 OS 层阻止 - 文件系统读:全盘可读,但禁止读
~/.ssh(密钥)、~/.aws(凭证)、~/.gnupg(GPG) - 文件系统写:只能写当前项目目录
.和/tmp,且.env、*.pem、*.key即使在项目目录里也不能写
自定义配置
三个优先级,浅合并:
- 代码默认值
~/.pi/agent/extensions/sandbox.json(全局覆盖).pi/sandbox.json(项目级覆盖,优先级最高)
例如项目需要访问 OpenAI API:
// .pi/sandbox.json
{
"network": {
"allowedDomains": ["api.openai.com"]
}
}
⚠️ 注意:配置合并是浅合并,
allowedDomains数组会被整个替换。如果项目配置只写["api.openai.com"],默认的github.com等全部会丢失。要把需要的域名全部写上:{ "network": { "allowedDomains": ["api.openai.com", "github.com", "*.github.com", "npmjs.org", "*.npmjs.org", ...] } }
如果要关掉某个默认域名,可以用 deniedDomains:
{
"network": {
"deniedDomains": ["raw.githubusercontent.com"]
}
}
使用
启动 pi 后,沙箱自动生效。状态栏会显示:
🔒 Sandbox: 10 domains, 2 write paths
三个常用操作:
# 查看当前配置
/sandbox
# 临时关闭(仅本次启动)
pi --no-sandbox
# 完全禁用
# 在 ~/.pi/agent/extensions/sandbox.json 中设置 { "enabled": false }
注意事项
- macOS 开箱即用。Linux 需要安装
bubblewrap和socat:sudo apt-get install bubblewrap socat - 沙箱只隔离 bash 命令及其子进程。read/write/edit 等其他工具不经过沙箱,它们走 pi 自己的权限系统
- 用户的手动
!命令(!npm install xxx)同样经过沙箱