π Pi Agent 源码学习课程 阶段五 · 14/14

阶段五 · 集成实战 | 第 14 讲(全 14 讲 · 完结)

第 14 讲安全、容器化与综合实战项目

最后一讲回答两个问题:Pi 为什么「敢」以完整用户权限运行,以及我们如何用扩展、技能、包与 RPC 把前十三讲的知识组装成一个可分发的 Java 工作流,并安全地把它交给团队。

预计 4–6 小时 前置:第 11 讲扩展 · 第 12 讲包 · 第 13 讲 RPC 配套源码:pi-main 本地仓库
信任边界示意:不可信的模型输出进入以完整用户权限运行的 Pi 进程,真正的安全边界只能在操作系统或虚拟化层 模型生成的命令与代码 = 不可信输入 宿主机 · Pi 以启动用户的完整权限运行 Pi 进程 read · write · bash · 扩展 · MCP · 子进程 无逐次审批 真正的安全边界只能建在 OS / VM / 容器 / 策略沙箱层 —— 本讲主题
图 14-0 · Pi 信任模型总览:进程内没有权限系统,边界必须外移

学习目标

准确复述 Pi 的信任模型:默认以启动用户的完整权限运行、不逐次审批、无内置权限系统是有意的极简,以及这对部署方式意味着什么。
说清 project trust(项目信任)保护的是启动时加载哪些项目资源,它不是运行时边界,且有一个 sessionDir 先行读取的例外。
列出凭据与隐私的三条泄露面:~/.pi/agent 目录、会话导出/分享内容、/bug 报告附件。
对比三种隔离方案——Gondolin 工具路由 micro-VM、Plain Docker 整进程容器、OpenShell 策略沙箱——各自把信任边界画在哪里、各自漏掉什么。
从零搭建 pi-java-review 综合实战包:扩展拦截 + mvn 校验、含 references/ 的技能、提示模板、RPC 批量预审脚本,并打包分发。
走通 Pi 的贡献流程:AGENTS.md 协作规则、lgtm 贡献门、CHANGELOG 规范、npm run check / ./test.sh / pi-test.sh,并知道 durable、p2、pi-chat 各自的方向。
  • 精读:pi-main/packages/coding-agent/docs/security.md(全文约 90 行,句句是边界定义)
  • 精读:pi-main/packages/coding-agent/docs/containerization.md(四种隔离方式的操作步骤与限制)
  • 通读:pi-main/AGENTS.md(开发规则全文)与 pi-main/CONTRIBUTING.md(贡献门)
  • 拓展:pi-main/packages/durable/README.md(Persist and Resume)、pi-main/apps/p2/README.md(前半)

§14.1信任模型:完整用户权限与「有意的极简」

一句话类比:Pi 不是沙箱里的实验宠物,而是一个拿着你的工牌、坐在你的工位上的自动化助手——它能做什么,完全取决于你的操作系统账户能做什么。

security.md 开篇第一句就是原则:「Treat model-generated commands and code as untrusted.」(把模型生成的命令和代码当作不可信输入。)Pi 可以以启动它的账户权限读取、修改、执行文件,并且不会在每次工具调用前请求批准。扩展(extensions)、包安装器(package installers)、语言服务器(language servers)以及其他子进程,除非被操作系统或虚拟化边界限制,否则都以同样的权限运行。

不可信输入从哪里来?文档列举得很具体:文件内容、代码注释、说明文字、命令输出、模型回复本身,都可以通过提示注入(prompt injection)操纵模型。第 9 讲我们讲过上下文组装——凡是进入上下文的文本,都是模型的「指令候选」。一个恶意仓库的 README、一段被注入的测试输出,都可能在下一次工具调用中变成行动。

packages/coding-agent/docs/security.md节选 · 开篇与边界结论
Treat model-generated commands and code as untrusted.
Pi can read, change, and execute files with the permissions of the
account that started it, and it does not ask for approval before
every tool call. Extensions, package installers, language servers,
and other child processes run with those same permissions unless an
operating-system or virtualization boundary restricts them.

...
Watching the transcript, using project trust, and reviewing changes
do not create a security boundary.
要点:文档明确排除三种「看起来像防线」的东西——盯 transcript、project trust、事后 review diff。它们是可观测性与流程控制,不是安全边界。

为什么「不内置权限系统」是设计决策,而不是缺失

第一讲我们就介绍过 Pi 的哲学:极简、可扩展的 Agent Harness。这个哲学在安全上的直接推论是:核心不实现权限系统,把它留给操作系统层与扩展生态。仓库里其实「有」权限门——只是它们是扩展示例,而不是核心功能:

  • packages/coding-agent/examples/extensions/permission-gate.ts——工具调用前的权限确认;
  • packages/coding-agent/examples/extensions/confirm-destructive.ts——破坏性操作二次确认;
  • packages/coding-agent/examples/extensions/timed-confirm.ts——带倒计时的确认。

这些扩展通过第 11 讲的 tool_call 事件拦截调用、弹确认框,本质上是在进程内加流程闸门。它们能防「手滑」,防不了「恶意」——因为闸门本身与被拦截的工具运行在同一个进程、同一个用户身份里。真正对抗恶意输出的边界,必须由不属于 Pi 进程的东西来执行:专用 OS 用户、容器、虚拟机或策略沙箱。

这个取舍还有一层工程理由:一个 TypeScript 进程内的权限检查,覆盖不住 bash 拉起的任意子进程、覆盖不住 node 的原生调用、更覆盖不住 MCP 服务器。与其做一个「看起来安全」的进程内开关,不如明确告诉用户:边界请画在进程外。这与 CONTRIBUTING.md 的第一句哲学一致——「pi's core is minimal. If your feature does not belong in the core, it should be an extension.」(核心保持极简;不属于核心的功能做成扩展。)

Java 视角:SecurityManager 的教训

