Pi 为什么不肯内置权限系统,它把 Agent 的安全边界推到了进程外
- Pi 不内置权限系统,安全边界外移到进程外,通过 Extension 实现自定义工具拦截
- Agent loop 结构简洁,处理模型截断、工具并行与顺序执行等风险
- TUI 采用差分渲染只重画变化行,但 Markdown 重算成本仍待优化
- 构建需先运行 hydrate:model-data 补全 Provider 模型数据,离线安装应使用 release archive
- Pi 采用边界外移模式,内核只保留控制流,Extension 改造能力,操作系统负责隔离
凌晨 1 点多,我把 Pi 的仓库重新 clone 到一个空目录,装完 334 个依赖,准备跑它在 README 里写的离线构建。
结果构建停在了 amazon-bedrock.json。
报错很直接,模型数据不存在,先运行 npm run hydrate:model-data。这个命令随后访问 models.dev、NVIDIA NIM、OpenRouter 和 Vercel AI Gateway,一共补进 1232 个可调用工具的模型,离线构建才真正通过。
这件事挺像 Pi 自己。
表面上看,它是一个装完就能用的 AI 编程 CLI。往下读代码才会发现,Pi 对「内核应该负责什么」划得很窄。Provider 模型目录不硬塞进 Git 仓库,权限系统也不硬塞进 Agent 进程。能外置的能力,它都尽量留给扩展、宿主系统或发布流水线。
说真的,这种克制比再做一套 Claude Code 更值得拆。
截至 2026 年 8 月 13 日,earendil-works/pi 有 88483 个 Star、10994 个 Fork,最新版本是 v0.84.1。仓库首页把它叫作 Pi Agent Harness,里面的 Coding Agent 只是这套运行时最显眼的入口。
一个故意不完整的 Coding Agent
先看一个反常识的决定。
Pi 的 security.md 明确写着,它没有内置 sandbox。read、write、edit、bash 都继承启动 Pi 的用户权限,Extension 也一样。Project Trust 只负责决定要不要加载项目里的 .pi/settings.json、Skills、Prompt 和 TypeScript 扩展,并不会约束模型之后能读什么、写什么、执行什么。
你想想看,一个能直接执行 shell 的编程 Agent,作者为什么主动放弃权限面板?
Pi 给出的理由不太讨巧。进程内做一层不完整的拦截,很容易让人误以为自己已经获得了安全边界。可它下面依然连着宿主 shell、文件系统、包管理器、网络、凭据和第三方 Extension。只拦住几个工具调用,并不能把这些东西关进笼子。
所以 Pi 选择承认这件事。
权限确认可以用 Extension 做,真正的隔离则交给 Docker、Gondolin micro-VM 或 OpenShell。一个负责工作流体验,一个负责操作系统级边界,两者不要混叫 sandbox。
坦白讲,我很喜欢这种不装全能的设计。它把产品体验和安全保证拆开了,也把责任写得足够清楚。
代码里的五层薄内核
我沿着 CLI 启动、资源加载、Agent loop、Provider 调用和终端渲染读了十多个核心文件。Pi 现在已经不是 README 里那张五包清单能概括的项目了。
根目录 package.json 的 build:offline 实际串起了 9 个生产包,除了 README 列出的 TUI、Telemetry、AI、Agent 和 Coding Agent,还包括 SQLite session backend、CBOR protocol、remote client 与 experimental server。README 的「All Packages」已经落后于代码,这是我这次读源码碰到的第一条文档偏差。
入口层没什么神秘的。packages/coding-agent/src/main.ts 解析参数、选择交互模式,modes/rpc/rpc-mode.ts 把同一套 Agent Session 暴露成 RPC,packages/client/src/connection.ts 再通过 framed CBOR bytes 接远程会话。
真正有意思的是第二层。
packages/coding-agent/src/core/resource-loader.ts 的 ResourceLoader.reload() 不是把所有资源一股脑加载进来。它先在未信任状态加载用户级和 CLI 指定的 Extension,再调用 resolveProjectTrusted(),最后按信任结果重新装载项目设置、Skills、Prompt、主题和项目 Extension。
这是一道代码注入门禁。
但它仍然不是工具权限门禁。trust-manager.ts 里需要信任的对象是一组资源路径,settings.json、extensions、skills、prompts、themes、SYSTEM.md 和 APPEND_SYSTEM.md。模型能不能在已启动会话里执行 bash,不在这张清单里。
Extension 不是插件皮肤,它能改写整条链路
其实吧,Pi 最强的部分不是 Extension 数量,而是 Extension 插入的位置。
packages/coding-agent/src/core/extensions/loader.ts 用 createJiti() 直接加载 TypeScript。Node 构建走 alias,Bun 单文件构建走 virtualModules,所以 Extension 可以稳定导入 pi-agent-core、pi-ai、pi-tui 和 TypeBox,不需要自己处理宿主里有没有这些模块。异步 Extension factory 还会在 session_start 前被完整等待。
加载完成后,createExtensionAPI() 给 Extension 的不只是一个 registerTool()。它还能注册命令、快捷键、Provider、消息渲染器,发送用户消息,追加 Session Entry,切换模型,并直接执行宿主命令。
能力很大,风险也一样大。
官方扩展示例里那段 rm -rf 确认逻辑很能说明问题。你可以自己写一个门禁。
pi.on('tool_call', async (event, ctx) => {
if (event.toolName === 'bash' && event.input.command?.includes('rm -rf')) {
const allowed = await ctx.ui.confirm('危险命令', '允许执行吗')
if (!allowed) return { block: true, reason: '用户拒绝执行' }
}
})
ExtensionRunner.emitToolCall() 会按加载顺序调用 handler,只要某个结果返回 block 就立即停下。emitContext() 可以改消息,emitBeforeAgentStart() 可以换 system prompt,emitBeforeProviderRequest() 甚至可以替换发给 Provider 的 payload。
我一直觉得,真正的扩展系统要看它能不能改变控制流。只能加按钮的叫插件皮肤,能拦工具、换模型、改上下文、接管渲染的才算运行时扩展。
Pi 属于后者。
Agent loop 很小,但小得有分寸
再往下一层看,packages/agent/src/agent-loop.ts 的 runLoop() 结构相当直白。外层循环等 follow-up message,内层循环处理 steering message、模型响应和工具调用。每轮结束后还可以通过 prepareNextTurn() 换上下文、模型与 reasoning level。
老实说,这段代码没有炫技。它的价值在几个不太显眼的防线里。
模型因为输出上限截断时,failToolCallsFromTruncatedMessage() 会把这一批工具调用全部判失败。即便残缺 JSON 恰好还能被解析和 schema 校验,Pi 也不会冒险执行。这个判断很实用,半截路径、半条 shell 命令都不该靠运气落地。
工具默认可以并行跑。只要批次里有一个工具把 executionMode 声明成 sequential,整批就会切回顺序执行。并行分支用 Promise.all(),结果仍按原始 tool call 顺序写回上下文,避免完成时间改变对话顺序。
代码不长,几处风险处理却很具体。
Provider 层也遵守同一个思路。packages/ai/src/models.ts 的 ModelsImpl 用一个 Map 管 Provider,applyAuth() 统一处理凭据、header 和 base URL,真正的 stream 行为仍由 Provider 自己拥有。createProvider() 允许一个 Provider 按 model.api 分发到不同协议实现,Agent loop 完全不需要知道背后是 Anthropic Messages、OpenAI Responses 还是 Google Generative AI。
我这次 hydrate 出来的目录里有 39 个 *.models.ts Provider 清单。统一的不是 HTTP 请求细节,而是模型、认证和流式事件的契约。
连终端都只重画变化的那几行
Pi 的 TUI 也不是现成库拼起来的。
packages/tui/src/tui-main-screen.ts 的 doRender() 会保留上一帧的 previousLines,比较新旧内容的 first changed line 和 last changed line,再用 synchronized output 只写回变化区间。终端宽度改变时,因为换行结果会整体变化,它才完整重画。普通 spinner 更新只改一行,没必要把整屏重新输出。
我自己的感受是,这个文件很能代表 Pi 的工程取向。先把宿主能力抽成窄接口,再认真处理接口下面那些没人愿意碰的细节。
不过这层目前也有真实代价。Issue #6665 在 v0.80.7 上测到,长 Markdown 流式输出会让单个 CPU 核心跑到约 105%。报告指向两个热区,未缓存的 Intl.Segmenter,以及每个 chunk 都重建 Markdown 组件。作者给出的扩展缓存方案能降到约 10% 至 16%,但截至我检查时,这个 Issue 仍然开放。
差分渲染解决了终端写回成本,还没完全解决 Markdown 重算成本。
fresh clone 不是离线冷启动
前面那个构建失败也该讲清楚。
README 写了 npm run build:offline,注释是使用现有模型数据离线重建。Release source archive 确实会附带生成好的 Provider 模型快照,但普通 Git clone 里没有 packages/ai/src/providers/data/amazon-bedrock.json 这些文件。
我在空目录里跑出的实际顺序是这样。
npm ci --ignore-scripts
npm run build:offline
# 失败,模型数据缺失
npm run hydrate:model-data
npm run build:offline
# 通过
补数据后,9 个生产包全部构建通过。pi-agent-core 的 23 个测试文件跑过 416 项,另有 1 项跳过。Coding Agent 的 Extension 定向测试跑过 76 项。
反正我觉得,这不算严重缺陷,但它会影响可复现构建的预期。想在断网机器上从源码装 Pi,应该下载 release source archive,不要只 clone Git 仓库。
Pi 和内置权限派不是一条路
拿 OpenCode 做个对照会更清楚。它的官方权限文档把 action 和 resource 写成有序规则,每条规则落在 allow、ask 或 deny。Pi 没有对应的内置规则引擎,默认就是当前进程权限。
| 维度 | Pi | OpenCode |
|---|---|---|
| 工具权限默认值 | 跟随启动进程的用户权限 | 无匹配规则时 ask |
| 项目资源加载 | Project Trust 决定是否加载项目 Extension 和设置 | 权限规则直接匹配 action 与 resource |
| 自定义拦截 | Extension 监听 tool_call | 配置规则和 Agent 级覆盖 |
| 真正隔离 | Docker、Gondolin、OpenShell 等进程外边界 | 内置权限之外,shell 仍拥有宿主用户权限 |
选型其实很直接。
如果你要给团队发一套开箱即用的统一策略,OpenCode 那类声明式规则更省心。Pi 更适合愿意自己组合工作流、需要替换 Provider、工具、TUI 与远程 Session 的人。代价是你必须知道 Extension 门禁和 OS 隔离不是一回事。
再补一条维护风险。Pi 最近版本密集,v0.82.0 到 v0.84.1 只隔了两周,功能推进很快。相应地,README 的包清单已经跟不上代码,OpenAI Codex 连接卡死的 Issue #4945 也积累了 76 条评论。拿它做个人工作台很有吸引力,直接塞进无人值守的生产流水线就该先做隔离、超时和 Provider 故障演练。
我会怎么安全地上手
如果只是想看 Pi 的手感,安装步骤很短。
npm install -g --ignore-scripts @earendil-works/pi-coding-agent
pi
真正要花心思的是启动目录和 Extension 来源。全局 Extension 放在 ~/.pi/agent/extensions/,它会进入所有项目,而且拥有当前用户的完整系统权限。项目里的 .pi/extensions/ 至少会经过 Project Trust,但一旦选择信任,里面同样是任意 TypeScript 代码,不是受限脚本。
我的做法会分成两档。
自己的仓库、人在屏幕前盯着时,我会保留宿主运行方式,只装读过源码的 Extension,再给危险工具加 tool_call 确认。这样改起来快,调试也顺手。
陌生仓库或无人值守任务,我会直接把整个 Pi 进程送进容器或 OpenShell,只挂载需要修改的工作区,API Key 也只传任务需要的那几枚。不要顺手挂载宿主的 ~/.pi/agent,否则 Session、设置和凭据也跟着进去了。
Gondolin 位于两者之间。它让 Pi 和 Provider 认证留在宿主,把内置 read、write、edit、bash、grep、find、ls 以及 ! 命令路由进 micro-VM。这里还有个容易漏掉的细节,自定义 Extension tool 默认仍在宿主运行,除非它自己也把执行委托给虚拟机。
门禁要看调用链,不能只看界面上有没有弹窗。
带走一个边界外移模式
Pi 这套设计,我愿意叫它 Boundary-Out Pattern,边界外移模式。
内核只保留无法外包的控制流,Extension 负责改造能力和体验,操作系统负责真正的隔离。三层各自说清楚能保证什么,也说清楚不能保证什么。
这个模式不只适合 Coding Agent。任何要运行第三方工具、插件或脚本的宿主,都可以先问一句,安全边界到底应该画在 hook 里,还是画在进程外。
Pi 的答案很硬。
别把一个确认弹窗叫沙箱。

评论互动