跳到主要内容
README 里写着 fail-closed,CI 里躺着四行 fail-open

README 里写着 fail-closed,CI 里躺着四行 fail-open

小周
小周

· 阅读约 6 分钟

不是刷 trending 刷到的。这期是追一篇自曝文追进去的。9 月 1 日 dev.to 上,Mahiro Hirakawa 写了篇东西,讲他在自己维护的项目里翻出一个 CI 配置缺陷。

项目叫 TraceFold/tracefold,Rust 写的。一句话定调——给 AI agent 的工具调用做可逆性校验的闸门。

它 README 里立着一条原则:fail-closed。校验机制没法确认一件事,就该拦住,不该放行。这句话限制说明页里有,receipts 的设计里也有。不是随口一提的口号,是它整个立场。

然后 8 月 31 号,他梳理自家 issue 列表的时候,第一次真正读了仓库根目录那个 GitHub Action 的实现。这个细节我卡了一下:自己维护的项目,Action 就在根目录躺着,读它跟翻自己抽屉一样,偏偏到今天才读。换我大概也一样,根目录那堆 yml 日常就是路过。

那四行做的事是让 CI 校验执行回执。两个分支:gx 二进制在 PATH 里就调校验器;不在,就打印一行"stub 检查通过",退出码 0。也就是说,没装校验器的环境里,这步是绿的。

一个把 fail-closed 当卖点的项目,自己的 CI 里埋了个 fail-open 的桩。而且是最没味道的那种绿。

更妙的是第二个分支连调用名都写错了。命令写的是 gx verify-receipts,CLI 实际提供的是 gx receipt verify,而仓库自带的示例 workflow 里用的反而是对的。两个分支叠一块,真实行为是:装了 gx 就红,没装 gx 就绿。唯一走得到的那条绿色,一点信息量都没有。

我看到这儿第一反应不是"这作者真菜",是"这事谁都能干"。四行,一点也不隐蔽。他自己给的解释我认:这类文件被当成打包配置在读,不是代码。打包配置不需要对抗性阅读,谁会拿敌人视角去看自家的 action.yml。

CI 恰恰是 fail-open 最好藏的地方。绿勾分不出"真通过"和"默认通过",下游也没有任何机制能区分这两件事。你的 branch protection 保护的是一个对勾,不是一次校验。

他先公开披露,再落地的修复。修的部分很克制:二进制缺失那条改成退出码 1,报错写清楚没有校验器就不可能通过;原来那行"stub 检查通过"撤了,但以注释形式留在文件原地,让缺陷历史留在案发现场。二进制存在那条一个字没动,只加注释标出已知限制——子命令名是错的,装了 gx 这一步今天也会失败。他说那个分支原本是"偶然地 fail-closed",注释的作用是让它变成"有意为之的 fail-closed"。这句我服。修真问题之前先把现状标清楚,比顺手糊一个看起来对的版本强。

值不值得点进去看?当工具用,现在不行。把 Action 做成真正可用版本的两条验收标准——装二进制、把校验器的退出码传出来——都还挂着 open issue;预编译二进制的发布工作本身就是另一个没关的 issue。它也没发到 Marketplace,作者说没有证据表明有外部人在用这个 Action。但 README 和限制说明页值得读一遍,当设计文本读,别当安装文档读。这个我只摸了小半天,没上任何真场景,先声明在前头。

适合谁:自己也在维护 CI、对仓库根目录那堆 yml 心里有点发虚的,或者想看看 fail-closed 这种原则怎么落到具体校验设计里的。不适合谁:想找个今天就能接进流水线的闸门,它现在还不是。

评论区比正文还耐看。七条顶级评论,主要围着这类桩实现暴露的更一般的问题打。

苏黎世那位 Artjoms Stukans 讲了自己 Go 项目里的同构事故:构造函数委托给一个支持注入超时的版本,测试全通过可注入版本构建,生产默认路径零覆盖,四个名字起得特别自信的测试照样全绿。他说只有变异测试把这事挖出来了,光读测试文件看不出来——缺失的东西不会出现在文件里。

作者回了一个。他那个 rehydrate 函数要重建的值,从功能没实现起就硬编码成占位符,没有调用方对占位符提过异议,cargo check 一路绿;直到真实路径开始产生真值、两边不再偶然一致,才炸出来。最后是两个针对性探针发现的,不是完整测试套件。

到这儿我以为要落到"去上变异测试"。没有。

作者拿 9 个文件做了个小变异,8 杀 1 活。他起初判那个活的是测试薄弱,然后自己把判断推翻了:那个变异改的是 ReceiptPayload.verdict,而这条路径上的断言查的是外层 DsseEnvelope,压根没看 verdict。他把变异重新打到 payload_type,外层信封确实带这个字段,第一次跑就红了。所以活下来的原因不是测试弱,是选的杠杆压根没碰到那条断言。

他给了条规则,我抄下来了:在信任一个存活变异之前,先说出本应捕获它的那条断言;说不出来,那就不是未测试分支,是任何测试都看不见的变异,不该进分母。

Artjoms 补了一刀。这规则把判断权挪进了分母,分母就成了人裁的,这个月和下个月不是同一个口径。而且变异只作用于已经存在的代码,还没人写的分支对它不可见——变异测试只是把盲区挪个位置。他还警告,如果没记下哪些变异被丢掉、为什么丢,那张排除清单自己会变成没人检查的角落,而那就是这整场讨论的起点。

作者没给轻松的答案。他提的办法是记谓词不记条目:判断被变异的字段是否落在那条断言比较的取值集合内,每次运行重算排除项,不持久化那张列表。这样还能挡住另一种失效——今天落在断言读取集之外的字段,等哪天有人把断言范围改大它就进来了,而缓存的排除项会让它永远出不来。另外他说,公布分母,别只公布比率:单给杀死数与范围内总数的比值,会掩盖变化方向,排除得越多数字反而越好看。同时报两个数,杀死的变异数、被判出界的变异数,第二个涨了,不审计清单也看得见。他也承认自己那个读取集是人工读测试数出来的,9 个变异的规模还撑得住,只有到了机器执行的量级,这套做法才算真诚实。

这一段比大多数项目的 README 都值得读。它没教你怎么配 CI,它教你怎么怀疑自己那句"测试是绿的"。

Action 那两条验收标准什么时候落地,我盯着 #5 那条带时间戳的记录。能不能打,你自己上 issue 区读一圈比我转述准。候选池里还压着一篇同类的事故复盘,看完再定要不要写。

顺手提一句,作者另外几篇的标题也很实在——六行改动干掉十五个单测、OOM killer 两天里掐断四次验收检查。这种标题我愿意多点几个。