Java 曾用进程内的 SecurityManager 做「每一步都检查权限」的模型,最终在 JEP 411 中被标记为废弃——因为它无法可信地覆盖原生库、子进程与文件描述符,维护成本却极高。Pi 的选择与 Java 社区的结论殊途同归:进程内权限检查挡不住逃逸到 OS 层的执行。就像现代 Java 服务不会靠 SecurityManager 隔离不可信代码,而是把不可信任务放进单独容器或 VM,Pi 也把安全边界交给容器化方案(§14.4)。进程内的 tool_call 拦截扩展,对应的是 Spring 里方法级注解校验——防呆可以,防黑不行。

§14.2Project Trust:启动边界,不是运行时边界

一句话类比:project trust 像 IDE 打开陌生项目时问你的那句「是否信任此项目并加载它的构建脚本」——它管的是启动时加载不加载,不是运行时能不能碰你的文件。

Pi 在工作目录中发现以下任何一项时,会要求你做出项目信任(project trust)决策,防止一个文件夹在你批准之前悄悄加载可执行扩展:

packages/coding-agent/docs/security.md节选 · 受保护资源清单
- .pi/settings.json
- .pi/mcp.json
- .pi/extensions, .pi/skills, .pi/prompts, .pi/themes
- .pi/SYSTEM.md 或 .pi/APPEND_SYSTEM.md
- 当前目录或祖先目录中的项目级 .agents/skills
注意:一个空的 .pi 目录本身不触发信任询问;触发的是其中的可执行/可注入资源。

授予信任后,Pi 才会加载:项目设置、.pi/mcp.json 里的项目 MCP 服务器、.pi 下的扩展/技能/提示模板/主题/系统提示词文件、项目设置声明的缺失包(missing packages),以及项目本地与项目包扩展。拒绝信任则跳过这些受保护资源——但有两个关键例外:

  1. sessionDir 先读例外:Pi 在解析 project trust 之前,就要读取项目的 sessionDir 设置来选择/创建会话。拒绝信任无法撤销这次初始目录查找——所以文档说 project trust 「不是一个完整的启动边界」。
  2. 上下文文件不设门:AGENTS.override.md、AGENTS.md、CLAUDE.md 无论是否授信都会加载(除非你关闭 context loading)。所以仓库里的说明文件永远要当作不可信输入对待。

更要紧的一句:project trust 不限制工具调用能访问或影响什么。Pi 启动之后,启用的工具仍然使用 Pi 进程的操作系统权限;目录里的指令内容也照样能影响模型。信任决策只回答「启动时加载哪些资源」,不回答「运行时能做什么」。

决策顺序与保存位置

当受保护资源存在且没有命令行覆盖时,Pi 按以下顺序决策:

  1. 命令行 --approve / --no-approve 优先级最高;
  2. 用户级与命令行扩展可处理 project_trust 事件,第一个返回 yes/no 的扩展拥有决定权;
  3. 否则查保存过的决策:当前目录或祖先目录的最近决策生效(保存在 ~/.pi/agent/trust.json,按规范化路径记录);
  4. 仍无决策时,遵循全局设置 defaultProjectTrust,默认值 "ask"。交互中用 /trust 可保存决策供未来进程使用。

print、JSON、RPC 模式无法弹出内置信任提示。没有覆盖、扩展决策或保存决策时:defaultProjectTrust: "always" 会加载受保护资源;"ask" 或 "never" 则跳过。自动化运行需要一次性明确决策时,用 --approve 或 --no-approve。

Java 视角:启动期配置加载 ≠ 运行时授权

project trust 的语义接近 Spring Boot 在启动期决定「扫描不扫描这个 jar 里的 @Configuration」——它控制的是组件装载,而不是每个方法调用时的权限。授予 project trust 之后,.pi/extensions 里的代码相当于被扫描进容器的 Bean,获得与主程序完全相同的运行身份;而 AGENTS.md 之类上下文文件像永远会被读取的 application.properties,不经过这道门。想让「运行时」也受限,只能靠进程外边界——这是下一节容器化要解决的问题。

§14.3凭据、会话导出与 /bug:三条容易被忽视的泄露面

容器化解决「模型能碰什么」;这一节解决「我们自己会把什么带出去」。三条泄露面都写在文档里,却最容易被日常习惯掩盖。

泄露面一:~/.pi/agent 是你的全部身家

用户级配置目录 ~/.pi/agent 集中了认证文件 auth.json(/login 的产物)、设置、已安装扩展与全部会话记录。containerization.md 的警告非常直白:挂载宿主的 ~/.pi/agent 等于把你的 Pi 凭据、设置、扩展和会话全部交进容器。对 p2 这类服务器形态,README 同样强调 provider 凭据保存在服务端私有的 auth.json 里、数据目录必须保持私有。

泄露面二:会话导出与分享

security.md 要求:导出或分享会话前必须先审查——会话可能包含提示词、工具参数、命令输出、文件内容,以及对话过程中暴露出来的凭据。第 10 讲我们看过 JSONL 会话文件的结构:user 消息、toolCall 参数、toolResult 全文落盘。你在会话里 cat 过一次 .env、粘贴过一次 token,它就永远在导出物里。分享 session 文件、HTML 导出、截图求助之前,先把这三类内容过一遍。

泄露面三:/bug 报告内容

/bug [description] 用于准备一份发给 Pi 开发者的私密错误报告(sessions.md)。你可以选择包含会话 transcript、不包含、或让当前模型先总结一遍问题。关键动作是提交前审查:transcript 或模型生成的摘要都可能含敏感对话数据。让模型代写摘要并不等于脱敏——它只是把原文压短,敏感字段大概率原样保留。上传目标可以通过环境变量 PI_RADIUS_GATEWAY 覆盖(environment-variables.md)。

Java 视角:会话导出 = heap dump / 全量日志

