开源编程 Agent pi 的安全边界:项目信任与输出防护分别拦住了什么
本文基于 pi v0.82.1(仓库 commit 027a584,2026-07-28)梳理,该项目仍在快速迭代,具体行为以官方文档 https://pi.dev/docs/latest 与仓库最新代码为准。
pi 仓库里真正跟安全沾边的只有两处代码,一处防的是仓库偷改你的配置,另一处防的根本不是攻击者、而是程序自己的日志把协议流写脏;真正意义上的隔离,官方文档白纸黑字说了不做。 把这句话记住,你再去看它的安全设计就不会误判。很多人看到「project trust」四个字,下意识以为这是个权限沙箱,进而放心让它在陌生仓库里跑——这个误解本身比任何漏洞都危险。
pi 是 earendil-works 开源的本地编程 Agent,MIT 许可证,主仓库在 https://github.com/earendil-works/pi ,截至 2026 年 7 月 GitHub 上约 8 万 star。下面按代码走一遍。
一、它把边界画在哪:一句话的安全模型
packages/coding-agent/docs/security.md 开头就把话说死了:pi 是本地 Agent,以启动它的那个用户账号的权限运行,并且把「该用户可写的文件」全部视为同一个本地信任边界之内。
这句话的推论很硬:如果攻击者已经能改你 home 目录下的文件、shell 启动脚本、环境变量或者 pi 的配置,那他本来就能影响你机器上的任何开发者工具,这类情况在仓库根目录的 SECURITY.md 里被明确列为不算漏洞。安全边界是操作系统给的,不是 pi 给的。
所以在 pi 的语境里,「安全代码」只剩两件事:一是别让一个你刚 clone 下来的仓库在你还没点头之前就改掉 Agent 的行为;二是别让程序自己的输出把机器可读的通道弄脏。前者是 project-trust.ts,后者是 output-guard.ts。
站内已有的 Agent 提示注入防御 和 Agent 最小权限设计 讲的是通用方法论——威胁怎么建模、权限怎么切分。本篇不重复那些,只做一件事:把一个真实开源项目里对应的代码逐行摊开,看方法论落到实处以后长什么样、哪些地方它索性承认做不到。
二、项目信任:拦住「仓库悄悄改配置」这一类
什么情况下才会触发
不是每次进目录都问你。packages/coding-agent/src/core/trust-manager.ts 里的 hasTrustRequiringProjectResources 负责探测,只有当前目录确实存在需要信任的项目资源时,整条链路才会启动:
const TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES = [
"settings.json",
"extensions",
"skills",
"prompts",
"themes",
"SYSTEM.md",
"APPEND_SYSTEM.md",
] as const;
这些条目是相对 .pi 目录检查的。除此之外,函数还会从当前目录一路往上找 .agents/skills;但用户级的 ~/.agents/skills 被显式排除掉——那是你自己的东西,永远算可信资源,哪怕你的工作目录就是 home。
一个细节值得记:光有一个空的 .pi 目录不算数。文档原话是 a bare .pi directory does not count,所以别指望建个空目录就能触发提示。
判定链路的优先级
packages/coding-agent/src/core/project-trust.ts 里的 resolveProjectTrusted 是唯一的裁决入口,顺序写得很直白:
if (options.trustOverride !== undefined) {
return options.trustOverride;
}
if (!hasTrustRequiringProjectResources(options.cwd)) {
return true;
}
命令行显式给了结论,就用命令行的;没有需要信任的资源,直接放行。往下依次是:先把 project_trust 事件抛给已加载的扩展,第一个返回明确 yes/no 的处理器说了算(extensions/runner.ts 里的 emitProjectTrustEvent 对返回 "undecided" 的处理器会继续往后找);扩展没接管,就查信任存储里的既有决定;再没有,才轮到全局设置里的 defaultProjectTrust,取值是 "ask"、"always"、"never" 三选一,默认 "ask";最后,如果连 UI 都没有(hasUI 为假),函数直接返回 false。
交互式下弹出的选项由 getProjectTrustOptions 生成,包括 Trust、Trust parent folder、Trust (this session only)、Do not trust、Do not trust (this session only)。带 session only 的两项 updates 是空数组,也就是不落盘。
信任存到哪、怎么继承
决定写在 ~/.pi/agent/trust.json,键是规范化后的绝对路径。读的时候 findNearestTrustEntry 从当前目录逐级向上找,命中最近的一条就返回。写入走 proper-lockfile 加锁,避免多个会话同时改这个文件打架。
「向上继承」这一点是双刃的。你选了 Trust parent folder,pi 会把父目录标成 true、同时把当前目录那条置为 null(删除),于是父目录下所有子目录——包括你明天才 clone 进来的仓库——都自动继承信任。这不是 bug,是设计,但你得知道自己按下去的是什么。
拦不住的那部分
文档里有一句话建议你抄到评审记录里:
Project trust is only an input-loading guard.
拒绝信任只是跳过受保护资源的加载。AGENTS.md 和 CLAUDE.md 这类上下文文件照常加载,除非你整个关掉上下文加载。而这两类文件恰恰是提示注入最省事的入口——SECURITY.md 直接承认了这点,说这种注入 cannot be protected against。
也就是说:项目信任守的是「配置与可执行资源的加载」,不是「模型读到了什么内容」。这两件事经常被混为一谈。
三、几个组成部分的分工
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
hasTrustRequiringProjectResources | 判断当前目录有没有需要信任才能加载的资源 | packages/coding-agent/src/core/trust-manager.ts | 进到一个带 .pi/settings.json 或 .agents/skills 的仓库时 |
resolveProjectTrusted | 编排整条信任判定链,产出一个布尔值 | packages/coding-agent/src/core/project-trust.ts | 每次启动都会走 |
ProjectTrustStore | 读写 trust.json,带文件锁,按最近祖先路径命中 | packages/coding-agent/src/core/trust-manager.ts | 想撤销之前那个「信任」决定时 |
project_trust 扩展事件 | 让用户级/全局扩展替你做信任决定 | packages/coding-agent/src/core/extensions/runner.ts | 团队想统一信任策略、不靠人工点选时 |
takeOverStdout / writeRawStdout | 接管 stdout,只放行显式写入的内容 | packages/coding-agent/src/core/output-guard.ts | 用 -p、--mode json、--mode rpc 时 |
| 子进程 stdio 改道 | 装包等子进程的输出不许流进 stdout | packages/coding-agent/src/core/package-manager.ts | 会话里触发包安装时 |
| 安全模型说明 | 讲清边界在哪、哪些不算漏洞 | packages/coding-agent/docs/security.md、仓库根 SECURITY.md | 做安全评审、写报告时 |
四、输出防护:它防的是「污染」,不是「攻击」
output-guard.ts 这个文件名容易让人想歪。打开看,里面一行跟权限、过滤、脱敏都不沾边,全部在处理一件事:谁有资格往 stdout 写字节。
核心是 takeOverStdout,它把 process.stdout.write 整个换掉,转发到 stderr:
process.stdout.write = ((
chunk: string | Uint8Array,
encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void),
callback?: (error?: Error | null) => void,
): boolean => {
if (typeof encodingOrCallback === "function") {
return rawStderrWrite(String(chunk), encodingOrCallback);
}
return rawStderrWrite(String(chunk), callback);
}) as typeof process.stdout.write;
接管之前拿到的原始写函数被存进模块级状态,此后只有 writeRawStdout 能用它。换句话说,接管生效以后,任何地方的 console.log、任何库顺手打的一行提示,都会被改道到 stderr;真正的 stdout 只剩下 writeRawStdout 这一个正门。
正门自己也有讲究。所有写入串在一个 promise 尾链上顺序执行,保证 JSON 行不会互相穿插;遇到 ENOBUFS、EAGAIN、EWOULDBLOCK 这三种错误码会等 10 毫秒重试,其它错误直接抛;写失败最终会让进程以 1 退出。waitForRawStdoutBackpressure 反复等到尾链不再增长为止,flushRawStdout 在此基础上再写一个空串,用来确保收尾时缓冲区确实排干了。
什么时候接管?main.ts 里的条件是:应用模式不是 interactive,并且不是那种只打印运行时元信息的纯命令(--help 之类)。RPC 模式在 runRpcMode 入口处还会再调一次,takeOverStdout 本身对重复调用是幂等的。
这条防线还延伸到了子进程。package-manager.ts 里 spawn 子进程时会看当前是否已接管:
stdio: isStdoutTakenOver() ? ["ignore", 2, 2] : "inherit",
也就是把子进程的 stdout 和 stderr 都绑到父进程的 fd 2。装包工具那些进度条、警告、废弃提示,一个字节都进不了你的 JSON 流。
这对你意味着什么:当你用 -p 或 --mode json 把 pi 接进流水线时,stdout 是可以直接喂给 JSON 解析器的纯净流,不需要在外面写正则去筛。这类保证的价值,做过 结构化输出不稳定排查 的人应该有体会——协议流被一行无关日志插进去,下游解析全崩,排查起来还特别费劲。但也要认清它的性质:这是工程健壮性设计,不是安全控制,它不会检查内容里有没有敏感信息。
五、边界与代价:官方承认不管的地方
security.md 里有一整节标题就叫 No Built-in Sandbox,态度非常明确:
- 内置工具能读文件、写文件、改文件、执行 shell 命令,权限跟 pi 进程一样;扩展是 TypeScript 模块,权限也一样。包安装、shell 命令、语言服务器、测试命令,全都是普通本地进程。
- 这是有意为之。文档给的理由是:一个只做一半的进程内沙箱,很容易被误当成安全边界,可它依然依赖宿主的 shell、文件系统、包管理器、凭证和扩展代码。真正的隔离得由操作系统或者虚拟化/容器边界来提供。
- 项目信任只是输入加载的门闩,它不会让不可信的代码、不可信的提示词、不可信的模型输出变得安全。
仓库根 SECURITY.md 的 Out Of Scope 列表更直接,下面这些都不按漏洞处理:本地代码执行与沙箱行为、用户自己装的扩展与技能的行为、在不可信仓库里工作的风险、装了不可信扩展/技能/包/工具的风险、不可信中间人代理导致的问题、把 pi 实例暴露到公网、提示注入攻击、第三方或用户自己控制的凭证泄露、以及任何需要「攻击者已能在目标机器上创建或修改文件、目录、符号链接、环境变量、shell 配置」作为前提的报告。文档甚至举了例子:往一个已被信任的 pi 配置文件里写恶意内容、导致 pi 执行命令或把凭证发到攻击者端点,也在范围之外。
放弃了什么,换来了什么,一目了然:放弃的是「开箱即用的越权拦截」,换来的是不做虚假承诺,以及跟本地工具链的顺畅集成。代价则由你承担——不适用的场景需要你自己识别出来。文档给的建议是,对不可信仓库、不打算盯着看的生成代码、无人值守的自动化,把 pi 放进容器、虚拟机、微虚拟机或者受策略控制的沙箱里跑,只挂载任务真正需要的文件和凭证;packages/coding-agent/docs/containerization.md 有对应的做法。这条思路跟 Agent 工作区隔离 讲的是同一件事,只不过 pi 把隔离整个交给了外部,而不是自己做半套。
顺带一提凭证:容器化建议里特别提到,别把宿主的 ~/.pi/agent 挂进容器,除非你确实希望容器能碰到宿主的会话、设置和凭证;只传任务必需的最小 API key,或者用短期凭证。还要提醒一句,海外模型服务商官方对中国大陆存在区域限制、不支持直连,市面上有第三方中转但这里不做任何背书,各家规则不同且会调整,以官方最新说明为准。
六、上手与避坑清单
非交互模式不会弹信任提示。 为什么会踩:你在交互式下点过 Trust,觉得脚本里跑也一样。实际上 -p、--mode json、--mode rpc 不显示提示,没有可用的既有决定时,"ask" 和 "never" 都会直接忽略那些资源,只有 "always" 才信任。怎么避:CI 和脚本里用 --approve/-a 或 --no-approve/-na 显式覆盖单次运行的信任结果,别依赖默认值。
信任是沿目录树向上继承的。 为什么会踩:当时图省事选了 Trust parent folder,那个父目录是你放所有 clone 的地方。之后每一个新拉下来的仓库都自动被信任,它的 .pi/extensions 会直接加载。怎么避:只对确定的项目目录点 Trust;临时看一眼别人的代码就用 session only 的那两项,它们不落盘;要撤销就去改 ~/.pi/agent/trust.json,删掉对应路径的那条。
上下文文件不在信任门内。 为什么会踩:拒绝了信任,以为整个仓库的内容都被隔离了。实际上 AGENTS.md、CLAUDE.md 照常加载,藏在里面的指令一样进模型。怎么避:把「读别人的仓库」当成一定会吃到注入来准备,靠工具审批和外部隔离兜底,别指望信任开关。
扩展可以抢走信任决定权。 为什么会踩:装了一个方便的第三方扩展,它注册了 project_trust 处理器,从此所有目录都被判成可信,你再也看不到提示。而且用户级/全局扩展和命令行 -e 扩展是在信任解析之前就加载的。怎么避:扩展只装看过源码或来源明确的;发现提示消失了,先查是不是某个扩展接管了这个事件。
别在扩展或工具里往 stdout 打日志。 为什么会踩:本地交互式下 console.log 一切正常,进了 --mode json 就发现输出「不见了」——其实是被改道到了 stderr。怎么避:调试信息本来就该走 stderr;机器可读内容交给运行时既有的输出路径,不要自己抢 stdout。至于调用日志本身该记多全、出问题时怎么查,MCP 安全边界 那篇里有一段专门在讲。
别在容器里挂宿主的 agent 目录。 为什么会踩:为了「登录状态别丢」顺手把 ~/.pi/agent 映射进去,等于把宿主的会话记录和凭证一起交给了容器里的进程。怎么避:容器里重新配最小凭证;确实要读写宿主工作区,用只读挂载,或者进出各拷一次。
七、收尾
评估任何本地编程 Agent,都可以用这三问过一遍,pi 只是给出了一份特别坦诚的答卷:它在什么时候加载谁写的代码、它的输出通道谁能写、它明说自己不管什么。
自检清单,五条,逐条能答上来再让它进你的仓库:~/.pi/agent/trust.json 里现在有哪几条记录、有没有一条是你后悔的父目录;CI 里跑的那条命令有没有显式带信任开关;有没有装来源不明、可能接管 project_trust 的扩展;处理陌生仓库时是不是在容器里;容器有没有多挂宿主的凭证目录。
想继续往下读,路径也清楚:先看 packages/coding-agent/docs/security.md 建立整体认知,再看 src/core/trust-manager.ts 弄清资源探测与存储,然后是 src/core/project-trust.ts 的判定顺序,最后翻 docs/containerization.md 把真正的隔离补上。代码不长,一个下午读得完,比任何二手解读都靠谱。
本篇属于一个把开源编程 Agent 项目 pi 逐层拆开讲的系列,整体地图见开源编程 Agent pi 是什么;沿着这条线往下,还可以看 开源编程 Agent pi 的扩展加载器 和 开源编程 Agent pi 的钩子与可观测性。