Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

沙箱与容器化:Pi 的隔离方案

TL;DR:Pi 没有内置权限系统,也不会在每次工具调用前请求批准。它的立场是:模型生成的命令和代码都是不可信的,安全只能来自操作系统或虚拟化边界,限制 Pi 能碰到的文件、凭证、进程和网络。1.5 官方文档给出两种隔离形态:把整个 Pi 进程放进隔离环境(plain Docker、Docker Sandboxes、OpenShell),或者 Pi 留在宿主机、只把内置工具送进隔离环境(Gondolin 扩展,本地 Linux micro-VM)。后者之所以能做成一个普通扩展,靠的是三个扩展点:用同名工具覆盖内置工具、给内置工具注入自定义 operations 后端、拦截用户 ! 命令的 user_bash 事件。项目信任(project trust)是另一层,只决定启动时加载哪些项目资源,不限制工具能做什么。

先说清楚:Pi 不提供什么

官方 docs/security.md 开宗明义:Pi 以启动它的账户权限读写、执行文件;扩展、包安装器、语言服务器等子进程也都继承这些权限,除非有 OS 或虚拟化边界挡住。文件、注释、命令输出都可能通过 prompt injection 操纵模型。盯着对话记录、开启项目信任、审 diff,这些都不构成安全边界。安全策略里也明确写了:"缺少内置沙箱"本身不算漏洞。

为什么不做内置权限弹窗?因为它挡不住真正的问题。一条 bash 命令可以做任何事,靠模式匹配判断"危险命令"既漏又烦,用户很快就会习惯性点"允许"。Pi 把这件事交给更可靠的层:要么用隔离环境限制爆炸半径,要么由扩展自行实现策略(仓库里的 permission-gate.ts、protected-paths.ts 示例就是用 tool_call 事件做确认和拦截的,但它们是"防手滑",不是安全边界)。

一句话:Pi 的安全模型是"假设模型会做错事,用环境边界限制后果",而不是"在每一步问用户"。

两种隔离形态

docs/containerization.md 列了四种方法,按"Pi 进程在哪"分成两类:

方法Pi 运行在被隔离的部分凭证
Plain Docker容器内Pi、内置工具、! 命令、扩展以环境变量传进容器
Docker Sandboxes托管沙箱内同上真实 key 留在宿主,由代理在出站请求时替换占位符
OpenShell本地或远程沙箱同上策略控制,可通过推理路由让原始 key 不进沙箱
Gondolin 扩展宿主机只有内置工具和 ! 命令文件里的 Pi 凭证留在宿主,但命令会继承宿主环境变量

整进程隔离是官方说的"通常最强的实用方案":Pi 和它加载的所有扩展都在边界里,只要不把宿主的 ~/.pi/agent 挂进去、只挂工作目录,宿主就是安全的。Plain Docker 的示例 Dockerfile 用 npm install -g --ignore-scripts 装 Pi,运行时 -v "$PWD:/workspace" 挂工作目录、用一个命名卷 pi-agent-home 存容器内自己的设置和会话。它的短板是凭证:-e ANTHROPIC_API_KEY 传进去的 key,容器里任何进程都读得到。Docker Sandboxes 和 OpenShell 正是为补这一点:前者的代理只给沙箱一个形如真 token 的占位符,请求发往指定 host 时才替换成真值(所以文档特别提醒不要在沙箱里 /login,那会把真凭证写进去);后者用网关做推理路由,Pi 指向网关暴露的兼容端点即可。

仅工具隔离更窄:Pi 进程本身、TUI、模型调用、会话存储都在宿主,只有内置工具执行的动作进了 VM。好处是保留宿主上完整的交互界面和已登录的 provider 凭证,坏处是边界只覆盖被路由的那部分。官方在表格下专门强调:其他扩展注册的工具仍在宿主执行,除非它们也把操作委托给隔离后端。

不管哪种,文档都列了同一组"仍然会漏"的地方:读写挂载意味着能改宿主文件;挂 ~/.pi/agent 等于交出凭证、扩展和会话;传入的环境变量在里面可见;有网络就可能把数据带出去。

一句话:整进程隔离把 Pi 和扩展都关进去,仅工具隔离只关工具,换来的是宿主上的完整体验和凭证。

Gondolin:一个扩展如何把工具送进 VM

Gondolin 是 earendil-works 自己的本地 Linux micro-VM(基于 QEMU,需要 Node 23.6+)。示例扩展 examples/extensions/gondolin/index.ts 只有五百多行,没有改 Pi 的任何代码。它的启动方式是在工作目录里 pi -e <扩展目录>,下面沿一次会话的顺序看它做了什么。

会话开始:起 VM,挂工作目录。 session_start 时调用 VM.create,用 RealFSProvider 把宿主的 process.cwd() 挂到 guest 的 /workspace。/workspace 下的改动直接写回宿主,guest 其他位置的改动只留在 VM 里。VM 启动后探测一下有没有 bash,没有就用 /bin/sh,并在状态栏显示 VM id。ensureVm() 用一个共享的 Promise 防止并发调用重复起 VM;session_shutdown 时关闭 VM。

