跟 Agent 一起写代码时间长了你会遇到一个非常诡异的现象方案讨论阶段它逻辑清晰、头头是道可一旦让它真正动手改仓库各种灾难就轮番上演——文件被拦腰截断、无关文件被顺手改动、正在用的分支被它一下切走甚至整个目录被一条命令原地蒸发。我过去一直把这些问题归结为“模型能力不行”直到最近完整读完了一款当红开源 CLI 编程代理工具下文统一叫它 C 代理的源码才意识到真正的病灶不在“大脑”而在“手”——也就是那层把模型意图落到文件系统和命令行上的工具实现。这篇文章会把我在源码里看到的执行链路、几个真的会改坏仓库的实现级缺陷、以及我自己的加固方案完整梳理一遍。正在用 Agent 改代码的开发者或者想给 Agent 写安全工具层的工程师应该都能从这里抄走一些东西。1. 先分清责任仓库被改坏锅在“手”不在“脑子”大多数人使用 Agent 时都把它当成一个“会打字的程序员”觉得它改坏了仓库就说明它编程能力差。但从架构层面看一个 Agent 的代码修改能力其实是三个层次叠加出来的规划层模型负责“想”输出修改意图通常是一段自然语言描述加一组工具调用参数。执行层工具运行时负责“做”把意图翻译成文件读写、命令执行、git 操作等具体动作。反馈层观察回路负责“看”把执行后的 stdout、退出码、diff 结果回传给模型供它判断下一步。C 代理源码里规划层是模型接口但真正下地干活的代码几乎全在执行层。读源码时会发现它内部定义了一大堆 ToolSpec覆盖文件编辑、代码搜索、Shell 命令、git 状态查询等能力每个工具都有一套参数 Schema 和对应的执行函数。这个“工具清单”本质上就是 Agent 伸进仓库的那双手。为什么我坚持说锅在“手”因为“大脑”天生就不可靠。模型生成的内容带有概率性它推理时可能看漏上下文、可能想当然、可能记错参数格式。工具层如果足够稳就能把这些错误拦截在落地之前——比如修改前校验目标上下文是否存在、破坏性命令强制二次确认、失败时把真实错误反馈给模型。反过来如果工具层只是“尽力而为”把所有信任都交给模型的自觉性那仓库损坏就只是时间问题。我甚至想给评价标准下个定义判断一个 Agent 工具链是否成熟不是看它能执行多少种操作而是看它失败时能不能安全回退、反馈信息是否足够让模型做出正确决策。C 代理在工具设计上其实比很多同类产品克制但源码里依然藏着几个很容易被触发的“手滑”点下面逐个拆开讲。2. 读源码理出的执行链路“手”摸到仓库的路径在分析具体坑之前先把 C 代理一次代码修改的完整链路梳理出来。单次工具调用的生命周期大概是这样模型收到用户指令或上一步观察结果生成一个工具调用参数可能是“修改 src/user.py把某函数改成 XXX”。运行时解析 JSON 参数做 Schema 校验然后把调用分发到对应的执行器。执行器操作文件系统或启动子进程。执行结果被收集成 stdout、stderr、退出码包装成一条观察结果。模型拿到观察结果决定继续修改、跑测试还是宣布结束。在这条链路里C 代理最核心的几个“手部动作”可以按操作形态划分成下面四类工具动作操作形式源码里的典型场景风险等级整文件覆写模型给出完整新内容运行时直接写文件生成新文件、替换小体量文件高会抹掉没被模型看到的既有修改上下文补丁模型给出带上下文的 hunk运行时匹配后替换对既有代码做局部修改中匹配错位置会把修改送到错误文件块Shell 命令模型生成命令字符串运行时起子进程执行跑测试、装依赖、执行脚本和 git 操作极高破坏性命令没有额外护栏Git 状态查询模型调用专用 git 工具或 shell 命令查看 diff、status、log低只读操作但仍可能被复合命令带偏这里最值得注意的一点是几乎所有修改动作最终都会落到“写入文件”这一个操作上。而“写入文件”在 C 代理源码里存在两种实现一种是直接把模型返回的完整内容写回磁盘另一种是先把当前文件内容读出来做快照把补丁应用在这个快照上再把结果写回。第二种看起来更精细但它把所有隐患转移到了一个问题上——快照会不会过期。如果你在模型思考期间手动改了同一个文件或者在后台有格式化工具自动保存了改动快照和磁盘就不一致了工具还是按旧快照写结果就是你的新改动被无声无息覆盖。另一个需要留意的设计是运行时会维护一份“当前对话上下文”里的文件状态模型看到的、判断用的都是这份状态不是磁盘的真实状态。只要状态与实际内容出现偏差后续所有的补丁基于的都是假前提。这也是为什么很多破坏不是一次性的而是滚雪球一样越改越歪。3. 四个实现级缺陷任何一个都能让仓库原地爆炸3.1 快照陈旧模型看到的文件已经不是我手里的文件C 代理执行文件补丁时会先把磁盘上的文件读出来存成当前快照然后让模型在这个快照上“思考”最后把结果写回。这个机制本身不坏坏的是它跟外部修改之间存在一个竞态窗口。我做过一次复现实验在编辑器里开着自动格式化文件一保存就自动重排缩进。我让 C 代理修改同一个文件它先读取了格式化之后的版本这没问题。但如果在它读取之后、写回之前我又手动改了文件并保存那么它的写回操作会用“旧快照 新补丁”的合成结果直接覆盖我刚刚手动加的内容。更隐蔽的是这个覆盖毫无提示——源码里那个写回函数根本不去核对磁盘文件当前的 hash 或 mtime 是否跟快照一致只要模型生成了结果它就闷头写盘。这个问题在多人协作项目里尤其致命。因为仓库里任何一个文件都可能被 CI 脚本、代码格式化插件、代码生成器、或者同事的手动修改触碰过。Agent 只要基于一个过期快照写盘就会把这些外部改动全部吞掉而 git diff 看起来又像是 Agent 主动改的追溯成本极高。3.2 并发写入两个工具同时握住同一只手C 代理的部分工具是支持异步并行执行的源码里也有对应的 runner 调度逻辑。但我翻遍整个执行层没有找到基于文件路径的全局写锁机制。这意味着当一个模型会话里同时生成两个针对同一文件的补丁时运行时可能让它们并发落盘。并发写会出现三种典型破坏第一种后写的覆盖先写的表面看文件完整但内容缺了前一修改第二种两个进程同时写同一个临时文件名如果实现用了固定名会出现写入交错文件内容直接变成乱码拼接第三种更隐蔽——一个补丁先成功另一个基于旧快照的补丁随后成功把前一个补丁的逻辑又改回旧版本等于白做了一个功能。你可能会说“模型一般不会同时发两个修改同一文件的调用吧。”实际会。模型在长上下文里经常会把一个大任务拆成多个并行工具调用比如“同时改 A.py 和 B.py”而这两个文件可能恰好互相 import。或者用户手工输入一个复合指令“把 login 函数重构一下顺手把 test 里对应的地方也改了。”运行时如果优化成并发它们就会在同一时刻握上同一个文件。3.3 Shell 命令它是万能钥匙也是万能炸药C 代理的命令执行器实现得非常直接拿模型给的命令字符串起一个子进程等退出收输出。就这么简单。源码里没有危险命令黑名单没有交互式进程检测也没有把命令工作目录强制锁定到仓库根目录。这么做的后果是模型只要在某一轮思考里生成git clean -fd、git reset --hard、rm -rf build/这类命令执行层就会原封不动地跑。一旦命中仓库立刻回到上古时代或者直接少掉整个目录。更麻烦的是shell 天然支持管道和链式命令模型完全可能写出一条git add . git commit -m x git push -f这样的一站式毁灭链路。它甚至不需要“故意”做坏事只要某次工具调用生成的字符串里多了一个-f结果就完全不一样。我之前和一些同行聊过能不能用白名单从源头禁掉复杂 shell但现实是跑测试、构建、安装依赖本身就依赖 shell太严格的限制会废掉 Agent 一大半生产力。问题不在于“要不要 shell”而在于“shell 的边界没有被人为收紧”。3.4 反馈失真命令失败了Agent 却以为一切正常这个缺陷更隐蔽但破坏力一点不小。C 代理里不同工具返回结果的方式并不统一有的工具只收集子进程的 stdout把 stderr 丢掉有的把 stdout 和 stderr 混在一起原样拼接还有的超时处理逻辑不完整——子进程被杀之后返回的“退出码”字段是空的甚至是 0模型会把超时当成成功。我实际遇过一次Agent 跑测试命令超时了运行时把整条测试输出截断后返回但退出码字段是 0。模型看到输出前面几行正常的日志就直接说“测试通过”然后继续往下改代码。后面所有修改都建立在一个错误前提上整个会话就完全失控了。你当然可以让模型自己看完整输出但当输出本身被截断、退出码字段失真时这个反馈回路就断了。这四个缺陷组合起来会让“改坏仓库”变成一种累积效应第一次修改错了不报错模型基于错误状态继续改第二次错得更多第三次直接引发连锁破坏。最后你打开 git diff根本不知道哪一步开始出的问题。4. 我抽出来的三段高危代码逐行拆给你看4.1 一个典型的 apply_patch 实现源码里这类实现会经过一些封装但核心逻辑抽出来基本长这样def apply_patch(file_path: str, patch_text: str) - bool: content Path(file_path).read_text() for hunk in parse_hunks(patch_text): index content.find(hunk.context) if index -1: continue content content.replace(hunk.context, hunk.new, 1) Path(file_path).write_text(content) return True这段代码至少有四个问题。第一它使用“第一个匹配位置”如果仓库里有两个文件内容相近、或者同一个文件里同一段代码出现多次补丁就会打在错误的地方。第二每个 hunk 是独立替换的后一个 hunk 的匹配上下文可能已经被前一个 hunk 改掉导致第二个 hunk 找不到目标但函数不报错。第三find查找失败时只是continue不会中断整个补丁流程。第四也是最致命的它永远返回 True——哪怕所有 hunk 都没匹配上模型收到的也是“成功”。我曾把这段代码的行为写在实验脚本里构造一个上下文重复的文件让模型修改确认它会把修改应用到第一处匹配而真正的目标在第二处。第一次出错后模型甚至没有收到任何异常提示后续几步全部在错误的文件上继续执行。4.2 看起来正常、但极端脆弱的写入方式def write_file(path: str, content: str): with open(path, w, encodingutf-8) as f: f.write(content)这段代码在正常情况下一万次都不会出问题。但“一万次没问题第一万零一次出问题”恰恰是它危险的根源。如果写入中途进程被杀、磁盘满了、或者编码转换遇到特殊情况原文件就会只剩半截内容。尤其对源代码文件来说半截文件往往意味着整个模块无法解析git diff 里全部是删除行几乎不可手工恢复。正确做法应该是在同目录先写一个临时文件然后通过os.replace()原子替换目标文件。这样即使进程崩溃磁盘上也永远保留旧文件或新文件之一不会出现“写到一半”的损坏状态。4.3 让模型随手跑任意命令的 Shell 执行器def run_shell(command: str) - str: proc subprocess.run(command, shellTrue, capture_outputTrue, textTrue) return proc.stdout proc.stderr这里把 stdout 和 stderr 拼接成一坨字符串返回模型无法区分哪些是正常输出、哪些是错误信息。shellTrue意味着命令可以随意包含管道符、环境变量、重定向一条命令能完成任何操作。没有超时没有工作目录约束输出也没有截断——如果模型运行了一个交互式进程整个执行器会被卡死直到手动杀掉进程。这段代码暴露出来的整体设计哲学是执行器信任模型生成的每一个字符。从工程角度看这种信任是危险的因为模型的每一步输出都带有随机性一条字符串解析偏差就可能把“列出目录”变成“删除目录”。5. 给 Agent 换一副“稳手”我的加固方案与代码读完整套源码之后我给自己维护的一套 Agent 执行层加了一堆约束。核心思路只有一条让工具失败的成本变低让失败的信息变真实。5.1 原子写先落临时盘再整体替换import os import uuid def atomic_write(path: str, content: str) - None: tmp_path f{path}.{uuid.uuid4().hex}.tmp try: with open(tmp_path, w, encodingutf-8) as f: f.write(content) os.replace(tmp_path, path) except Exception: if os.path.exists(tmp_path): os.remove(tmp_path) raise这个改动看起来微不足道但它把“文件损坏”这个最惨的失败模式直接排除了。进程被杀、磁盘满、异常断电最坏情况也只是临时文件残留目标文件永远处于完整状态。5.2 补丁前置校验上下文必须唯一且存在给补丁工具加一道硬校验解析出的每个 hunk在目标文件中必须能找到并且只能找到一段完全匹配的上下文。否则整个补丁拒绝应用并把冲突位置原样反馈给模型。def validate_hunk(content: str, hunk) - bool: occurrences content.count(hunk.context) if occurrences ! 1: raise PatchConflict( f上下文块需要唯一匹配当前匹配 {occurrences} 处位置: {hunk.header} ) return True模型收到PatchConflict后就知道自己选错了上下文会自动重试换一个更精确的锚点。这比在错误位置默默打上补丁强一万倍。5.3 危险命令拦截黑名单永远比白名单好落地import re DANGEROUS_PATTERNS [ r\bgit\sclean\b, r\bgit\sreset\s--hard\b, r\brm\s-rf\b, r\bgit\scheckout\s--\b, r\bgit\spush\s-f\b, r\bgit\sbranch\s-D\b, ] def check_command(command: str) - None: for pattern in DANGEROUS_PATTERNS: if re.search(pattern, command): raise CommandBlocked( f命令匹配到危险模式已拦截: {pattern} )黑名单有两个明显好处一是实现成本低二是不会误杀正常命令。坏处是模型可以写一些绕开正则格式的等价命令。所以更稳妥的姿势是双保险——黑名单之外再默认禁止git push、git clean、git reset --hard这三类高危操作除非用户显式设置allow_destructive_commandstrue。5.4 文件级串行锁并发写直接排队import threading import hashlib _file_locks {} _file_locks_guard threading.Lock() def acquire_file_lock(path: str) - None: key hashlib.sha256(path.encode(utf-8)).hexdigest() with _file_locks_guard: if key not in _file_locks: _file_locks[key] threading.Lock() _file_locks[key].acquire() def release_file_lock(path: str) - None: key hashlib.sha256(path.encode(utf-8)).hexdigest() with _file_locks_guard: _file_locks[key].release()文件锁要加在补丁工具和整文件覆写工具的共同入口而不是每个 handler 内部。这样才能保证“无论哪种修改方式同时只有一个能碰这个文件”。实现上还可以按仓库根目录做一把全局锁简单粗暴但绝对安全。5.5 反馈标准化让模型看见真实失败这是我改动后收益最大的一项。所有工具返回统一结构class ToolResponse(BaseModel): ok: bool exit_code: int | None stdout: str stderr: str timeout: bool changed_files: list[str]模型在每一步都能看到okFalse、exit_code2、stderr里有什么这比拼接字符串的方式可靠得多。实测中标准化反馈能让模型在出错后主动换个方案而不是在错误状态上反复横跳。6. 踩过这几回坑之后我对“Agent 改代码”的几点反思最早我并不相信“手”的问题这么关键总以为 Agent 能力不行换一个大模型就好。但当你真的把整个工具调用链梳理一遍后会发现再聪明的模型穿上一双不受控制的“手”也能把仓库搅得天翻地覆。反过来工具层一旦做扎实即使模型偶尔犯傻也只是多试一次、多问一句的代价而不是整个仓库陪你陪葬。我有几个真实经历某次因为格式化工具并发写同一个文件整个源文件只剩一半恢复靠从远端 checkout但当天的工作全部丢失某次 Agent 修改测试文件时因为匹配错误把另一个 fixture 生成文件也改了git diff 红了一大片还有一次它在错误的子目录里重建了一套重复的项目结构后来靠路径归一化和 cwd 锁定才杜绝。每一回教训都指向同一个结论给 Agent 配工具先想清楚它失败时怎么恢复再想它能做什么。如果你现在正准备把 Agent 接入自己的开发流程我的建议是不要一上来就给它最齐全的工具链。先把文件修改工具和命令执行工具管住再逐步放开观察它在哪些场景最容易“手滑”针对性地补护栏。宁可让它多问一句话也不要让它多闯一次祸。