把 Pi 会话导出理解为「带着请求参数的全量调用日志」或 heap dump:排查问题黄金,公开分享灾难。Java 团队对日志有脱敏中间件(掩盖密码、token、身份证号),但 Pi 会话里的敏感内容散布在工具参数、命令输出与文件内容中,没有模式可匹配——所以官方给的建议不是「上脱敏工具」,而是导出前人肉审查。养成习惯:凡是进了会话上下文的 secret,视为已泄露,事后轮换(rotate)。

§14.4容器化三方案:把边界画在进程外

一句话类比:与其给助手配一个「按步审批」的监工,不如把它关进一间只能碰到指定工具的房间——房间由操作系统或虚拟机看守,Pi 自己说了不算。

隔离的总体思路只有两种:把整个 Pi 进程放进隔离环境(Plain Docker、Docker Sandboxes、OpenShell),或者 Pi 留在宿主、只把工具执行路由进隔离环境(Gondolin 扩展)。位置不同,扩展的运行位置就不同:整进程隔离时扩展跟着进边界;Gondolin 模式下,其他扩展工具仍在宿主运行,除非它们也把工作委托进 micro-VM。

三种隔离方案的信任边界对比:Plain Docker 整进程进容器;Gondolin 仅内置工具进 micro-VM;OpenShell 用策略沙箱约束整进程 Plain Docker · 整进程 宿主机 凭据:环境变量注入容器 容器(最强实用边界) Pi 进程 内置工具 · ! 命令 · 扩展 全部在边界内 边界完整;凭据必须放进容器 Gondolin · 工具进 micro-VM 宿主机 Pi 进程 + 其他扩展(宿主) 凭据文件 · 留在宿主 工具路由 micro-VM(QEMU) read write edit bash grep find ls /workspace 直通挂载 · ! 命令 ⚠ 环境变量会被继承 保宿主界面;只隔离被路由的工具 OpenShell · 策略沙箱 宿主机 gateway(本地或远程) 凭据 / 推理路由(可留宿主) 策略沙箱 Pi + 内置工具 + ! + 扩展 文件 · 进程 · 网络 · 凭据策略 策略决定能碰什么 控制粒度最细;远程需显式传文件
图 14-1 · 三方案信任边界对比。实线酒红框 = 真正被执行的隔离边界;虚线 = 宿主。Gondolin 的边界只覆盖被覆盖(override)的七个内置工具,Pi 进程与其余扩展仍在宿主。
方案Pi 在哪运行隔离了什么凭据处理最适合
Plain Docker 容器内 Pi、内置工具、! 命令、扩展,整进程 凭据通过环境变量传入容器 最简单直接的本地整进程容器边界
Gondolin 扩展 宿主 仅内置工具与 ! 命令进 micro-VM;Pi 与其他扩展留在宿主 文件型凭据留宿主;但 VM 内命令继承宿主环境变量 要保留宿主界面、只隔离工具执行的场景
OpenShell 本地或远程沙箱 Pi、内置工具、! 命令、扩展,整进程 凭据与推理路由按策略控制,可留宿主 需要文件系统 / 进程 / 网络 / 凭据细粒度策略
Docker Sandboxes
(托管变体)
托管沙箱 同 Plain Docker,由 sbx 托管 真实 provider 凭据留宿主,由代理替换为占位符 想要托管隔离又不暴露真实 API key

方案一:Plain Docker——最直接的整进程边界

官方文档给出的最小镜像基于 node:24-bookworm-slim,全局安装 CLI 后以 pi 为入口。两条关键纪律:凭据通过 -e 环境变量显式传入(只传任务需要的那一个);容器本地的 Pi 主目录用命名卷 pi-agent-home 保存,而不是挂载宿主的 ~/.pi/agent。

