Pi Extension 的第一枪:Sandbox Extension(1)
从操作系统隔离机制出发,拆解 pi Sandbox Extension 的命令包装、平台检测与安全边界。
临江仙 · 沙箱
自律从来难自缚, 纸条贴遍门窗。 手机在兜誓不刷, 三更破了戒, 还怨意志伤。
须知锁匙交他手, 内核铁壁高墙。 任凭代码百般狂, 柜员摇头处, 一句不商量。
Pi 的沙箱:一个不存在的内置功能
上一篇我们聊了 Pi 的扩展系统:什么都不内置怎么长出一切——Pi 自己不干活,全靠扩展干。那你可能想问:Pi 有沙箱吗?它怎么保护你的系统不被 AI 搞崩?
答案是:没有。Pi 没有内置沙箱。
我知道你可能觉得被骗了——一个能跑任意 shell 命令的 AI 工具,居然不带安全围栏?但 Pi 的文档说得很直白:
Pi does not include a built-in sandbox… A partial in-process sandbox would be easy to misunderstand as a security boundary while still depending on the host shell, filesystem, package managers, credentials, and extension code.
翻译过来:半吊子的沙箱比没有沙箱更危险,因为它给你一种虚假的安全感。
不过——这才是有意思的地方——Pi 的扩展机制可以接入真正的 OS 级沙箱。Pi 自带了一个 sandbox extension 示例[1],用 macOS 的 sandbox-exec 和 Linux 的 bubblewrap 给 bash 命令套上内核级别的限制。
读完这篇,你会理解:
为什么”进程内自我限制”在原理上就不可能成功
操作系统内核是怎么做到真正不可绕过的强制执行的
Pi 的 sandbox extension 具体怎么把这两件事拼在一起
你的程序其实什么都碰不到
在讲为什么自我限制不行之前,得先搞清一件事:你的代码实际上不直接碰任何东西。不能直接读文件,不能直接写磁盘,不能直接发网络包,连打个字到屏幕上都不行。
这听起来明显跟经验矛盾——你每天都在 open("file.txt") 啊。但实际发生的流程是:
你的代码准备好参数(”我想打开
/tmp/hello.txt,只读模式”)执行一条特殊 CPU 指令,把控制权交给内核
内核——另一个程序,跑在 CPU 的特权模式里——决定行不行
内核干活(或者拒绝),把结果还给你
这整个 1→2→3→4 的流程叫一次 syscall(系统调用)。你 Mac 的 ARM64 芯片上,那条特殊指令是 svc [#0x80](javascript:;)(supervisor call,管理员调用,下面有更形象的展示,往下看👇)。像去银行柜台办业务:你填个单子递进去,柜员决定办不办、怎么办,你全程只能等结果。钱(文件、网络、硬件)全在柜台里面,你摸不到。
继续用银行的比喻。银行有很多窗口:1 号窗口办开户(fork),2 号窗口办转账(write),5 号窗口办查询(open)……这些窗口就是不同种类的 syscall,每种有个编号。你去哪个窗口,取决于你要办什么业务。
去窗口之前,你得先填单子。单子上的格子就是寄存器——CPU 芯片里的一小块存储,每个格子有名字(x0、x1、x16…),用来填你的业务参数。指令就是填单子的动作——“在 x16 格里写上窗口编号”、”在 x0 格里写上文件名”。
你 Mac 的 ARM64 芯片有 31 个通用寄存器(x0 到 x30),每个能存一个 64 位数字。所有程序——Python、C、Rust——最终都被翻译成”往格子里填数字”的操作。
现在看底层长什么样——感受一下结构就行:
1 | mov x16, #5 ; 指令:"在 x16 格里填 5"(5 = 5 号窗口 = 打开文件)mov x0, <文件名地址> ; 指令:"在 x0 格里填文件名的地址"mov x1, #0 ; 指令:"在 x1 格里填 0"(0 = 只读模式)svc #0x80 ; 指令:"单子填好了,递给柜员" ; 内核(柜员)看单子,办业务,结果填回 x0 |
整个 syscall 就是”填好单子(寄存器),递给柜员(svc [#0x80](javascript:;)),等结果”。没有 HTTP 包,没有 JSON。
Python 的 open("/tmp/hello.txt") 最后也落到这同一组”往格子里填数字 + svc [#0x80](javascript:;)“上——只是中间裹了很多层让它好用的库代码。
关键点来了:内核跑在 CPU 的 EL1 层级,你的代码在 EL0。这不是软件约定——是硬件物理上的隔离。你的代码没法执行内核指令,CPU 芯片会拒绝。你能做的唯一事情就是通过 svc 去请求。
自我限制:一个注定失败的纸老虎
明白了 syscall 之后,来看看”沙箱”到底是什么。
沙箱(sandbox)这个词的意思就是:限制一个程序能做什么。比如”只能读写这几个目录””只能访问这几个网站”。叫”沙箱”是因为像小孩在沙坑里玩——随便折腾,但出不去那个坑。
实现沙箱有两种方式:
- 软的:程序自己写一个
if检查,不满足条件就拒绝执行(下面的例子) - 硬的:让操作系统内核来拦,程序自己说了不算(后面
sandbox-exec的例子)
先看软的。假设一个编程智能体想当好人,给自己写了个路径检查,只允许往 /tmp/safe/ 写入:
1 | import osALLOWED_WRITE_DIR = "/tmp/safe/"def safe_write(path, content): real = os.path.realpath(path) if not real.startswith(ALLOWED_WRITE_DIR): raise PermissionError(f"拦截: {path} 不在 {ALLOWED_WRITE_DIR} 里") with open(path, "w") as f: f.write(content) |
跑一下:
1 | python3 -c 'import osALLOWED_WRITE_DIR = "/tmp/safe/"def safe_write(path, content): real = os.path.realpath(path) if not real.startswith(ALLOWED_WRITE_DIR): raise PermissionError(f"拦截: {path} 不在 {ALLOWED_WRITE_DIR} 里") with open(path, "w") as f: f.write(content)os.makedirs("/tmp/safe", exist_ok=True)safe_write("/tmp/safe/hello.txt", "允许的\n")print("写入 /tmp/safe/hello.txt 成功")try: safe_write("/tmp/evil.txt", "不应该出现\n")except PermissionError as e: print(f"被拦截: {e}")' |
输出:
1 | 写入 /tmp/safe/hello.txt 成功被拦截: 拦截: /tmp/evil.txt 不在 /tmp/safe/ 里 |
看起来有用?那来看看一行代码怎么让它变成笑话:
1 | python3 -c '# 我是那个"沙箱化"的智能体,看这个:open("/tmp/evil.txt", "w").write("沙箱只是个建议\n")'cat /tmp/evil.txt |
输出:
1 | 沙箱只是个建议 |
safe_write() 是 Python 代码检查 Python 代码。但 Python 代码也可以不检查自己。open() 直接发 syscall 跟内核对话——不走 safe_write()。这不是实现的 bug,这是范畴错误。safe_write() 是你自己给自己立的规矩——“我今天不刷手机”。你能遵守,也能随时破戒,因为手机就在你兜里,没有任何外力阻止你掏出来。真正管用的是什么?把手机交给别人保管——这就是后面 sandbox-exec 做的事。
你可能会问:上面的 open("/tmp/evil.txt") 不也是”交给内核”吗?确实,但内核默认的态度是”你是这台电脑的主人,/tmp 你本来就有权写,我没理由拦你”。safe_write() 想做的是在你已有的权限之内再加一层自我约束——而同一个程序里的其他代码可以无视这层约束直接找内核办事。后面的 sandbox-exec 做的事情不同:它额外通知内核”这个程序虽然是这个用户跑的,但不许它写 /tmp“。这时候内核的态度变了——不管你怎么调 open(),回答都是”不行”。
核心洞察来了。后面所有内容都从这一个事实推出:一个进程始终有能力直接发 syscall,进程内没有任何逻辑能阻止这一点。
Shell:沙箱的万能破洞
对编程智能体来说,问题还要严重得多。智能体的核心工作就是跑 shell 命令——写代码,然后执行。而 shell 是一个全新的进程,它对父进程的”自律”一无所知:
1 | python3 -c 'import subprocess# 智能体"自律"只允许写 /tmp/safe/# 但接着它执行了一条 shell 命令:subprocess.run(["bash", "-c", "echo pwned > /tmp/evil2.txt"])'cat /tmp/evil2.txt |
输出:pwned
bash 完全不知道 Python 想限制什么。它继承的是操作系统级的用户权限,不是 Python 进程里的任何变量。
推广一下——这些日常操作全是新进程,全不受”进程内沙箱(假的沙箱)”的管辖:
| 操作 | 发生了什么 |
|---|---|
pip install pkg |
可能执行 setup.py,编译 C 代码,跑任意命令 |
npm install |
postinstall 脚本什么都能做 |
| 启动 language server | 一个拥有完整权限的长驻进程 |
| 触发 git hook | pre-commit、post-checkout 都是任意脚本 |
每一个都是智能体应该能做的合法操作。但每一个产生的新进程都不知道”沙箱”是什么玩意儿。限制只活在智能体自己的内存里——不传播、不继承、不强制。
内核出手:真正说了算的家伙
现在看看内核级强制执行长什么样。你 Mac 上有 sandbox-exec(虽然标记废弃了,但还能用,完美演示原理):
1 | sandbox-exec -p '(version 1)(allow default)(deny file-write* (subpath "/tmp"))' \ bash -c 'echo "尝试写入" > /tmp/evil3.txt' |
输出:
1 | bash: /tmp/evil3.txt: Operation not permitted |
不是 Python 拦的。不是某个函数拦的。是内核——跑在进程无法触及的 CPU 特权层——拦截了 open() syscall,直接返回错误。
使劲绕试试?
1 | sandbox-exec -p '(version 1)(allow default)(deny file-write* (subpath "/tmp"))' \ python3 -c 'import ostry: open("/tmp/evil4.txt", "w")except OSError as e: print(f"open() 失败: {e}")try: fd = os.open("/tmp/evil4.txt", os.O_WRONLY | os.O_CREAT)except OSError as e: print(f"os.open() 失败: {e}")ret = os.system("touch /tmp/evil4.txt")print(f"touch 退出码: {ret}")' |
输出:
1 | open() 失败: [Errno 1] Operation not permitted: '/tmp/evil4.txt'os.open() 失败: [Errno 1] Operation not permitted: '/tmp/evil4.txt'touch: /tmp/evil4.txt: Operation not permittedtouch 退出码: 256 |
全部失败。open()、os.open()、生成子进程 touch——全都落到同一个 svc [#0x80](javascript:;),内核在那个层级检查策略。进程没有绕过的可能,因为检查点在它物理上无法访问的地方。
关键区别用一句话说清:
做限制的东西,必须运行在被限制的东西无法触及的特权层级上。
进程内的检查函数和被检查的代码是同一层(都是 EL0),随时可以互相绕过。内核在 EL1——硬件保证进程碰不到。
EL0 用户态 vs EL1 内核态
Pi 的 sandbox extension:把内核的力量接进来
现在你知道了:自我限制不行,得靠内核。Pi 也知道这一点,所以它不在自己进程里造沙箱——它提供一个 extension 示例,把 bash 命令的执行包进 OS 级沙箱里。
这个扩展的设计思路是:
- 替换内置的
bashtool — 扩展注册了一个同名工具,在执行前把命令交给SandboxManager.wrapWithSandbox()处理 - OS 级包装 — 在 macOS 上用
sandbox-exec(Seatbelt),在 Linux 上用bubblewrap(用户命名空间) - 策略可配置 — 通过 JSON 配置文件定义允许的网络域名、文件读写路径
有一点要特别说清楚:Pi 不会拦命令本身——命令一定会跑。 不会弹窗问你”允许执行 rm -rf / 吗?”,不会在执行前审批。它的做法是让命令跑起来,但跑的环境被锁死了:
| 命令 | 会发生什么 |
|---|---|
rm -rf / |
直接执行,但只能删 allowWrite 配置的路径(当前目录和 /tmp),其他地方内核返回 Operation not permitted |
curl evil.com |
直接执行,但代理拒绝连接,命令报网络错误 |
cat ~/.ssh/id_rsa |
直接执行,但内核拒绝读取,返回 Operation not permitted |
对比 Claude Code 的做法:Claude Code 会在执行前问你”允许吗?”——相当于门口站保安。Pi + sandbox extension 不问,直接让你进,但房间里的抽屉锁上了、窗户封了。两种思路,同样安全,交互方式完全不同。
整体流程:
Sandbox Extension 流程图
拆解扩展代码
来看 Pi sandbox extension 的核心代码(examples/extensions/sandbox/index.ts)。我会逐块讲。
配置结构
1 | const DEFAULT_CONFIG: SandboxConfig = { 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"], },}; |
注意这个策略的设计哲学:
- 网络:白名单制。只有包管理器和 GitHub 能访问——AI 没法随便把你的代码传到其他地方
- 文件系统:敏感目录(SSH 密钥、AWS 凭证、GPG 密钥)禁止读取;只能写当前目录和
/tmp;密钥文件(.env、.pem、.key)禁止写入 - 配置可以被项目级的
.pi/sandbox.json覆盖,优先级:项目 > 全局 > 默认
核心:替换 bash 工具
1 | pi.registerTool({ ...localBash, label: "bash (sandboxed)", async execute(id, params, signal, onUpdate, _ctx) { if (!sandboxEnabled || !sandboxInitialized) { return localBash.execute(id, params, signal, onUpdate); } const sandboxedBash = createBashTool(localCwd, { operations: createSandboxedBashOps(), }); return sandboxedBash.execute(id, params, signal, onUpdate); },}); |
逐行拆解:
pi.registerTool({...localBash, ...})— 注册一个工具,用...localBash继承内置 bash 工具的所有属性(名字、描述、参数定义),然后覆盖执行逻辑。因为名字相同,Pi 运行时会优先用扩展的版本替代内置的。label: "bash (sandboxed)"— 给工具加个标签,让你在 UI 里能看出来这是沙箱版。if (!sandboxEnabled || !sandboxInitialized)— 检查两个条件:sandboxEnabled是用户有没有想要 开沙箱(没传--no-sandbox),sandboxInitialized是沙箱有没有实际能 跑起来(SandboxManager.initialize()成功了没有——它要编译策略、启动代理,可能因为系统不支持或端口被占用而失败)。两个都满足才走沙箱路径,否则退回普通 bash。这是防御性设计——沙箱坏了不会让整个工具不能用,只是失去保护。createBashTool(localCwd, { operations: createSandboxedBashOps() })— 创建一个新的 bash 工具实例,两个参数:localCwd是工作目录(命令在哪个文件夹里跑),operations是”具体怎么执行命令”。默认的 bash 工具直接spawn("bash", ["-c", command])跑命令,这里替换成了沙箱版本——先调SandboxManager.wrapWithSandbox()把命令包一层,再跑。就像快递公司换了配送方式:默认是骑手直接送(直接执行),沙箱版是骑手得穿防护服、走指定路线送(在受限环境里执行)。
本质就是一个”偷梁换柱”:外表看起来还是 bash 工具,参数一样,行为一样,但执行时命令被悄悄包进了 OS 级沙箱里。模型感知不到区别——它照常发 bash 命令,只是跑起来后环境被锁了。
实际执行:包装命令
1 | function createSandboxedBashOps(): BashOperations { return { async exec(command, cwd, { onData, signal, timeout }) { // 第一步:把原始命令包进沙箱 const wrappedCommand = await SandboxManager.wrapWithSandbox(command); // 第二步:启动子进程跑包装后的命令,返回一个 Promise 等它跑完 return new Promise((resolve, reject) => { const child = spawn("bash", ["-c", wrappedCommand], { cwd, // 在哪个目录跑 detached: true, // 独立进程组(方便后面整组杀掉) stdio: ["ignore", "pipe", "pipe"], // 不要标准输入,接收标准输出和错误 }); // 收集输出——子进程每吐一段数据就回调一次 child.stdout?.on("data", onData); child.stderr?.on("data", onData); // 命令跑完了:resolve 交回退出码 child.on("close", (code) => { resolve({ exitCode: code }); }); // 命令启动就失败了(比如 bash 不存在):reject 报错 child.on("error", (err) => { reject(err); }); }); }, };} |
逐行拆解:
await SandboxManager.wrapWithSandbox(command)— 把"npm install"变成"sandbox-exec -p '(策略)' bash -c 'npm install'"这样的包装命令。这是@anthropic-ai/sandbox-runtime包的核心方法。return new Promise((resolve, reject) => {...})— 手动创建一个”承诺”。因为spawn启动子进程后不会等它跑完,你需要用 Promise 把”等命令结束”这件事包起来。外面的代码await这个 Promise 就能等到结果。spawn("bash", ["-c", wrappedCommand], {...})— 启动一个 bash 子进程执行包装后的命令。这个子进程从出生起就在沙箱里。child.on("close", (code) => resolve({exitCode: code}))— 命令跑完了,把退出码交回去。就像银行柜员叫号:”办好了,结果给你。”child.on("error", (err) => reject(err))— 命令启动就失败了(比如系统找不到 bash),报错。就像柜员说:”系统崩了,办不了。”
SandboxManager.wrapWithSandbox(command) 把原始命令变成类似这样的东西:
- macOS:
sandbox-exec -p '(version 1)(allow default)(deny file-write* (subpath "~/.ssh"))...' bash -c '<原始命令>' - Linux:
bwrap --ro-bind /usr /usr --dev /dev --proc /proc --bind <cwd> <cwd> ... -- bash -c '<原始命令>'
无论 AI 模型在那条命令里塞了什么,执行时都被管着。模型可以尝试写 ~/.ssh/id_rsa——内核会返回 Operation not permitted。它可以 curl evil.com——代理服务器会拒绝连接(网络域名过滤走的是 sandbox-runtime 启动的代理,不是内核直接拦)。文件系统靠内核管,网络靠代理管,两层配合。
用户的 ! 命令也被覆盖了
1 | pi.on("user_bash", () => { if (!sandboxEnabled || !sandboxInitialized) return; return { operations: createSandboxedBashOps() };}); |
Pi 里用户可以用 ! 前缀执行 shell 命令。这个事件 hook 让用户自己手敲的命令也进沙箱——一视同仁。
初始化和平台检测
1 | pi.on("session_start", async (_event, ctx) => { const platform = process.platform; if (platform !== "darwin" && platform !== "linux") { sandboxEnabled = false; ctx.ui.notify(`Sandbox not supported on ${platform}`, "warning"); return; } try { await SandboxManager.initialize({ network: config.network, filesystem: config.filesystem, }); sandboxEnabled = true; sandboxInitialized = true; // 走到这里说明初始化成功 } catch (err) { sandboxEnabled = false; // 初始化失败,标记为不可用 ctx.ui.notify(`Sandbox initialization failed: ${err}`, "error"); }}); |
这就是前面 sandboxEnabled 和 sandboxInitialized 的来源:
- 平台不支持 →
sandboxEnabled = false,直接 return SandboxManager.initialize()成功 → 两个都设trueSandboxManager.initialize()抛异常(端口被占、策略编译失败等)→sandboxEnabled = false
后面 execute 里的 if (!sandboxEnabled || !sandboxInitialized) 就是看这两个标记。初始化只在会话启动时跑一次,后续每条命令直接复用编译好的策略,不用重复初始化。
为什么这个设计是对的
回到最开头的问题:Pi 为什么不内置沙箱?
因为把沙箱做成扩展而不是内置功能,是唯一诚实的方案:
不制造虚假的安全感 — 你选择启用 sandbox extension,你知道它在;你不启用,你知道没保护。比”内置了一个什么也拦不住的检查”要诚实得多。
强制力来自内核,不来自 Pi — Pi 只是调度者,真正说”不行”的是 macOS Seatbelt 或 Linux bubblewrap。Pi 自己的代码不参与安全决策——它不需要完美,因为安全不依赖于它。
可替换 — 你可以用这个 sandbox extension,也可以用 Gondolin(把工具执行路由到 QEMU 虚拟机),也可以整个 Pi 丢进 Docker 里跑。每种方案适合不同的场景。不内置意味着不强制一种选择。
承认现实 — 一个需要跑
npm install、pip install、git commit、启动 language server 的工具,不可能在进程内做出有意义的限制。能做的是:把需要权限的操作委托给一个被内核管着的子进程。这正是 sandbox extension 做的事。
Pi sandbox extension 架构

