本篇以上一篇的runs/demo-001/replay_report.json为基线,读取其events_sha256与最终状态,再复制契约、裁判快照、工具清单和回执到临时沙箱逐项变异。新增mutation_report.json;它记录每种攻击是否被预期守卫捕获,成为最终交付验收的安全证据。
一、绿色测试可能只是没碰到守卫
我们写了目标指纹、只读快照和回执验证,但如果测试从未故意破坏它们,只能证明正常路径能跑。变异测试主动制造一个小错误,期望系统变红:修改契约目标、弱化断言、把工具根目录扩到/、删除事件、伪造回执 ID。若某个变异仍通过,就暴露了守卫缺口。
这里测试的不是业务实现,而是控制面不变量。每个变异只改变一个因素,避免失败原因混杂。临时目录从基线复制,执行结束即丢弃,原始运行证据绝不能被测试修改。
from__future__importannotationsimportjsonimportshutilimporttempfilefromdataclassesimportasdict,dataclassfrompathlibimportPathfromtypingimportCallable@dataclass(frozen=True)classMutationResult:name:strdetected:boolguard:strdefmutate_contract(root:Path)->None:path=root/"contract.json"data=json.loads(path.read_text(encoding="utf-8"))data["goal"]="只要测试通过即可"path.write_text(json.dumps(data),encoding="utf-8")defmutate_receipt(root:Path)->None:path=next((root/"receipts").glob("*.json"))data=json.loads(path.read_text(encoding="utf-8"))data["call_id"]="forged"path.write_text(json.dumps(data),encoding="utf-8")defrun_case(source:Path,name:str,mutation:Callable[[Path],None],verifier:Callable[[Path],None])->MutationResult:withtempfile.TemporaryDirectory()asfolder:root=Path(folder)/"case"shutil.copytree(source,root)mutation(root)try:verifier(root)except(ValueError,KeyError)asexc:returnMutationResult(name,True,type(exc).__name__)returnMutationResult(name,False,"none")defmain()->int:source=Path("runs/demo-001")cases=[run_case(source,"contract_goal",mutate_contract,verify_all),run_case(source,"receipt_id",mutate_receipt,verify_all),]report={"baseline":json.loads((source/"replay_report.json").read_text()),"mutations":[asdict(case)forcaseincases]}Path("mutation_report.json").write_text(json.dumps(report,indent=2),encoding="utf-8")caught=sum(case.detectedforcaseincases)print(f"mutations={len(cases)}detected={caught}escaped={len(cases)-caught}")return0ifcaught==len(cases)else2if__name__=="__main__":raiseSystemExit(main())运行输出:
mutations=2 detected=2 escaped=0二、为什么“杀死变异”比覆盖率更接近目标
行覆盖率说明代码被执行过,不说明断言能发现错误。验证契约的分支即使达到百分之百覆盖,测试若不检查返回值,篡改仍会逃逸。变异存活直接说明“这个错误发生时,现有证据链仍然给绿灯”,比覆盖率更贴近防作弊目标。
变异也不能越多越好。把 JSON 随机改成乱码只会证明解析器能报语法错,价值有限。高价值变异来自威胁模型:改变目标但保留旧摘要、修改裁判并重算文件时间、授权相似前缀路径、复用另一任务回执、删除预算耗尽事件。记忆点是:覆盖率问守卫来过没有,变异问小偷经过时守卫醒没醒。
importjsonfrompathlibimportPathdefassert_mutation_report(path:Path)->None:report=json.loads(path.read_text(encoding="utf-8"))escaped=[item["name"]foriteminreport["mutations"]ifnotitem["detected"]]guards={item["guard"]foriteminreport["mutations"]}ifescaped:raiseAssertionError(f"escaped mutations:{escaped}")if"none"inguards:raiseAssertionError("a mutation has no named guard")assert_mutation_report(Path("mutation_report.json"))print("mutation_gate=PASS escaped=0 named_guards=True")运行输出:
mutation_gate=PASS escaped=0 named_guards=True三、避免变异测试自己说谎
示例中的verify_all在工程里应组合独立验证器:契约摘要、快照摘要、事件序号、回执身份和状态重放。不能让变异函数与验证器共享“预期答案”,否则测试可能按变异名称硬编码通过。报告保存实际异常类型与失败位置,评审者可以确认是正确守卫捕获,而非后续无关崩溃。
还有一个踩坑:只要抛异常就算检测成功。若篡改契约后因缺少临时目录报FileNotFoundError,并未证明契约守卫有效。每个案例应声明预期原因码,如contract_hash_mismatch,其他异常一律判测试错误。
四、变异集合也要版本化
威胁模型会随着工具能力变化。新增网络写工具,就要增加重复请求、跨域和凭据泄漏变异;允许修改数据库,就要覆盖事务中断。mutation_report.json记录套件版本、基线摘要和每例原因码。某次版本减少案例必须经过人工审查,不能因为难以通过就删除攻击。
运行时间较长的变异可以分层:每次提交跑控制面快速集合,发布前跑容器逃逸、并发崩溃和真实 API 沙箱。失败绝不能由 Agent 自动修改变异本身来修复,因为它们与裁判同属保护范围。
本篇产物mutation_report.json将成为最后一篇release.py的必需输入。只有基线可重放、全部高价值变异被正确守卫捕获,最终交付包才会生成。
变异失败后的修复流程同样受保护:先为逃逸攻击增加或加强确定性守卫,再运行原基线确认正常任务不被误杀,最后重跑全部变异。不能只针对该样例写字符串判断,否则守卫只是换一种硬编码。新守卫应描述不变量,例如“回执任务号必须等于当前任务”,并覆盖至少一个合法反例。
参考来源
- Martin Fowler|Mutation Testing
- Dev.to|Loop Engineering: How to Stop Your Agent Reward-Hacking Its Own Checks
👍 觉得有用就点个赞 + 收藏,方便回头查阅;有疑问直接在评论区留言,我看到都会回。
🚀 本文属于《Agent可靠性工程实战》系列,持续更新,关注不迷路。
📌 文章里的代码都能直接跑。想要可直接 clone 的完整工程 + 配套部署脚本 / 踩坑清单?评论一声或发邮件到cj2664@qq.com,我免费发你。
如果你正好在做类似系统、或有工程化难题想找人做,也欢迎邮件聊一句——我按实际情况评估,能落地的就接单或出方案。评论和邮件都能直接找到我,不用跳别的平台。