packages/coding-agent/docs/containerization.md(Dockerfile.pi 节选)Docker
FROM node:24-bookworm-slim
RUN apt-get update \
  && apt-get install -y --no-install-recommends bash ca-certificates git ripgrep \
  && rm -rf /var/lib/apt/lists/*
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
WORKDIR /workspace
ENTRYPOINT ["pi"]

# 启动(宿主工作目录即要访问的目录):
# docker run --rm -it -e ANTHROPIC_API_KEY \
#   -v "$PWD:/workspace" -v pi-agent-home:/root/.pi/agent pi-sandbox
自检:进入 Pi 后执行 !pwd 应报告 /workspace。不想让写入直通宿主,就改只读挂载或去掉挂载、用复制进出。

方案二:Gondolin——宿主保存认证,工具进 micro-VM

Gondolin 是一个本地 Linux micro-VM(QEMU),示例扩展把 Pi 进程与文件型 provider 凭据留在宿主,把内置工具与用户 ! 命令路由进 VM 执行。它覆盖(override)的工具恰好是七个:read、write、edit、bash、grep、find、ls;宿主工作目录挂载为 VM 内的 /workspace,文件修改直通宿主。环境要求:Node.js 23.6+ 与系统包管理器安装的 QEMU。

三个必须记住的限制:第一,VM 内命令继承宿主进程的环境变量——如果你的 API key 走环境变量,它在 VM 里同样可见,这种模式就不是凭据边界,除非先移除敏感变量或改造扩展的环境处理;第二,其他扩展工具仍在宿主运行,除非它们显式把操作委托进 VM——给 Gondolin 加工具前先读示例源码,别无意中凿穿边界;第三,它保护的是「工具执行」这一段,不保护 Pi 进程本身。

方案三:OpenShell——按策略画出最细的线

NVIDIA OpenShell 提供本地或远程沙箱,策略覆盖文件系统、进程、网络、凭据与推理路由。每个沙箱需要一个激活的 gateway:openshell gateway add 后 gateway select;然后 openshell sandbox create --name pi-sandbox --from pi -- pi 让 Pi 连同工具与扩展整进程进入边界。远程 gateway 不做宿主目录 bind-mount,需要 sandbox upload / sandbox download 显式传文件。配置推理路由后,原始模型凭据可以留在沙箱外,Pi 指向 gateway 暴露的 OpenAI / Anthropic 兼容端点。

无论选哪种:先决定「暴露什么」

隔离环境里能碰到什么,取决于你暴露了什么:读写挂载 = 容器可改宿主文件;挂载 ~/.pi/agent = 交出凭据与会话;传入的环境变量对内部进程全量可见;开放网络 = 代码与工具输出可以外传。原则只有一句:只暴露任务需要的工作目录、凭据与网络目的地;不需要写入就用只读挂载或复制进出。

Java 视角:Gondolin 是 sidecar / 代理模式

Gondolin 的形态你在后端一定见过:主进程持有凭据与连接池,把不可信的计算委托给隔离执行器——像把图片压缩任务丢进受限的 worker 容器,或通过数据库代理替换真实密码(Docker Sandboxes 的凭据占位符替换正是这个思路)。对应地,它的坑也一样:只要有一条路径绕过代理直连宿主(未被覆盖的扩展工具、被继承的环境变量),边界就名存实亡。评估这类架构时永远问一句:调用方还有没有不经代理触达宿主的通道?

§14.5安全清单:把「不可信输入」当作默认前提

把 security.md 的建议浓缩成一张可以贴在团队 Wiki 上的清单。它不能替代隔离,但能显著缩小暴露面、加快恢复:

  • 只用可信来源的扩展与包:扩展在 Pi 进程内执行,等于「以你的身份运行别人的代码」;第三方包可能内置指示模型运行程序的技能,安装前先读源码,项目包声明在授予 project trust 前先审查。
  • 逐行审查 SKILL.md 与 scripts/:技能的本质是「指示模型做什么」的文本 + 可执行脚本。看三处:frontmatter 的 description 何时触发加载、正文指令是否要求运行脚本、scripts/ 与 references/ 里有没有奇怪的内容。不熟的技能,在授予 project trust 前别加载。
  • 导出与会话分享前脱敏:提示词、工具参数、命令输出、文件内容、对话中暴露的凭据都在会话里;/bug 的 transcript 与模型摘要同样要过目。
  • 最小授权:专用 OS 用户或容器边界收窄权限;只挂载任务所需目录;能只读就只读;网络按需开放。
  • 短命且窄域的凭据:优先使用小范围、短时效凭据;能留在外层(宿主 / gateway / 代理)就不带进环境;进入过会话上下文的 secret 视为已泄露,及时轮换。
  • 可恢复性:大改动前打快照、备份或确认版本控制干净;应用生成结果到另一个系统前先 review diff。

最后重申一条否定式结论,方便你在 code review 里引用:盯 transcript、project trust、事后审查 diff,都不构成安全边界。它们回答「发生了什么」,不回答「能发生什么」。

§14.6综合实战:pi-java-review 包从零到可分发

这是全课程的毕业项目:用第 11 讲的扩展、第 12 讲的技能与包、第 13 讲的 RPC,为一个真实 Java 工作流组装一个可安装、可分享的 Pi 包。它不新增框架知识,只考验「把零件装成机器」的能力。

目标场景:团队用 Pi 辅助维护一个 Maven 单模块 Java 服务。我们希望——① 模型只能写 src/main 与测试目录,误碰别处直接拦截;② 每轮改动后自动跑 mvn compile 把结果喂回给模型;③ 评审遵循团队规范(技能 + 参考清单);④ CI 或本地一条命令批量预审 PR 里的 Java 文件。

pi-java-review/(实战项目新建,目录骨架)结构
pi-java-review/
├── package.json                # pi 清单:声明扩展/技能/模板
├── extensions/
│   └── java-guard.ts           # 写保护 + mvn 校验
├── skills/
│   └── java-review-standards/
│       ├── SKILL.md            # 技能入口(frontmatter + 指令)
│       └── references/
│           └── checklist.md    # 评审清单(按需加载的参考)
├── prompts/
│   └── pr-review.md            # /pr-review 提示模板
└── scripts/
    └── pr-precheck.mjs         # RPC 批量预审脚本(不随包加载)
布局依据:packages.md 的无清单约定目录为 extensions/、skills/、prompts/、themes/;下面我们仍显式写 pi 清单,路径一目了然。

14.6.1 扩展:写路径拦截 + mvn compile 回灌

两个钩子各司其职:tool_call 在工具执行前拦截(可返回 { block: true, reason },文档明确该事件可阻塞);agent_end 在一轮运行结束时做编译校验,用 pi.exec 不经 shell 执行 Maven,再把结果通过 pi.sendMessage 以 followUp 方式送回上下文(文档:followUp 等待 agent 空闲后投递)。骨架中所有需要按环境调整的点都用 ★ 注释标出:

pi-java-review/extensions/java-guard.ts(骨架,标注 ★ 处需按环境调整)TypeScript
/**
 * pi-java-review · java-guard
 * 1) 拦截对允许前缀之外的 write / edit
 * 2) 一轮运行若有写操作,结束后跑 mvn compile 并把结果回灌给模型
 */
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

// ★ 按团队规范调整:允许写入的路径前缀(相对工作目录,/ 分隔)
const WRITABLE_PREFIXES = ["src/main/java/", "src/main/resources/", "src/test/java/"];
// ★ Windows 通常可执行名是 mvn.cmd;也可改用 mvnw(包装器)
const MVN = process.platform === "win32" ? "mvn.cmd" : "mvn";
// ★ 编译超时(毫秒):按项目体量调整
const MVN_TIMEOUT_MS = 120_000;

