作者修复了一个误报 bug:linter 对 YAML 列表形式的依赖声明无法识别,导致正确声明依赖的作者收到警告,而未声明的作者反而无事。
警告落在了唯一做对了的人身上
我维护着一个 linter,它读取 agent 配置文件——SKILL.md、AGENTS.md、CLAUDE.md——当这些文件中埋入了只在作者机器上能跑的东西时,它会让 CI 失败。其中一条规则是:如果你调用了外部 CLI,就必须声明它,否则下一个人会没有它。
声明的方式是在 frontmatter 中命名它:
requires: codex
只是,任何有超过一个依赖的人都会写成列表形式,因为这就是 YAML 的用法:
requires:
- codex
- gemini
我的实现只认第一种形态。所以块列表——正常写法,只要有两样东西就会这样写——对 linter 来说是透明的,它会警告你有一个未声明的 CLI,但实际上你已经声明过了。
慢慢重读上面那句话。完全忽略依赖问题的人永远不会被标记,因为他们根本没写 requires: 这个 key。而认真坐下来写契约的人反而会收到警告,说他们没有声明。这个规则和它想要鼓励的行为完全反过来了。
我把它发布了。那是一个 patch release,我之所以发现它,是因为一位评论者用了"依赖契约"这个词,我去重新读了一遍自己的实现。
然后同样的事又发生了。一 release 里出现了两次
我一篇帖子下面的两条评论催生了新的规则。其中一条叫 unverified-write,它报告一个修改了外部状态——git push、npm publish、一条 INSERT——但在文件中任何地方都没有回读该状态的文件。
发布前,我用它跑了 586 个从公开注册表拉来的真实 skill 文件,在数据中发现了两种 false-positive 形态,修复了这两处,然后重新测量。触发率 0.7%,每一条我手工核查过的都是真的。我感觉良好。
然后我把 diff 交给另一个模型做发布前 review,它在大约一分钟内产生了这样一条输入:
Never run `git push --force` from this skill.
那是一句 git push,放在 code span 里,所在文件在任何地方都没有回读。我的规则把它标记成了 unverified write。
AGENTS.md 和 CLAUDE.md 里满是这种句子。写下"没有询问就不要 push"是这个类型文件中最重要的关爱行为。我构建了一条规则,专门因为你写下了"禁止 push"而警告你。
我修了它,发布了,然后注意到同一形态又往外走了一层。禁止条款现在被排除了,但许可条款没有:
- `git push` は明示の指示があるときだけ。
- Only run `git push` when the user asks.
- `npm publish` requires approval from a maintainer.
写这些句子的人没有一个有 unverified write。他们写的是策略。三次发布,三种变形,都指向同一个方向:警告找到了写下规则的人,漏掉了从来没提过它的人。
为什么它偏偏指向那个方向
文本匹配规则看不见行为,只看得见提及。而对危险操作的提及在作者之间并非随机分布——它们集中在认真思考过这件事的人的文件里。
粗心的作者的 AGENTS.md 里不会写 git push 任何地方。没有什么可以被规则捕获。而认真的作者的文件里会写三遍:一遍声明何时允许,一遍禁止 force 变体,一遍在实际的 deploy step 里。这三句中有两句不是你在检测的东西,而这两句恰恰是认真的证据。
所以先验概率就不利于你。在所有包含你匹配字符串的文件中,由尽职作者所写的比例远高于总体——而你收到的每一条 false positive 都来自那个池子里。被警告的人最可能仔细读你的警告,也最可能因为它而卸载你——而他们恰恰是你最可能判断错误的人。
静态分析对底层区分有一个名称——使用与提及——我的旧规则已经知道这一点。它的 CLI 检查忽略散文中裸露的 codex,只在 codex exec build(一个带有参数的真正调用)上触发。这个排除是我在更早两个 release 里写的,作为对同一类抱怨的回应——然后我在构建新规则时把它忘了。
什么都没找到的审计
我想要诚实说的部分:用 586 个真实文件跑了一遍,没有捕获到上述任何问题。
它不可能捕获到。已发布的、可下载的 skill 是被设计成要让人用的;它们说"运行这个"远多于说"永远不要运行这个"。禁止形态生活在团队内部的 AGENTS.md 文件里,没人往注册表上传。我的语料是真实数据,但它是错误的真实数据——存在偏差,恰恰在隐藏失败的方向上。
这一点值得拆开来讲,因为"用真实数据测试"是我写文章给过的建议,我至今仍然相信:
真实数据审计告诉你的是你的规则对你能触及的语料做了什么。
对抗性阅读告诉你的是你的规则对有人故意构造的输入做了什么。
后者在一轮中就发现了 586 个文件没有发现的东西。它花了一个 prompt。如果你的检测器会跑在你看不到的文件上——而 linter 总是会的——你需要两者,而且你应该假定语料是两者中更舒服的那个。
真正修好它的是什么
不是更多的关键词。修复是把使用/提及的区分做成结构性的,然后检查代价:
写信号只在代码上下文中计数——在围栏内,或者在反引号 span 内。散文里说"之后我们 push 到 git"不是一步。
带有禁止语的行(never、do not、禁止)不是一步。
带有条件或许可的行(only … when、requires approval、〜のときだけ)在围栏外不是一步。在围栏内,每一行都是命令,把它们排除在外会漏掉像 git push origin main # main only 这样附带注释里的真实发现。
然后是不可省略的部分:重新跑语料,证明排除没有吃掉信号。修复前四个真阳性,修复后仍然是四个,触发率保持在 0.7%。你没有测量过的排除只是一条多绕了步骤的删除规则。
还有一件事,既然你的 linter 跑在别人的文件上
同一次 review 发现了另一件不相关但更严重的事。我对远程副本的检查是这样的:
\b(?:scp|rsync)\b[^\n]*\s\S+@\S+:
贪心填充,然后在某行中搜索 something@something:。在一行包含很多 @ 但没有冒号的长行上,它会二次方级回溯:20k 字符 77ms,40k 字符 312ms,而且还在继续往上走。一个人仓库里的一行 minified blob 或 base64 payload 就足够了。
我一直把输入当作"人们写的配置文件"。但它不是。它是来自陌生人的任意文本,而一个会卡死的 linter 就是会卡死陌生人的 CI。修复方案是阻止填充符跨过 @,这样就没有东西可以回溯了;8 万字符的行现在是一个测试用例。
我保留的这条经验法则
当一个检测器基于文本匹配时,问问是谁最常说那句话。如果答案是"对此很认真的人",你的 false positive 不是均匀分布的——它们是有方向的。唯一可靠的发现方式是把规则交给一个故意让你难堪的东西,因为你自己的语料是由你已知道的情况组成的。
工具:上面那个 linter 叫 carrylint;它刻意不尝试的那一半回读检测是 genchi。