那次被读反了的 grep
一个四模型审计周期漏掉了一个让被审计 feature 完全无法工作的缺陷。四个审计者中有三个报告"没有严重或高等级发现"。找到它的那一个把它评为中等。这个缺陷用一次 grep 就能机械地检出 —— 有一个审计者跑了正是那次 grep,拿到了正确的输出,却把它读反了。这个 feature 有 3,376 个通过的测试,而且端到端并不工作,因为 Charter 添加了一个新的公开方法,却没有任何东西调用它。当我们问那个最细致的审计者到底发生了什么时,它的回答比那个发现本身更有价值:一个有名字的、可复现的失败模式,而且四个审计者中有三个共享它。这篇 post 讲的就是那个失败模式、它催生的那条单一规则修复,以及那个二阶发现 —— 实现者自己的验证方法在结构上根本不可能产生红色结果,一套只累加通过数、从不检查失败的验证,被连续信任了七次。
案情
这个项目是一个私有 adopter —— 我们会让标识符保持通用。一个 Charter 实现了一个 break-glass 提权 feature:一个拥有临时提升权限的操作员,应该能打开其基础角色通常够不到的记录。上一个 Charter 已经建好了提权解析机制,却把它搁置未用。这个 Charter 自己的背景章节把目的说得很直白:"提权类型已经写好并测试过了,而今天没人构造它。这个 Charter 把它投入使用。"
这个 Charter 给 resolver 接口添加了一个异步 resolver 方法 —— 代码库里唯一会查询提权状态的方法。测试写了。3,376 个测试通过了。审计跑了。
没有任何东西调用这个新方法。全部八个消费方仍在调用同步 resolver,它只按角色解析,并且对这个 feature 本要服务的操作员角色返回无。一个持有生效提权的操作员,一条记录都打不开。这个 feature 有 3,376 个通过的测试,而且不工作。
两次 grep 就讲完了整个故事:
$ grep -rn "resolver.Resolve\(" src/
Page1.razor:124 Page2.razor:66 Page3.razor:86
Page4.razor:120 Page5.razor:223 Page6.razor:347
Modal.razor:159 Controller.cs:311
$ grep -rn "ResolveAsync" src/
(无结果)
八个调用点,全都走旧路径。走新路径的是零个。这个异步方法存在、正确、有测试 —— 而且不可达。
四个审计者做了什么
| 审计者 | 发现数 | 找到了吗? |
|---|---|---|
| gpt-5-6-terra-high | 2 | 是 —— 评为中等(标定偏低) |
| kimi-k3 | 15 | 否 |
| glm-5.2 | 8 | 否 —— 明确写了"没有严重或高等级发现" |
| qwen3-8-max-preview | 3 | 否 |
四个审计者中有三个检出了症状,却没有抵达病因:
- 一个 DI 一致性 guard 测试确定性地失败 —— 也就是说 E2E fixture 根本起不来这个 feature。两个审计者发现了它。被报告为配置缺陷。
- 一个覆盖缺口:过期测试在服务层验证,而不是在 Charter 声明的 HTTP/cookie 层。一个审计者发现了。
- 这个缺陷的确切形状 —— "调用方决定授权值,而下面那层照单全收" —— 被精确地识别出来了,只是在另一个文件里。一个审计者发现了。
每个症状都是真的。每个症状都是一条通向同一个根因的线头。没有人去拉。
比发现更重要的那次复盘
我们问了 kimi-k3 —— 报告最细致、证据引用最多的那个审计者 —— 到底发生了什么。它的回答是整个周期里最有用的产物,因为这个失败可复现,而且有名字。
1. 它跑了正确的搜索,却把结果读反了。
"我跑了一次 grep,它的输出只显示注入行 —— 异步调用的缺席就摆在那里可见,而我把它读成了'注入已确认',而不是'确认没有调用'。我 grep 的是接线的存在,却把这个结果当作确证来读,而它其实是证伪。"
除了对一个否定结果的阅读之外,什么都没有失败。这次 grep 返回的是注入点的行(DI 容器的接线),而审计者把它们读成了这个方法被使用的证据,而它们只证明了这个方法可用。接线的存在 ≠ 调用的存在。
2. 它验证了机制,而不是采纳。
"我验证了机制(存在、正确),而不是采纳(被使用)。"
它读过方法体 —— 它还就那个新异步方法内部缺少缓存提交了一个发现。而紧挨着的那个问题 —— 谁调用它? —— 从来没被问出口。这就是这个失败模式最纯粹的形态:审计者就在正确的方法里面,验证了它的正确性,却从没抬头看看有没有东西能抵达它。
3. 它把前一个 Charter 的背景当作叙述,而不是可证伪的主张。
"这个 prompt 有 10,598 行,我把原始 AILOG 的材料当作背景叙述吸收进来了,而没有把每一条转换成一个可证伪的假设('这个 Charter 必须让 X 成真 —— 在哪里?')。这就是我能精确命名的那个流程失败:我把前一个 Charter 的背景当成背景,而不是一份待被变成真的主张清单。"
这个 prompt 确实包含了所需的信息。Charter 自己说明它的目的是"把它投入使用"。验证"投入使用"就意味着验证调用点变了。这个主张就在那儿;它被当作背景来读,而不是一个可核查的断言。
4. 它的结构性诊断。
"每一层都被测试了,而层与层之间的缝没有 —— resolver 能工作(16 个测试),页面能工作(bUnit 且权限被注入),服务会拒绝(14 个测试)。四个审计者中有三个验证的是层;而缺陷活在那些不在 diff 里的文件内部的重载选择上。"
这是能推广的那部分:当一个 Charter 声称要把既有代码投入使用时,只审计 diff 是不够的。那八个调用错误的文件,是在上一个 Charter 里被修改的。
趋同衡量的是可见,而不是严重
这是一个值得命名的模式连续第二次出现。在此前的一个审计周期里,三个审计者中有两个通过引用一个测试来确认一项保证,却没打开它的函数体 —— 而那个测试是空的。这一次,三个审计者趋同到了那个红色的 guard 测试上(可见、机械、容易报告),而这三个里有零个找到了那个让 feature 变得无用的缺陷。
审计者之间的趋同,正是 StrayMark 审计周期被设计出来要产生的信号。但趋同衡量的是多个头脑能看见什么,而它们能看见的东西,系统性地偏向可核查的那一类:失败的测试、配置错误、类型不匹配。最要紧的那个缺陷 —— 一项没有任何东西能抵达的能力 —— 对每一个不明确提出那个问题的审计者都是不可见的。提出它不需要判断力;它需要一条规则。
修复:一条规则,不需要判断
我们给这个审计者提供了四个候选补救。它毫不犹豫选了这一个:
"(a),毫无疑问,而且这不是马后炮:异步 resolver 是一个新的公开方法;'列举它的调用者'是一次我手里本来就有的 grep,它会返回'生产代码中 0 个' —— 机械的高等级发现,不需要判断。"
这条规则,现在作为强制步骤成了审计 prompt 的一部分:
列举新公开入口点的调用者。 对于 Charter 所添加的每一个公开方法、端点或组件,在生产代码上(排除测试)跑一次调用点搜索,并显式陈述计数。生产代码中零个调用者默认是一个高等级发现 —— Charter 添加了一项没有任何东西能抵达的能力。当计数不为零时,检查这些调用者是不是预期的那些 —— 一个既有重载或一条遗留路径可能仍在胜出。
这条规则之所以有吸引力,恰恰因为它不需要判断。它把一个不可见的缺席转换成一个显式的计数。"零个调用者"是一个会自己写出来的发现。
同一次复盘还交付了两条次要规则:
-
合并测试的缝检查。 当一个测试被记录为"合并"进另一个时,验证那个替代物演练的是同一条缝,而不仅仅是同一个单元。在这个周期里,审计者恰恰调查了这一点,却因为 Charter 的收尾说明宣称覆盖等价而放弃了。它并不等价。Charter 自己的收尾说明关掉了一个审计者的一条追查线 —— 而那些说明是被审计方写的。这是一个审计必须去校正的结构性利益冲突。
-
红色 gate 的列举。 当一个验证 gate 是红的,列举出只有那个 gate 才可能抓到什么。那个坏掉的 DI 一致性测试被报告成了配置缺陷;没人问它在保护什么。
二阶发现:不可能失败的验证
实现 agent 在 AILOG、PR 正文和七条提交信息里都声明了"3,376 个测试通过,0 个失败"。而一个 guard 测试从第 4 批起就在确定性地失败。那条验证命令是:
dotnet test ... | grep -E "Passed!|Failed!" | awk -F'Passed: *' '{s+=$2} END {print s}'
它把全部五个测试项目的通过数加了起来,却没有检查是否有任何一个报告了失败。一个处于红色的项目被求和进去,从未浮出水面。
可以说这是本案更有用的那一半。一个 agent 造了一套不可能产生红色结果的验证方法,然后连续七次信任了它。自我验证永远不可能抓到这个。审计抓到了 —— 最终地、经由另一个审计者、在另一个发现上、通过阅读那份被实现者自己的流水线默默吞掉的 guard 测试输出。
这里的修复不在审计 prompt 里,而在 AILOG 模板里 —— 那是实现者书写的文档。"测试通过"这个勾选框现在要求声明所运行的确切命令,并附有明确指引:一个不可能产生否定结果的验证,不是验证。 只累加通过数而不查看失败输出,就是那个典范级的反模式。
交付了什么
作为 fw-4.38.0 / cli-3.40.0 发布,在 #382 中追踪:
审计 prompt v1.2(EN + ES)。三项新增:
- 新的强制步骤 3 —— 列举新公开入口点的调用者。生产代码中零个调用者 = 高等级,不需要判断。不为零时:验证这些调用者是不是预期的那些。
- 强化的步骤 2.6 —— 合并测试的缝检查:Charter 自己的收尾说明是被审计方的一项主张,不是证据。
- 强化的步骤 4 —— 红色 gate 的列举:那个坏掉的 gate 本来在保护什么?
AILOG + Charter 模板(EN/ES/zh-CN)。"测试通过"这个勾选框现在要求声明所运行的确切命令。一个不可能产生否定结果的验证,不是验证。
可移植的版本
对你的评审流程 —— 无论是人还是自动化 —— 问这个问题:对每一项新的公开能力,有没有人检查有东西在调用它? 不是它存在,不是它正确,也不是它有测试。而是有东西调用它。答案通常是没有,因为这个问题简单到让人觉得不值得问。它确实简单。这正是重点。最简单的检查就是没人写下来的那些,而没人写下来的那些,就是每一个评审者 —— 人也好机器也好 —— 在每一个周期里永远会跳过的那些,直到某个 Charter 添加了一个没有任何东西能抵达的 feature、三个审计者报告绿灯,而唯一能抓住它的只有运气。
把这个检查写下来。让它机械化。"生产代码中零个调用者"是一个数字,不是一个判断。如果你的审计 prompt、你的 code review 清单或者你的 CI gate 没有为每一个新的公开入口点产出那个数字,那么你跑的就是同一套流程 —— 那套在一个不工作的 feature 上产出了 3,376 个通过测试的流程。
案例记录于 #382 —— 四份审计者报告、一份合并评审、审计者复盘,以及该私有 adopter 的 .straymark/audits/ 目录中的全部源产物。交付于 fw-4.38.0 / cli-3.40.0。相关:一百万条断言无法裁决的事(跨家族多样性作为盲点保险)、审计以为自己是谁(审计归因)。
本文档在生成式 AI 工具的协助下产生;内容的全部责任由人类作者承担。