钉版本与校验

E23
概念 · AI 模型与工具生态

钉版本与校验。

一个你从不核对的「钉」不是锁,只是一种偏好——而撑着你整套智能体技术栈的那些钉,大多恰恰如此。钉版本有两半:说出你想要的那件东西,以及证明你收到的字节就是那件东西。几乎所有智能体工具链都只实现了前一半,把它称作安全,然后把后一半交给恰好在解析这个名字的注册中心、git 托管方或模型提供方。真正值得内化的区分,是「描述内容的标识符」与「只是指向内容的标识符」之间的区别,因为只有前者能被在乎它的那一方自行校验。

STEP 1

标识符有两种,只有一种能自己核对自己。

你的智能体在构建期或运行期拉取的每样东西,都是靠某个标识符取来的,而每个标识符都落在两类之一。

  • 名字要经过别人拥有的命名空间来解析。v2.1.0、latest、一个 git 标签、一个分支、一个 OCI 镜像 tag、一个模型别名、一个 MCP 服务器的注册表条目。名字是查询键;它返回什么,取决于命名空间的主人今天说它返回什么。你收到的字节里,没有任何东西能让你核对这个答案。
  • 摘要就是内容本身,只是压缩过。sha256: 镜像摘要、git commit 对象 ID、npm 的 integrity 哈希、go.sum 里的一行。你可以对拿到的东西做哈希,然后比对。这个检查不需要网络请求、不需要可信第三方,也不需要送文件给你那一方的配合。

有用的性质不是不可变性——不少名字是靠策略保证不可变的,而策略是一种承诺。有用的性质是本地可核对性:消费方能不能独自察觉一次替换?摘要说能。名字说的是「再去问一次注册中心,并且盼着它两次给出同一个答案」。

拿这一个问题把你的依赖过一遍,结果通常不太好看。模型版本、MCP 服务器、插件与技能包、远端提示词模板,几乎总是按名字取的。容器镜像和各语言的包锁文件通常按摘要取——而这恰恰说明,你供应链里成熟的那一半,是早于智能体存在的那一半。

STEP 2

钉是一个请求。让它具有约束力的,是校验那一步。

把协议写出来,缺口就明显了。钉版本是两个操作:请求制品 X,然后确认落地的就是 X。跳过第二步,你其实什么也没钉住;你只是向一台可以自由忽略你的服务器表达了一个偏好,并且顺手取消了原本会由版本号肉眼可见地变化所带来的那点警觉。

最干净的现实例子是 2026 年 9 月公布的 Plugin4Shell,它一次性在四个编程智能体里发现了同一处遗漏。每一个都会把插件检出到一个被钉住的 40 字符 commit SHA,然后再也没问过 git 自己究竟落在了哪个 commit 上。由于那串字符同时也是一个合法的分支名,只要仓库所有者创建一个名字完全相同的分支,就能在清单里那个钉逐字节不变的同时,送出完全不同的代码。修法只有一条命令——检出之后解析 HEAD 再比对——而它的缺席,让一个已审核、已批准的插件在用户什么都没做的情况下变成了攻击者控制的插件。

# the pin without the check: whatever the host resolved, you keep
git clone --depth 1 "$REPO" plugin
git -C plugin checkout "$PINNED_SHA"

# the check that makes it a pin (one line, no network)
test "$(git -C plugin rev-parse HEAD)" = "$PINNED_SHA" || exit 1

把视野推到 git 之外,同一句话照样成立:一条只记了版本号却没有哈希的锁文件条目、一个写在配置里的模型别名、一份每次连接都重新取一遍的工具 schema。每一样都是一个到货时没人查验的请求。

STEP 3

校验缺席时,安全性质已经悄悄搬到托管方那边去了。

下面是团队会漏掉的部分,也正是这件事之所以是一个概念而不是一份 bug 报告的原因。当客户端不做校验时,系统并不一定就可被利用——它是除非命名空间禁止那种歧义,否则可被利用。这个条件在某些托管方成立,在另一些不成立,而它不出现在你任何一处配置里。

在 git 这个案例里,GitHub 会拒绝任何分支或标签名为 40 个十六进制字符的推送,GitLab 也在 pre-receive 钩子里拦掉同样的形状。所以对托管在这两个平台上的插件,拦住攻击的是平台而不是智能体——这恰好就是某家厂商用来解释自己不打补丁的理由。这个理由是对的,而它同时就是问题本身:一旦插件被镜像、被内嵌进仓库、被自托管到一个更小的代码托管服务上,或者从一台跑着旧策略的企业实例上取得,这条性质就消失了,而清单里什么都没变。

  • 保障不在你能读到的那件制品里。无论托管方是否执行那条规则,你的锁文件看起来都一模一样,所以任何评审、diff 或审计都看不出差别。
  • 它很不耐迁移。镜像、代理、离网重新托管、并购,全都会把制品搬到规则不同的命名空间里,而迁移检查清单里从不会提到分支命名策略。
  • 它是某一方的产品决策。这条规则之所以存在,是因为一个平台这么选了;它被写成一项易用性防护而非安全控制,而且可以在没有任何 CVE 的情况下改变。

把这个模式读懂一次,你会在智能体技术栈里到处看见它:一条在生产中真实存在、却不出现在制品里、并且归一家从未做出此承诺的公司所有的供应链性质。

STEP 4

智能体里该钉住什么,按回报排序。

智能体把依赖图往一个经典打包体系从未覆盖的方向拉长了:那些按次取用、改变行为而不是改变代码、并且根本没有锁文件的东西。

  • 模型版本。一个像「裸家族名」那样的别名,是最强意义上的名字——提供方重新指向它,你的评测就在你脚下挪了位。请钉住带日期的快照,并把切换当作一次带回归测试的发布来对待,这正是模型弃用与迁移要做的事。
  • 工具 schema。一个 MCP 服务器在连接时发来它的工具清单,明天可能发来另一份;而描述文本是你提示词的一部分,所以一次静默的改动就是一次你没有评审过的提示词改动。给送来的 schema 做哈希、把哈希存下来、在漂移时告警——工具投毒所描述的那种失效,正需要这个才能被看见。
  • 技能、插件与提示词包。按任何诚实的定义,这些都是代码,包括那些 Markdown 写的,因为它们进入上下文并操纵工具调用。它们需要一个摘要和一次评审,与依赖同等待遇——见智能体技能。
  • 智能体执行所在的环境。基础镜像、解释器版本、装上去的各种 CLI。这一半已经有不错的工具了;用摘要形式而不是 tag。
  • 采样方面什么也钉不住。钉版本买不到确定性,指望它能买到是个常见的失望——那是可复现性与确定性,是另一个问题,有另一套答案。

先做那次便宜的清点:把你的智能体按名字(而非摘要)取用的所有东西列出来,然后逐条写下命名空间归谁所有,以及是什么在阻止他们——或攻破他们的人——明天返回不同的字节。接着,凡是摘要本来就存在、只是没人去比对的地方,都补上校验那一步,因为那是几行代码,却能把一个承诺变成一次检查。至于那些根本没有摘要的条目,诚实的做法不是找一个更好的钉,而是缩小授权:假定那件制品会变,并确保它够得着的范围小到它变了也无关大局。