export default function (pi: ExtensionAPI) {
  let dirty = false;   // 本轮是否发生过成功的写操作
  let checking = false; // mvn 运行中的重入保护

  pi.on("tool_call", async (event, ctx) => {
    if (event.toolName !== "write" && event.toolName !== "edit") return undefined;

    // event.input 是 schema 校验后的工具参数——这里是「决定的信任边界」,
    // 做一次形状收敛符合 AGENTS.md 的校验规则;正式版可改用 isToolCallEventType 窄化。
    const path = String((event.input as { path?: unknown }).path ?? "");
    const rel = path.replaceAll("\\", "/").replace(/^\.?\//, "");
    const allowed = WRITABLE_PREFIXES.some((p) => rel.startsWith(p));

    if (!allowed) {
      if (ctx.hasUI) ctx.ui.notify(`已拦截对 ${path} 的写入`, "warning");
      return { block: true, reason:
        `pi-java-review: ${path} 不在允许写入的前缀内(${WRITABLE_PREFIXES.join(", ")})` };
    }

    dirty = true; // 放行的写操作,留待本轮结束校验
    return undefined;
  });

  pi.on("agent_end", async (_event, ctx) => {
    if (!dirty || checking) return;
    dirty = false;
    checking = true;
    try {
      const result = await pi.exec(MVN, ["-q", "-DskipTests", "compile"],
        { timeout: MVN_TIMEOUT_MS, signal: ctx.signal });
      const output = [result.stdout, result.stderr].filter(Boolean).join("\n").slice(0, 4000);
      pi.sendMessage({
        customType: "pi-java-review",
        content: result.code === 0
          ? "mvn compile 通过。"
          : `mvn compile 失败(exit ${result.code}):\n${output}`,
        display: true,
      }, { triggerTurn: true, deliverAs: "followUp" });
    } finally {
      checking = false;
    }
  });
}
API 依据:tool_call 可阻塞、返回 { block, reason }(extensions.md);pi.exec(command, args, options) 返回 stdout / stderr / code / killed / truncated;pi.sendMessage(message, { triggerTurn, deliverAs: "followUp" }) 在 agent 空闲后投递并触发新一轮。★ 需调整:前缀表、Maven 可执行名、超时;若要更精确地在收尾前追加会话 entry,可研究 agent_before_settle。

两个工程细节值得强调。其一,dirty 只在放行写入时置位、checking 防重入,避免「编译失败 → 回灌 → 新一轮又触发编译」的死循环——回灌那一轮通常没有写操作,循环自然收敛。其二,这是防呆闸门而不是安全边界(§14.1):它与工具同进程,真正对抗恶意输出的仍是容器化方案。

14.6.2 技能:规范正文 + 按需加载的 references

第 12 讲的关键结论在这里落地:启动时只有 name 与 description 进入系统提示词,完整指令在任务匹配时才被读取——所以 SKILL.md 保持短小,把大清单放进 references/,由技能正文指示模型「先读再审」:

pi-java-review/skills/java-review-standards/SKILL.md(骨架)Markdown
---
name: java-review-standards
description: 团队 Java 代码评审规范。在评审、修改 Java 源码、
  处理 PR 或解释 Java 改动时使用。
---

# Java 评审规范

1. 审查任何 Java 改动前,先完整读取 `references/checklist.md`。
2. 评审结论按严重级别分组:blocker / major / minor / nit。
3. 每条发现必须给出:位置(文件:行)、问题、为什么、建议修复。
4. 未经明确要求不要直接修改文件;建议修复以 diff 形式给出。
5. ★ 在此追加团队特有规则(框架版本、日志库、异常规范等)。
要点:description 是路由信息——写清「做什么 + 何时用」,不要写「帮助处理 Java」这种无法匹配的描述。引用 references/ 用技能目录内的相对路径。

references/checklist.md(骨架)按团队习惯填充,例如:空安全与边界、异常与资源关闭(try-with-resources)、并发与不可变、日志级别与敏感字段、事务边界、API 兼容性、测试覆盖缺口。分发前记得用第 12 讲的验证流程:启动看诊断、/skill:java-review-standards 强制加载、改完 /reload。

14.6.3 提示模板:/pr-review

pi-java-review/prompts/pr-review.md(骨架)Markdown
---
description: 按团队规范审查当前 git 改动
argument-hint: "[focus]"
---
按 java-review-standards 技能中的规范,审查当前 git 改动
(未暂存与已暂存都要看)。重点:${1:-空安全、异常处理、并发安全、日志规范}。
输出按文件分组的发现列表;只报告问题,不要修改文件。
语法:文件名即命令名 /pr-review;${1:-默认} 是第一个位置参数带默认值(prompt-templates.md)。技能名会出现在系统提示词里,模板里按名字引用即可被模型路由到。

14.6.4 RPC 脚本:PR 批量预审

第 13 讲的 RpcClient 在这里变成 CI 的第一道闸:对 git diff --name-only 得到的每个 Java 文件发一次审查 prompt,收集文本输出。脚本放在 scripts/(不进 pi 清单,不会被 Pi 加载),由人或流水线显式运行:

pi-java-review/scripts/pr-precheck.mjs(骨架,标注 ★ 处需按环境调整)Node.js
// 用法示例:
//   git diff --name-only origin/main...HEAD -- "*.java" | xargs node scripts/pr-precheck.mjs
// 前置:npm install @earendil-works/pi-coding-agent(本脚本自行依赖)
import { RpcClient } from "@earendil-works/pi-coding-agent";

const files = process.argv.slice(2).filter((f) => f.endsWith(".java"));
if (files.length === 0) { console.error("没有待审的 .java 文件"); process.exit(1); }

const client = new RpcClient({
  cliPath: process.env.PI_CLI ?? "pi",  // ★ 仓库示例用 dist/cli.js,按安装方式调整
  args: ["--no-session",
         "--provider", "anthropic",        // ★ 按团队默认调整
         "--model", "claude-sonnet-4-5"], // ★ 按团队默认调整
});

let text = "";
const unsubscribe = client.onEvent((event) => {
  if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
    text += event.assistantMessageEvent.delta;
  } else if (event.type === "tool_execution_start") {
    console.error(`  [tool] ${event.toolName}`);
  }
});

