
明明写了“不覆盖”,为什么文件还是被替换了?一次 Agent 工具合同实验
我们把“不覆盖”写进了一次文件操作请求。审批记录也确实保存了这项限制。
执行完成后,目标文件却被替换了。
这不是一场用户数据事故,而是我们用合成文件做的一次受控实验。更值得追问的是对照结果:同样设置 overwrite=false,也就是“不覆盖已有目标”,写入操作会拒绝,复制和移动却返回成功,并把目标换成了源文件的内容。
如果只查看审批记录,会认为限制已经明确;如果只查看成功回执,会认为操作正常完成。把两者与实际字节放在一起,问题才出现:记录里的“不覆盖”,究竟由谁负责兑现?
1. 一个参数,从请求走到文件,中间经过了什么
CodeFlowMu 是我们在开发的本地多 Agent 协作系统,用任务文件、执行会话和证据组织工作。它的一类受控工作区操作,会先构造具体请求,再取得一次批准,执行时核对请求,最后交给文件执行器。
这比模型直接运行一条任意命令更明确:请求里有执行位置、目标、内容或源路径,也有操作限制;审批记录保留这些信息。我们这次研究的不是“能不能绕过批准”,而是已经进入批准链的限制,会不会在最后一层失去作用。
实验使用真实的请求构建器、审批服务和文件执行器。每个场景都在新建的隔离目录中生成测试文件,通过服务取得研究用的管理员批准,再观察执行结果。没有调用在线面板,也没有让真实 Agent 操作业务文件。
我们固定源码提交为 c008d9db91a21136fc61a4f60314e22db395d5d2,首先做了四组对照,每组两轮:
| 场景 | 请求中的限制 | 实际结果,两轮一致 | 执行后文件事实 |
|---|---|---|---|
| 写入一个不存在的目标 | 不覆盖 | 成功 | 新内容写入,说明限制没有挡住合法创建 |
| 写入一个已经存在的目标 | 不覆盖 | 拒绝,操作记录为失败 | 原内容保留 |
| 复制到一个已经存在的目标 | 不覆盖 | 成功 | 目标被源内容替换,源文件仍在 |
| 移动到一个已经存在的目标 | 不覆盖 | 成功 | 目标被源内容替换,源文件已移动 |
这里必须保留第二行。它证明受测写入执行器已有保护,不能因为复制和移动的结果,就把问题写成“所有文件操作都不检查覆盖”。第一行同样重要:拒绝得越多并不意味着越正确,合法的新文件仍然应该写得进去。
四组共八条正式观测,支持的是受测接口之间的行为差别,不是生产事故发生率。

