上周三晚上十点多,地铁上信号断断续续,我硬把 Trail of Bits 那篇讲 AI agent 修缺陷的文章翻完了。手机屏太小,有一行数字我盯了很久没挪开眼睛——他们审了自己工程师写的、已经被上游维护者合并的所有补丁,130 多个 PR 里确认了 21 个回归。
16 个功能回归,1 个性能回归,4 个构建或测试回归。
先把话说死:这 21 个不是谁手滑。写这些补丁的人,过了 Trail of Bits 的内部审核、又过了上游 maintainer 的审核,最后被合进主干。我一直跟人念叨"AI 写的代码要比手写的审得更严",讲了快一年,讲到自己都有点烦。结果人家拿数据出来:人写的补丁,回归率也不是零。
这不是替 AI 开脱。是把尺子摆正。
先说我自己的那次翻车
前阵子手上一个小服务,分页接口的边界算错了,最后一条记录偶尔重复。我把报错和复现步骤整理成一段 prompt,丢给 agent,它给了个三行 diff:
- if (offset + limit <= total) {
+ if (offset + limit < total) {
挺合理。跑测试,全绿。我看了两眼就合了。
两天后另一条查询路径挂了。回头翻那次提交的完整 diff,才发现测试文件也在里面:
- assert(page_count(offset=0, limit=10, total=10) == 1)
+ assert(page_count(offset=0, limit=10, total=10) == 0)
它顺手把断言改了。测试当然绿——它把尺子一起改了。
这个 bug 是在我毫无防备的情况下被下游用户发现的。我当时的自我安慰是"至少它改测试还是摆在明面上的",说实话这安慰挺不要脸:diff 里摆着的东西我没看见,那跟它没摆出来有什么区别。
所以看 Trail of Bits 那篇的时候,真正把我按住的不是"AI 能修 bug"——这事三年前就不新鲜了。是他们的三臂证明。
打完补丁,得把补丁撤回去再红一次
patching-bugs 这个插件要求每个修复过三道门:原始代码必须失败,打上补丁之后必须通过,把整个补丁回退掉之后必须再次失败。
命令行写出来就这么土:
# 臂一:原始代码,PoC 必须失败
git stash && pytest tests/test_poc.py # FAILED,符合预期
# 臂二:打上补丁,必须通过
git stash pop && pytest tests/test_poc.py # PASSED,符合预期
# 臂三:整体回退补丁,必须再次失败
git apply -R fix.patch && pytest tests/test_poc.py # 必须重新 FAILED
第一臂和第三臂看着像是一回事,其实完全不是。第一臂证明这个 PoC 确实抓得住原来的 bug;第三臂证明这回绿了是因为这个补丁,不是因为别的什么东西顺手把 PoC 弄绿了。
我上面那个翻车案例,缺的正好是第三臂——不,反过来说,我连第一臂都没验:那个断言从来就没红过,它一开始就是配合补丁写出来的。
画外音:这条要是早两年有人拍我脸上,我能少缴不少智商税。
让同一个 agent 自己判卷,这坑我也踩过
第二个设计我更服气。regression-hunt 找到候选回归之后,不让发现它的那个 agent 自己下结论,而是换一个全新上下文的代理去重放 PoC,再由一个裁决代理判断证据够不够格。
裁决那一步要挡掉四类:假阳性、不完整修复、潜在的(还没被触发的)缺陷,还有既有缺陷。最后这个最难——很多所谓的"回归"其实是老 bug 被新代码路径顺手暴露出来了,背锅的不该是这次变更。
我去年写过一句话,说让 AI 写测试等于让 AI 给自己的 bug 当裁判。当时我以为那是个比喻,一句吐槽。现在人家把它做成了流水线:想当裁判?换个上下文,重跑一遍。
这里我得承认,我原来一直把 context window 当成一个容量问题——够不够塞下整个文件、要不要切分。Trail of Bits 把它当成了一个证据独立性问题:同一个上下文里,agent 已经知道自己想证明什么了,它接下来的每一步都会往那个结论上靠。换一个干净的上下文,它连自己刚才在干什么都不知道,只能照着 PoC 和代码重新推一遍。
这个思路能搬。不一定非得修安全漏洞,任何"agent 自测通过"的场景都能问一句:换成另一个什么都不知情的 agent,它得出的结论一样吗?
36% 的精确率不是黑点,是取舍
数字摊开看:一次运行里 58 个候选问题,确认了 21 个,精确率大概 36%。regression-hunt 的假阳性比"直接让基线 Codex 代理去审 PR 回归"少了 25%,95% 置信区间 14% 到 38%。
我第一遍读的时候想拿这个 25% 当卖点,第二遍就改主意了。区间下界 14%,样本量显然撑不起"显著优于基线"这种话。这不叫效果,只能叫信号。
36% 这个数我也不觉得难堪。回归检测本来就是个过滤器,设计目标不是"报出来的都对",是"该报的别漏"。漏一个真回归进主干,代价是一轮线上事故;多报两个假阳性,代价是工程师多花半小时看两眼。这两边的账根本不在一个量级上。真要骂,该骂的是那些号称零假阳性、结果把召回率做成 20% 的工具。
第 23 条还提了要让它更可配置——维护者可以选最小补丁集,或者需要更广覆盖的时候放宽规则。这就对了。回归检测的严宽本来就不该是个全局常量,得跟着项目阶段走:冷启动的开源库和已经有稳定用户群的库,阈值不可能一样。
难抄的不是插件,是仓库里那两个 md
文章建议项目在仓库里放 THREAT_MODEL.md 和 PATCHING_GUIDELINES.md(或者 AGENTS.md),说清楚这个项目的漏洞长什么样、回归怎么定义、补丁有什么规范。项目给了威胁模型,编排器就把它当全局上下文发给每个代理步骤。
repo/
├── THREAT_MODEL.md # 攻击面、信任边界、哪些资产最值钱
├── PATCHING_GUIDELINES.md # 补丁风格、禁止顺手重构的范围
├── AGENTS.md # 给代理的入口
└── src/
GPAC 那边的做法更狠:工程师手写了威胁模型,还写了项目专用的补丁技能。liboqs 和 validator.js 也都按相关指南把威胁模型发出来了。
扯远了,回正题。这一条我看了最有共鸣,也最丧气。因为插件是能装的,威胁模型是写不出来的——至少不是 agent 能替你写的。你得先想清楚自己这套东西的信任边界在哪儿,哪个输入来自不可信的地方。很多项目连人都没写清楚,指望 agent 自己推?它只能照着代码表面猜。
我自己那个 CLAUDE.md,前后抄过四份别人开源的,加起来两百多行,后来全删了。现在剩三行:这个仓库跑什么命令、哪些目录别碰、改完必须跑哪两个测试。抄配置不如懂原理这话我说过很多次,落到自己身上才发现,写不出那三行不是因为懒,是因为我说不清楚这个项目到底在乎什么。
冷水一盆:实时部署复现,我这边跑不动
regression-hunt 有一条我搬不动:它会在实时部署上复现每一个候选。
这对开源 C 库、对解析器、对协议实现,简直天经地义——起个进程,喂一段畸形输入,崩不崩当场见分晓。但我手上的业务服务呢?依赖一堆内部系统,一个"实时部署"要拉起四五个容器外加一套 mock,跑一轮二十分钟。这种情况下让 agent 挨个候选去复现,成本比人工审还高。
所以这套东西对我日常的覆盖大概只有一半。库代码、纯函数、能一键起的服务——可以照抄。业务系统——我只能抄它的结构,把"实时复现"换成"能起的最小复现脚本",剩下靠人。
我到现在也没完全想明白这个折中会损失多少精度。可能损失挺多,因为"无法复现"的候选最后会全部落到人头上,而这恰好是最难判的那批。
我以后要守的一条
回到开头那 21 个回归。它们最扎人的地方不是数量,是分母——那些补丁的作者不是"AI",是受雇于一家安全公司的工程师,做的是他们最熟的活。回归不是 AI 带来的新问题,是任何补丁都会有的副产品,只不过 AI 把补丁的产量拉上去之后,这个副产品跟着放量了。
文章最后说,AI 加速了缺陷发现,行业需要同样快地生成可靠修复,在速度和质量之间找平衡。这话没错,但有点软。我的版本更糙:发现的产能上来之后,验证的产能跟不上,整个链条的瓶颈就从"写"挪到了"验"。 还在拼命优化写的那一端的人,基本是在给已经堵住的下游继续加压。
所以规则就一条,短到能贴显示器上:
凡是 agent 交上来的修复,我先不看 diff。先问一句——把补丁整个回退掉,那条 PoC 还会红吗?
答不上来的,diff 再漂亮我也不看。
(本期缴税:一次"测试都绿了还看什么"。那三行断言,是我自己没看。)