try {
  await client.start();
  for (const [i, file] of files.entries()) {
    text = "";
    console.error(`(${i + 1}/${files.length}) ${file}`);
    await client.promptAndWait(
      `按 java-review-standards 技能审查 ${file} 在本次 diff 中的改动;` +
      `只报告问题(blocker/major/minor/nit),不要修改任何文件。`);
    console.log(`\n=== ${file} ===\n${text.trim()}`);
  }
} finally {
  unsubscribe();
  await client.stop();
}
依据:RPC 模式以子进程 JSONL 双向通信;promptAndWait() 内部先订阅事件再等待空闲(rpc.md 生命周期一节);文本增量来自 message_update 的 text_delta(与 examples/rpc-client.ts 同款)。★ 需调整:CLI 路径、provider/model、审查 prompt 措辞。

把这个脚本接入 CI 时,回到 §14.4 的纪律:RPC 子进程继承流水线环境的凭据与网络;预审任务只需要读仓库 + 调模型,那么就在只读挂载、无多余凭据的容器里跑它。

14.6.5 打包与分发

pi-java-review/package.json(骨架)JSON
{
  "name": "pi-java-review",
  "version": "0.1.0",
  "keywords": ["pi-package"],
  "pi": {
    "extensions": ["./extensions/java-guard.ts"],
    "skills": ["./skills/java-review-standards"],
    "prompts": ["./prompts/*.md"]
  },
  "peerDependencies": {
    "@earendil-works/pi-coding-agent": "*"
  }
}
依据(packages.md):宿主提供的包(pi-coding-agent 等)声明在 peerDependencies 且用 "*",不要放进 dependencies,避免绕过 Pi 的扩展模块映射。分发给同事:pi install ./pi-java-review(本地路径)或发 npm/git 后 pi install git:…;试用不落盘可 pi -e ./pi-java-review。

§14.7贡献流程:从 issue 到 PR

学完源码之后,最自然的下一步是回馈一个文档修正或小 bugfix。Pi 的贡献流程比多数开源项目更「硬」——先理解规则,再动手。