同名覆盖内置工具。 扩展对 read、write、edit、bash、ls、find、grep 七个工具各调用一次 pi.registerTool({ ...localRead, execute })。用内置工具的名字注册,就会无提示替换掉内置版本;展开 ...localRead 保留了原来的名字、描述、参数 schema 和提示词片段,模型看到的工具和平时完全一样,渲染也自动沿用内置渲染器。只有 execute 换了:

execute(params)
  → ensureVm()
  → createReadTool("/workspace", { operations: createGondolinReadOps(vm, localCwd) })
  → tool.execute(params)        // 截断、行号、图片识别等逻辑全部复用内置实现

operations:只替换"最后一公里"。 这是整个方案能做小的关键。Pi 的内置工具把"工具逻辑"和"怎么碰文件系统/起进程"拆开了:createReadTool(cwd, { operations }) 里,ReadOperations 只要求 readFile、access、detectImageMimeType;BashOperations 只要求一个 exec(command, cwd, { onData, signal, timeout, env }),返回退出码。Gondolin 版的实现就是把这些调用转成 vm.fs.readFile、vm.fs.writeFile、vm.exec([shell, "-lc", command]),并用 toGuestPath 把宿主路径翻译成 /workspace/...。输出截断、临时文件、超时与中止提示、非零退出码报错这些都还在 bash 工具层,扩展只需遵守约定:中止时抛 "aborted",超时抛 timeout:N。唯一例外是 grep:内置 grep 依赖 ripgrep 进程,扩展干脆在 guest 文件系统上自己遍历匹配,find 也是同理用 vm.fs 遍历加 glob 匹配。

拦截用户的 ! 命令。 工具覆盖只管模型发起的调用,用户在输入框里敲的 !ls 走的是另一条路。扩展监听 user_bash 事件,返回 { operations: createGondolinBashOps(...) },于是用户命令也在 VM 里执行。

改写系统提示词里的工作目录。 before_agent_start 把提示词中的 Current working directory: <宿主路径> 换成 /workspace (Gondolin VM; host workspace mounted from ...),让模型生成的路径与 guest 一致。

什么留在宿主、什么进了 VM:

宿主机                                         Gondolin VM
┌─────────────────────────────────────────┐      ┌────────────────────────────┐
│ Pi 进程:TUI、agent loop、模型请求     │      │ /workspace ← 挂载宿主 cwd     │
│ ~/.pi/agent:auth.json、会话、设置   │      │   (写入直接落到宿主)         │
│ 其他扩展工具、MCP 工具              │      │ 其余文件系统:只在 VM 内      │
│ Gondolin 扩展:覆盖后的 7 个工具 ───┼──────┼→ vm.fs.* / vm.exec(shell -lc) │
│              user_bash 处理器   ───┼──────┼→ vm.exec(shell -lc)          │
└─────────────────────────────────────────┘      └────────────────────────────┘

为什么这样分?VM 里跑的是模型生成的、不可信的命令,它们看不到宿主的 ~/.pi/agent,自然拿不到存在 auth.json 里的 provider 凭证;而模型请求本来就由宿主上的 Pi 进程发出,不需要把凭证交给 VM。但官方文档特别警告:bash 的 env 参数来自宿主进程环境,扩展的 sanitizeEnv 只是把值规整成字符串,并没有过滤,所以通过环境变量提供的 provider key 在 VM 里是可见的。要把它当凭证边界用,就得自己剔除敏感变量。

一句话:Gondolin 用"同名覆盖 + operations 注入 + user_bash 拦截"三件事,把工具执行面整体搬进 VM,而工具语义一行没改。

扩展实现隔离的设计要点

把 Gondolin 和其他示例放在一起看,可以总结出扩展实现隔离时能用的几个钩子,以及它们各自的边界。

operations 覆盖 是最干净的方式,适用于"换执行后端":本地 shell、SSH(examples/extensions/ssh.ts 用同样的套路把 read/write/edit/bash 送到远程机器)、容器、VM。可替换的接口有 ReadOperations、WriteOperations、EditOperations、BashOperations、PowerShellOperations、LsOperations、GrepOperations、FindOperations。如果只想在本地执行前改写命令、cwd 或环境,bash 工具还提供更轻的 spawnHook;user_bash 则可以用 createLocalBashOperations() 复用 Pi 自己的本地 shell 后端再包一层。

