Part V · Engineering & Governance

16 供应链硬化 把依赖变更当代码评审:一条从 .npmrc 到 OIDC 的完整链

已发布 生命周期脚本白名单 2 条 同日发布延迟 2 天
§1 Mental Model 一句话心智模型

先记住这一句

pi 的供应链策略是一句话推出来的:「我们把 npm 依赖变更当作需要评审的代码变更。」——一旦接受这个前提,「锁精确版本」「拒绝同日发布」「生命周期脚本要白名单」全都是必然推论,而不是各自独立的安全措施。

这一章值得读的地方不是清单本身,而是清单的完整性。多数项目做前两条就停了;pi 把链条一直拉到「发布时不用本地 npm token」。有意思的是,一个明确拒绝做运行时权限系统的项目(第 15 章),在供应链上却是全书最谨慎的。

这两件事并不矛盾,反而互相解释:pi 认为真正的边界要建在能真正守住的地方。进程内沙箱守不住,所以不做;依赖引入的那一刻能守住,所以守得很紧。

§2 Evidence 代码实证

整条链

环节机制证据
写入依赖直接外部依赖锁精确版本;工作区内部包保留范围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-scriptsREADME.md:86
CInpm ci --ignore-scripts + 定时 npm audit 与签名校验README.md:87
发版npm trusted publishing,走 GitHub Actions OIDC,无本地 token/OTPAGENTS.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 临时关闭。

② 生命周期脚本白名单只有两条,而且每条写了理由。

scripts/generate-coding-agent-shrinkwrap.mjs原文 L13–L16
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"],
]);
白名单的 key 带精确版本号——依赖升级后必须重新审一次。value 不是注释而是 Map 的值,结构上强制你写理由。generate-coding-agent-shrinkwrap.mjs:13

新增带生命周期脚本的依赖会让检查失败,直到有人显式加进这个白名单——AGENTS.md 里配了一条给 agent 的硬规矩:never add one silently。AGENTS.md:45

精确版本是程序化校验的,不是靠自觉

scripts/check-pinned-deps.mjs原文 L5
const exactVersionPattern = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
一个不接受任何 ^ / ~ / * 的 semver 正则,任何直接外部依赖只要不是三段精确版本就报错退出。check-pinned-deps.mjs:60

特定依赖的特殊规矩

AGENTS.md 里点名了一个包:更新 undici 时必须读目标版本的 changelog / release notes,评估是否影响功能后才能应用。AGENTS.md:42

为什么是 undici:它是所有 provider 请求的 HTTP 层,它的行为变化(连接池、超时、流式语义)会以极难诊断的方式影响 agent。把「这一个依赖需要人读 changelog」写进给 agent 的规则文件,比泛泛地说「谨慎升级依赖」有用得多。

§3 Design 设计动机与权衡

为什么这条链值得整条抄

供应链攻击的经济学很特别:攻击者的成本几乎恒定(攻破一个流行包的维护者账号),而收益随下游用户数线性增长。对一个装在开发者机器上、持有 provider API key、能执行任意命令的 CLI 来说,它的下游用户是「有 API key 且有 shell 权限的开发者」——这是攻击者能想到的最好的一批受害者。

所以 pi 在这里的谨慎程度与它在运行时权限上的放任,并不是自相矛盾,而是同一个判断的两面:把有限的防御预算花在真能守住的关口上。依赖引入是一个离散的、可评审的、频率很低的事件;运行时的每一次工具调用不是。

OIDC trusted publishing 消掉的是什么

传统发布流程里,某台机器上存着一个能发布 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——成本从「记得要做」变成「不做就红」。这是本章最实用的一条:纪律不可持续,门禁可持续。

§4 Limits 边界与失效条件

检查过并确认(锚点版本):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

§5 Takeaway 拿走什么
§ Self-check 自测

场景你给 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

基于 earendil-works/pi @ ac4ac9e(2026-08-07 快照)。
仓库 commit 速度约 428/月,行号引用仅对该 SHA 有效。