开源编程 Agent pi 的安全边界:项目信任与输出防护分别拦住了什么

2026-07-29

本文基于 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.mdCLAUDE.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 改道装包等子进程的输出不许流进 stdoutpackages/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 行不会互相穿插;遇到 ENOBUFSEAGAINEWOULDBLOCK 这三种错误码会等 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.mdCLAUDE.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 的钩子与可观测性

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。