旧的 OS 级沙箱思路 仍然可以用这套机制实现。v0.84 时代的官方 sandbox 示例(1.5 已删除)用 @anthropic-ai/sandbox-runtime 包装 bash:macOS 上是 sandbox-exec + Seatbelt profile,Linux 上是 bubblewrap + 网络命名空间 + 宿主代理。思路是不改程序本身,只在进程启动前用系统原生机制把它和整棵子进程树圈起来,配置里写网络域名白名单(allowedDomains)、读黑名单(denyRead: ~/.ssh, ~/.aws)、写白名单与写黑名单(allowWrite: ., /tmp,denyWrite: .env, *.pem)。实现上它覆盖了 bash 工具,在自定义 BashOperations.exec 里先 wrapWithSandbox(command) 再 spawn("bash", ["-c", wrapped], { detached: true }),用独立进程组实现超时和中止时整组 kill。与 Docker 的区别是:它是"给现有进程加边界",而不是"运行一个完整的容器环境",启动快、不需要镜像,但只覆盖它包装的那条执行路径。

把它和 Gondolin 对比,能看出两个值得注意的设计取舍:

  • 覆盖面。旧示例只包装了 bash,read、write、edit 仍在宿主直接读写,所以文件系统规则只对 shell 命令生效。Gondolin 把七个内置工具全部路由进 VM,边界才是完整的"工具执行面"。
  • 失败时往哪走。旧示例在平台不支持或沙箱初始化失败时会回退到普通 bash,即 fail-open。1.5 的核心机制则倾向 fail-closed:user_bash 处理器抛错或返回非法结果时,命令直接中止,不再调用后续处理器,也不会回退到本地执行(CHANGELOG 明确记录了这次改动);tool_call 处理器抛错同样会阻止工具执行。扩展文档也要求 gate 类处理器"在无法做出安全判断时 fail closed"。Gondolin 的 ensureVm() 失败会让工具直接报错,而不是退回宿主执行。

tool_call 拦截 适合做策略而不是换后端:在执行前检查参数,返回 { block: true, reason } 拒绝。旧 sandbox 示例的注释里也提过另一种做法:不替换 bash 工具,而是在 tool_call 里改写命令输入。它简单,但只能管到"命令字符串"这一层,拦不住命令内部再起的进程,因此更适合确认弹窗、保护路径这类防误操作的场景。

无论用哪种钩子,有一条边界始终存在:扩展本身运行在 Pi 进程里,拥有宿主权限。工具级隔离只能约束经过被覆盖工具的动作;MCP 工具、codemode、其他扩展注册的工具,只要没有走隔离后端,就仍在宿主执行。真正需要强边界时,应该选整进程隔离。

项目信任:另一层,管的是"加载什么"

项目信任解决的是另一个问题:克隆一个陌生仓库后,里面的 .pi/extensions 是可执行代码,不能一启动就悄悄加载。存在 .pi/settings.json、.pi/mcp.json、.pi/extensions|skills|prompts|themes、.pi/SYSTEM.md 或 APPEND_SYSTEM.md、项目 .agents/skills 这些资源时,Pi 需要一个信任决定,才会加载项目设置、MCP 服务器、扩展、skills 等。决定的来源依次是命令行 --approve/--no-approve、用户级或 -e 扩展处理的 project_trust 事件、~/.pi/agent/trust.json 里最近的已保存决定、全局 defaultProjectTrust(默认 "ask")。非交互模式无法弹窗,ask 等同于跳过。

它的边界要说清楚:项目信任只控制启动时加载哪些资源,不限制工具调用能访问什么;AGENTS.md、CLAUDE.md 这类上下文文件不受它管,照样加载;而且 Pi 在解析信任之前就会读取项目的 sessionDir 设置,这一步无法被拒绝信任撤销。所以它和沙箱是互补的两层:信任挡住"仓库自带的代码在宿主进程里运行",沙箱限制"模型生成的动作能影响什么"。细节见 资源发现、项目信任与系统提示词。

常见追问

Gondolin 和直接把 Pi 跑在 Docker 里,怎么选? 想要最强边界、扩展也一起关进去,选整进程隔离;想保留宿主上的 TUI、已登录凭证,又只担心模型跑的命令,选 Gondolin。后者要接受"其他扩展工具仍在宿主"和"环境变量会进 VM"两个限制。

为什么 Gondolin 用 operations 而不是从零写七个工具? 工具的提示词、参数 schema、截断规则、渲染是模型行为和用户体验的一部分,重写一份就会和内置版本产生细微差异。operations 只替换 I/O,模型看到的工具完全不变,扩展也能随 Pi 升级自动拿到工具逻辑的改进。

为什么 user_bash 要 fail closed? 用户装隔离扩展,期望的就是"命令不在宿主跑"。如果 VM 起不来时悄悄回退到本地执行,用户以为在沙箱里,实际在宿主上,这比明确报错危险得多。

源码入口(v1.5):packages/coding-agent/docs/security.md、packages/coding-agent/docs/containerization.md、packages/coding-agent/examples/extensions/gondolin/index.ts、packages/coding-agent/src/core/tools/bash.ts、packages/coding-agent/src/core/extensions/runner.ts