图 1:按 E0–E3 正式记录绘制的实验解释图,每组两轮;保留合法创建与拒绝覆盖两组对照。图不是实验截图,来源:正式受控实验记录,见证据说明。
2. 限制没有丢,但有的执行器没有使用它
随后我们沿着源码检查参数流向。
通用工作区输入类型包含 overwrite 字段。请求构建器把它写进批准对象;从批准记录还原执行输入时,这个值也保留下来。正式实验的复制、移动记录里,该字段始终是 false。
因此,不能把结果解释为“模型忘了填写参数”,也不是传输时把 false 变成了 true。
差别发生在最终文件操作分支:
- 写入分支先检查目标是否存在。如果存在且明确不允许覆盖,就抛出
target_exists_and_overwrite_is_false,原字节保留。 - 复制分支调用文件复制函数,没有在该分支使用“不覆盖”参数。
- 移动分支调用重命名函数,同样没有在该分支使用这个参数。
在受测 Windows 环境中,后两条路径完成了目标替换。这个结果来自实际执行后的读回,不是只凭函数名猜测。
这里有一个容易忽略的层次差别:请求摘要可以证明“执行请求与批准对象一致”,却不能单独证明“执行器遵守了对象里的每一项限制”。 如果执行器根本没有消费某个字段,字段被稳定保存、参与绑定,也不会自动变成运行约束。
我们还分别记录了回调次数和操作状态。被拒绝的写入已进入执行回调,审批服务捕获错误后保存失败记录;复制和移动则保存成功记录。不能因为研究脚本最后正常退出,就宣布这些操作的合同都通过了验收。
3. 外部的“创建文件”,照亮了同一种差别
OpenAI Agents SDK 是用来开发 Agent 应用的工具库。它的一项沙箱补丁提案,讨论了另一个很具体的问题:对模型宣称“创建新文件”的操作,遇到已有目标时,实际写入是否也只允许创建。
在 PR #4893 中,作者报告已有文件被替换,工具却仍向模型返回创建成功。拟议改动是在创建前检查目标不存在;新增测试同时核对拒绝结果和原内容保留。截至本轮 2026 年 9 月 7 日的核验,该提案仍未合并,我们没有独立复跑它的测试。
它与本地实验不是同一个问题。外部案例的限制来自“创建”这项操作的定义;我们的限制来自请求中的显式参数。但两者都要求追问:调用方看见的效果承诺,是否在真正修改文件的位置得到执行?
这也不意味着一切写入都必须变成“只创建”。需要替换文件的操作本来就可以存在,关键是不能把替换包装成创建,也不能让调用方以为自己附加了一个实际不起作用的限制。
同一项目的 PR #4894 则从参数编码提供了另一种对照:挂载配置中的值被拼进逗号分隔的选项字符串,值里的逗号可能被解释成另一项选项。拟议修复针对分隔符校验,而不是再增加一次权限确认。它同样尚未合并。
两个外部案例提醒我们,权限、参数含义和底层效果不能互相代替。已经确认“允许使用这个工具”,不等于工具已经正确解释并执行了所有输入限制。
4. 现在应该补执行器,还是先收紧接口?
实验之后,最容易着急做的事情是直接补一个条件判断。但在这之前,还需要回答一个合同问题:“不覆盖”到底适用于哪些操作?
如果产品合同规定它对写入、复制、移动都有效,那么受测后两条分支就需要兑现同样的限制,并用相应的文件操作保证它。若要求包括并发下不覆盖,还必须验证检查与写入之间的竞争,不能仅凭顺序测试就称为原子保证。
如果合同只把该字段定义为写入专用,那么复制、移动入口就应该明确拒绝这个不适用参数,或通过不同输入结构让它无法被误用。默默接收并保存在批准对象里,容易让调用方把它当成有效限制。
目前我们确认了内部受控请求到执行效果的可重复差别,也检查了注册表确实把这些操作连接到同一套准备和执行函数。但尚未完成公开入口的适用性核查:哪些调用方可以传入该字段,界面怎样呈现,它是否被正式声明为通用限制。
因此,这是一项值得进入窄范围合同核查的异常观察,不是已证明的线上越权,也不是已经完成的修复。研究没有修改产品代码,更没有据此立项重建一套通用授权框架。
5. 不要只测试“批准成功”,要测试限制如何失败
对接工具时,可以为重要限制建立一张很小的消费表:它在哪里输入,在哪里被批准,哪个执行器分支真正使用它,不满足时返回什么,以及原对象是否保持不变。
测试也应至少成对出现。一边是合法场景能完成;另一边是违反限制时被拒绝,并且没有产生被禁止的效果。只检查错误字符串不够,检查原字节是否仍在,才能避免“已经改了文件,最后才报错”的另一种假保护。
遇到一个工具有多种操作模式,还要逐个模式验证。写入分支的正面结果不能替复制、移动作证;相同输入类型也不能证明相同语义已经实现。
本次研究留下的判断很简单:限制被记录,是可审计的起点;限制被执行,才是调用方真正依赖的能力。
证据与范围
本文使用正式数据中的 E0–E3,每组两轮,共八条观测;与配套文章使用同一研究批次,但不混算样本。中文证据说明、逐轮脱敏记录和英文说明已随本文公开提供。
实验只涉及隔离目录内生成的合成文件,没有生产丢失率样本,没有在线 HTTP、面板或真实 Agent 全链路验证,也未做多进程覆盖竞争。本文的代码解释限定于固定提交与受测 Windows 环境。记录完整性校验不等于重新运行产品,更不等于独立 QA;本轮也没有产品修复、合并或发版结论。