这一章值得读的地方不是清单本身,而是清单的完整性。多数项目做前两条就停了;pi 把链条一直拉到「发布时不用本地 npm token」。有意思的是,一个明确拒绝做运行时权限系统的项目(第 15 章),在供应链上却是全书最谨慎的。
这两件事并不矛盾,反而互相解释:pi 认为真正的边界要建在能真正守住的地方。进程内沙箱守不住,所以不做;依赖引入的那一刻能守住,所以守得很紧。
| 环节 | 机制 | 证据 |
|---|---|---|
| 写入依赖 | 直接外部依赖锁精确版本;工作区内部包保留范围 | README.md:80 |
| 解析时 | save-exact=true + min-release-age=2 | .npmrc:2 |
| 提交时 | pre-commit 拦截 lockfile 提交,除非显式设环境变量 | check-lockfile-commit.mjs:5 |
| 检查时 | npm run check 串了 5 道自定义门禁 | package.json:18 |
| 发布物 | CLI 包内附 npm-shrinkwrap.json,钉死传递依赖 | README.md:84 |
| 安装时 | 本地发布安装、文档里的 npm 安装、pi update --self 一律 --ignore-scripts | README.md:86 |
| CI | npm ci --ignore-scripts + 定时 npm audit 与签名校验 | README.md:87 |
| 发版 | npm trusted publishing,走 GitHub Actions OIDC,无本地 token/OTP | AGENTS.md:159 |
锚点之后表里「发布物」这一环变了。581e7ba(2026-10-02)从发布包里移除了 npm-shrinkwrap.json,随 coding-agent 1.0.1 发布:CHANGELOG.md:220。直接用 npm 安装不再钉住传递依赖,钉版改由 pi.dev 安装器负责:README.md:176;npm run check 里也不再有 check:shrinkwrap:package.json:21。其余几环还在:.npmrc 未变(.npmrc:2),OIDC 发版的说明从 AGENTS.md 移到了 release skill(release.md:48)。这次改动给 §4 补了一条失效条件:传递依赖由发布包钉还是由安装器钉,决定了库使用者能不能覆盖它们——上游选了安装器,代价是 npm 安装路径不再钉版。本章其余内容按锚点版本成立。
① min-release-age=2。npm 解析依赖时跳过发布不足 2 天的版本。.npmrc:2 这一行专门针对被攻破的维护者账号发布恶意版本这一类事件——这类投毒的典型生命周期是「发布 → 数小时内被发现 → 撤包」,两天的延迟就把绝大部分窗口错开了。代价是拿不到当天的安全补丁,所以发版时要显式 npm_config_min_release_age=0 临时关闭。
② 生命周期脚本白名单只有两条,而且每条写了理由。
const allowedInstallScriptPackages = new Map([
["@google/genai@1.52.0", "preinstall is a no-op in the published package"],
["protobufjs@7.6.5", "postinstall only warns about protobufjs version scheme mismatches"],
]);
新增带生命周期脚本的依赖会让检查失败,直到有人显式加进这个白名单——AGENTS.md 里配了一条给 agent 的硬规矩:never add one silently。AGENTS.md:45
const exactVersionPattern = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
AGENTS.md 里点名了一个包:更新 undici 时必须读目标版本的 changelog / release notes,评估是否影响功能后才能应用。AGENTS.md:42
为什么是 undici:它是所有 provider 请求的 HTTP 层,它的行为变化(连接池、超时、流式语义)会以极难诊断的方式影响 agent。把「这一个依赖需要人读 changelog」写进给 agent 的规则文件,比泛泛地说「谨慎升级依赖」有用得多。
供应链攻击的经济学很特别:攻击者的成本几乎恒定(攻破一个流行包的维护者账号),而收益随下游用户数线性增长。对一个装在开发者机器上、持有 provider API key、能执行任意命令的 CLI 来说,它的下游用户是「有 API key 且有 shell 权限的开发者」——这是攻击者能想到的最好的一批受害者。
所以 pi 在这里的谨慎程度与它在运行时权限上的放任,并不是自相矛盾,而是同一个判断的两面:把有限的防御预算花在真能守住的关口上。依赖引入是一个离散的、可评审的、频率很低的事件;运行时的每一次工具调用不是。
传统发布流程里,某台机器上存着一个能发布 npm 包的长期 token。那个 token 是整条链上最值钱的东西——拿到它就能发布带后门的新版本,绕过前面所有的锁版本和白名单。
OIDC trusted publishing 把它换成 GitHub Actions 在特定环境下签发的短期身份凭证:没有长期 token,没有 OTP,没有本地 npm publish。AGENTS.md:159 攻击面从「一个可能泄漏的密钥」变成「一个需要攻破的 CI 配置」。
发布流程还有一道后置校验:发布后有专门的 job 逐个确认每个公开工作区包都能按精确发布版本解析、且其 npm tarball 可获取,然后才写「已验证发布」标记;pi.dev/api/latest-version 读的是那个标记。「绝不能在这个 job 成功之前从 npm 宣告新版本」——这句写在 AGENTS.md 里。AGENTS.md:159
精确锁版本 + 2 天延迟 + shrinkwrap(锚点版本),合起来意味着安全补丁的到达速度变慢,而且每次依赖升级都是一次需要人参与的显式操作。对一个由 3 位主要维护者 + 大量偶发贡献者组成的项目,这是可观的持续成本。
他们的对冲是把这些检查全部程序化并塞进 npm run check——成本从「记得要做」变成「不做就红」。这是本章最实用的一条:纪律不可持续,门禁可持续。
min-release-age=2 挡不住有耐心的攻击者。它针对的是「发布后数小时内被发现」的投毒模式。一个潜伏两周才激活的后门版本,这条规则完全无效。包@版本 锁的,升级即失效——这是特性,但也是摩擦。两条白名单里那两个包每次升版都要重新走一次审。项目一大,这个列表会变成「谁也不敢动」的地方。PI_ALLOW_LOCKFILE_CHANGE=1 存在的理由是正当的(发版要改 lockfile),但它同时意味着这道门禁是防手滑,不防有意。检查过并确认(锚点版本):npm run check 确实串联了 check:pinned-deps、check:ts-imports、check:shrinkwrap、check:install-lock:coding-agent、check:browser-smoke 五道自定义门禁外加 biome 与 tsgo,且 --error-on-warnings 让警告也算失败。package.json:18
.npmrc 配置,成本几乎为零,挡掉的是最常见的一类投毒窗口。
适用条件:npm 生态。发版当天要临时关闭——否则你自己的新版本也会被自己的规则挡住。包@精确版本 → 理由 的 Map,而不是数组。数据结构强制写理由,升级自动作废旧审查结论。
适用条件:任何允许安装脚本的包管理器。这是全章最容易直接抄的一条。场景你给 pi 加一个新依赖,它有 postinstall 脚本。npm run check 会怎样?
C。shrinkwrap 生成脚本会对 hasInstallScript 的条目报错,除非命中 allowedInstallScriptPackages。而 AGENTS.md 明令「never add one silently」。AGENTS.md:45
场景发版时为什么要临时设 npm_config_min_release_age=0?
A。AGENTS.md 的原话:仓库常规的 npm age gate 会在当前工作区包版本刚发布时挡住发布用的 lockfile 刷新。所以这个开关只用于发布命令。AGENTS.md:155
ac4ac9e(2026-08-07 快照)。