协作规则要点(AGENTS.md)

  • The One Rule:You must understand your code.(必须理解你提交的代码。)用 AI 写代码可以,提交不理解的东西不行;在仓库根目录跑 agent,让它自动读取 AGENTS.md 并遵守。
  • 核心极简:不属于核心的功能做成扩展;会让核心膨胀的 PR 大概率被拒。
  • 代码质量:读全文件再大改;尽量避免 any;不在 TypeScript 已保证类型的地方做运行时检查,只在「决定的信任边界」(wire 输入、schema 校验后的参数)校验;只用可擦除 TypeScript 语法(Node strip-only 模式);不用内联 import;packages/ai/src/models.generated.ts 不许手改。
  • Git 纪律:同目录可能有多个 pi 会话并行工作——只提交自己改的文件、显式 git add <path>、禁止 git add -A / git add . / reset --hard / stash / --no-verify;提交信息格式 {feat,fix,docs}[(ai,tui,agent,coding-agent)]: 说明;关闭 issue 在信息里写 fixes #123(每个 issue 重复一次关键字)。
  • CHANGELOG 规范:位置在 packages/*/CHANGELOG.md(每个包一个)。新条目一律追加到 ## [Unreleased] 下既有的 ### Breaking Changes / Added / Changed / Fixed / Removed 小节,不重复建节;已发布版本段落(如 ## [0.12.2])不可修改;分支不是 main 的改动不写 changelog。外部贡献按模板标注 issue/PR 号与作者。注意分工:CONTRIBUTING.md 明确外部贡献者不要改 CHANGELOG.md,条目由维护者添加。

贡献门(CONTRIBUTING.md)

新贡献者的 issue 与 PR 默认自动关闭,维护者每日复查并重新打开值得处理的。解锁方式是维护者在 issue 回复中的口令:lgtmi(未来 issue 不再自动关闭)与 lgtm(未来 issue 和 PR 不再自动关闭;只有它授予 PR 提交权)。口令必须出现在回复的开头(可前置 @username)或结尾。issue 质量标准:用模板、一屏以内、用自己的话写、说清 bug 与影响、想自己实现就说明。周五到周日的 issue 不保证被看;紧急问题去 Discord。大改动先看 RFC:rfc.earendil.com(关键词 pi)。

提交前的检查命令

pi-main/package.json · pi-test.sh(节选)命令
# 提交 PR 前,两条必须全部通过:
npm run check     # biome + 依赖检查 + tsc --noEmit + 浏览器冒烟等
./test.sh         # 在隔离环境中跑完整测试套件

# pi-test.sh:从源码直接运行 pi(source resolver,无需构建);
# --no-env 会清掉全部 provider 凭据环境变量(列表见
# packages/ai/src/env-api-keys.ts),适合验证「无凭据」行为:
./pi-test.sh --no-env -p "hello"
细节:test.sh 自建临时 HOME/TMP/NPM 配置的隔离环境,且删除目录前校验归属标记(.pi-test-owned)——连测试脚本本身都在演示「最小授权」。
Java 视角:这相当于一个带强门禁的 OSS 项目

把贡献门类比成一个「默认拒绝」的准入系统:新身份的请求先被自动关闭(default deny),维护者用 lgtm 显式放行(allowlist),之后该身份的请求免检直达——像防火墙从黑名单模型换成白名单模型。npm run check 之于 Pi,相当于 mvn verify + Spotless + ArchUnit 的合体:格式、依赖策略、类型、入口图、冒烟一次跑完。想给 Pi 提交第一个 PR?从文档错字或文档修正开始,先在 issue 里说清「错在哪、为什么重要」,比直接甩 PR 通过率高得多。

§14.8延伸阅读:durable、p2 与 pi-chat

课程主线止步于 coding-agent 的当前形态;仓库里还有三个值得持续关注的方向,它们回答同一个问题的不同侧面:Agent 运行时要变得更可靠、更长期,还需要什么?

pi-durable:Persist and Resume

packages/durable(实验性)是一个持久化 Agent 运行时:会话、模型轮次、工具调用与你自己的状态,在对外可见之前先提交到存储。进程在执行中途死掉,重开存储后从停止处继续;死掉时所处的那次模型请求计为一次失败尝试并被重试——一个反复杀死进程的请求最终会结束(Persist and Resume)。类比 Java:这是 WAL(预写日志)+ 幂等重试在 Agent 上的翻版,「先落盘再响应」换来了崩溃恢复能力。它基于 pi-ai 与 chord(文档状态层),API 随版本变化且不作通知。

p2:自托管的 Durable UI(原型)

apps/p2 是用独立 Chord 插件搭的自托管 Pi Durable UI 原型,README 第一行就警告:实验性、即将被替换、不要在其上构建任何依赖。安全模型与本章呼应:所有者对服务器与 worker 拥有完整 agent 与 shell 控制——这不是服务端文件系统隔离;服务只监听 loopback,身份由私有数据目录(0700)中的 owner-key 决定,签名 cookie、一次性登录 token、设备会话各有细粒度撤销语义。它是「把第 14 讲的威胁模型搬到服务端形态」的活教材:凭据仍在私有 auth.json,隧道与代理的每个环节都可能成为泄露面。

pi-chat:聊天平台自动化

根 README.md 指向独立仓库 earendil-works/pi-chat:面向 Slack 等聊天平台的自动化与工作流集成。结合 p2 文档里的 Discord 集成(提到机器人即可唤醒沙箱会话)可以窥见方向——Pi 的扩展、技能与包体系正在被复用到「多入口、长驻、多人共享」的形态。学完 14 讲的你,已经有能力直接读这些上游项目的源码。

§14.9课程回顾:1–14 讲知识地图

最后一页纸,把十四讲串成一张地图。每个阶段解决一层问题,下一层站在上一层之上;点击任意讲次可回跳复习。

阶段一 · 准备与全景讲 01–03
01Agent 零基础:无状态 LLM API、消息角色、工具调用闭环、Agent Loop 的由来
02项目全景:monorepo 分层、11 个 package 职责与依赖方向
03安装与使用:/login、会话、print / JSON / RPC / SDK 多模式
阶段二 · 模型层(pi-ai)讲 04–05
04消息与流式:统一消息模型、流式事件、模型目录
05Provider 适配:认证、各家协议差异、工具调用协议归一
阶段三 · Agent 内核(agent-core)讲 06–07
06Agent Loop:请求-执行-回填-再请求、事件流、steering 与 abort
07工具系统:工具定义、JSON Schema 校验、执行策略
阶段四 · 编码代理(coding-agent)讲 08–12
08架构:AgentSession 与核心模块分工
09提示词与压缩:上下文组装、compaction、缓存友好性
10工具与会话:七大内置工具、JSONL 会话树与分支
11扩展系统:事件拦截、自定义工具、UI 集成
12Skills 与包:SKILL.md、提示模板、主题、包分发
阶段五 · 集成与实战讲 13–14
13TUI / Codemode / MCP / 自动化:终端渲染、脚本化工具编排、MCP 客户端、RPC 与 SDK
14安全与实战(本讲):信任模型、project trust、容器化三方案、pi-java-review、贡献流程

如果只带走一条主线:Pi 把「大模型请求 → 工具执行 → 结果回填 → 再请求」这个循环做成了可插拔的骨架——模型层可换(pi-ai)、工具可换(tool registry)、能力可注入(extensions / skills / packages)、进程边界可画(容器化)、可靠性可持续演进(durable)。理解了骨架,任何一层的新变化都只是插件的位置变了。

动手实践

通读两份安全文档并画出你自己的边界图 精读 docs/security.md 与 docs/containerization.md,对照 §14.4 的 SVG,在纸上画出你当前使用 Pi 的方式:Pi 进程在哪、凭据在哪、工作目录挂载关系、网络是否受限。 产出:一张你自己的信任边界图,标出至少一个过度暴露的点(例如宿主 ~/.pi/agent 被挂载、或环境变量里有长期 key)。
用 Plain Docker 跑一次隔离会话 构建 Dockerfile.pi 镜像,以命名卷 + 最小环境变量启动;进去后执行 !pwd 确认在 /workspace,再让模型改一个文件,回宿主确认写入直通。 有 Linux 环境的可再试 Gondolin(Node 23.6+ / QEMU),观察七个内置工具被路由进 VM、而 Pi 界面仍在宿主。
搭建 pi-java-review 骨架并逐个验证 按 §14.6 建目录;先 pi -e ./pi-java-review/extensions/java-guard.ts 单独验证拦截(让它写 pom.xml 应被 block);再装整包 pi install ./pi-java-review,验证 /pr-review 出现在补全里、/skill:java-review-standards 能强制加载。 ★ 别忘了调整 WRITABLE_PREFIXES 与 mvn 可执行名;每改一次资源用 /reload。
把 RPC 预审接入你的仓库 在一个真实(或玩具)Maven 仓库上运行 scripts/pr-precheck.mjs,对比两个分支的 diff 输出;确认工具事件打到 stderr、审查结论打到 stdout,便于 CI 采集。 进阶:让脚本在发现 blocker 时以非零码退出,变成 PR 门禁。
走一遍贡献流程(选做) 在仓库里找一个文档错字或表述不清处,按 CONTRIBUTING 质量标准开 issue(一屏内、自己的话、说清影响),拿到 lgtm 后再提交修复 PR;本地先跑 npm run check 与 ./test.sh。 体会它的「默认关闭」门禁:第一次贡献的目标是通过复审,而不是速度。

常见误区

误区一:「project trust 批准了,所以这个项目是安全的。」

project trust 只决定启动时加载哪些项目资源(扩展、技能、MCP 配置等),它不限制工具运行时能访问的文件与进程;而且 sessionDir 在信任判定前已被读取,AGENTS.md 等上下文文件根本不受它管辖。授信 ≠ 安全边界。

误区二:「Pi 跑在 Docker 里,就万无一失了。」

容器只隔离你没有暴露进去的东西。读写挂载直通宿主文件;-e 传入的每个环境变量对容器内进程可见;挂载 ~/.pi/agent 等于交出全部凭据与会话;开放网络意味着数据可以外传。隔离的强度由「你暴露了什么」决定。

误区三:「Gondolin 把工具关进了 VM,凭据安全了。」

Gondolin 只覆盖 read / write / edit / bash / grep / find / ls 七个内置工具与 ! 命令;VM 内命令继承宿主环境变量,环境变量形态的 key 依然可见;其他扩展工具若未显式委托,仍在宿主执行。文件型凭据确实留在宿主——前提是你没用环境变量。

误区四:「我会盯着 transcript / 审查 diff,出不了大事。」

文档原话:Watching the transcript、project trust、reviewing changes do not create a security boundary。它们是事后可观测性,回答「发生了什么」;恶意或出错的动作在被看到之前已经执行完毕。防线只能是事前的授权收窄与进程外隔离。

误区五:「给 pi-java-review 加了写拦截,生产库就安全了。」

tool_call 拦截运行在 Pi 进程内,与被拦截工具同一身份——它是防呆(手滑、误解需求),不是对抗恶意输出的安全边界。两者都要有,但别混为一谈。

自测问答

三种容器化方案(Gondolin / Plain Docker / OpenShell)各自信任边界画在哪里?各自的主要权衡是什么?点击展开

Plain Docker:Pi 连同内置工具、! 命令、扩展整进程进入容器,是最完整实用的本地边界;代价是凭据必须以环境变量等形式传入容器,宿主工作目录通过挂载直通。Gondolin:Pi 与文件型凭据留在宿主,仅七个内置工具(read/write/edit/bash/grep/find/ls)与 ! 命令路由进 micro-VM 执行,保留宿主界面;但 VM 内命令继承宿主环境变量,且其他扩展工具仍在宿主,边界较窄。OpenShell:整进程进入本地或远程策略沙箱,文件系统 / 进程 / 网络 / 凭据 / 推理路由全部按策略控制,粒度最细,原始模型凭据可留宿主;代价是配置最重,远程沙箱还需显式 upload/download 传文件。

为什么 Pi 不内置文件 / 进程 / 网络权限系统?这个「极简」背后的设计理由是什么?点击展开

因为进程内的权限检查挡不住进程外的执行:Pi 的工具会拉起任意子进程(bash、语言服务器、MCP 服务器),TypeScript 进程内的开关无法可信地覆盖这些逃逸路径。与其提供一个「看起来安全」的内置系统,Pi 选择明确文档化「以启动用户完整权限运行」,把真正的边界交给 OS 用户、容器、VM、策略沙箱;流程性的确认门(permission-gate、confirm-destructive 等)作为扩展示例存在,满足防呆需求而不冒充安全边界。这也与项目「核心极简、一切皆扩展」的总体哲学一致。

project trust 保护什么、不保护什么?说出至少一个例外。点击展开

保护:控制工作目录提供的设置与资源是否在启动时加载——.pi/settings.json、.pi/mcp.json、.pi/extensions|skills|prompts|themes、.pi/SYSTEM.md / APPEND_SYSTEM.md、项目 .agents/skills。不保护:启动之后的工具调用仍以 Pi 进程的 OS 权限运行,目录内容仍可影响模型。例外:①项目的 sessionDir 设置在信任判定前就会被读取;②AGENTS.md、CLAUDE.md、AGENTS.override.md 等上下文文件无论是否授信都会加载(除非关闭 context loading),必须当作不可信输入。

会话导出与 /bug 报告为什么是泄露面?提交前应该做什么?点击展开

会话记录包含提示词、工具参数、命令输出、文件内容,以及对话中暴露过的凭据;这些内容随 JSONL 落盘,也会进入 HTML 导出与截图。/bug [description] 可以附带会话 transcript 或让模型生成摘要,但 transcript 和摘要都可能含敏感对话数据——模型压缩不等于脱敏。提交前应逐段审查要分享的内容;已进入上下文的 secret 应视为泄露并轮换。

pi-durable 的 Persist and Resume 解决了 Agent 的什么可靠性问题?点击展开

普通 Agent 运行时的状态在内存里:进程在模型请求或工具执行中途崩溃,这轮工作就丢了,且难以安全重试。pi-durable 把会话、模型轮次、工具调用与用户状态在对外可见之前先提交到存储(类似 WAL 的「先落盘再响应」);重开存储后从停止处恢复,死掉时所处的那次模型请求计为一次失败尝试并被重试,因此反复杀死进程的请求最终会终止而不是无限重放。它面向长生命周期、可崩溃恢复的 Agent 服务形态。

想给 Pi 提交第一个 PR,正确的路径是什么?点击展开

先用 GitHub 模板开一个一屏以内、用自己的话写的 issue,说清问题与影响(想自己实现也要说明);等待维护者回复 lgtm(只有它授予 PR 提交权,lgtmi 只解锁 issue)。获得批准后:不改 CHANGELOG.md(条目由维护者添加),本地通过 npm run check 与 ./test.sh,再提交 PR;遵守 AGENTS.md 的提交信息格式与 git 纪律(显式 git add 路径、不用 -A)。