第九章:Write 为什么更像受控覆盖
拆 Write 这条高风险整文件覆盖链路,以及 Claude Code 为它补上的保护措施。
1. 为什么 Write 不能和 Edit 混着看
从表面看,Write 很简单:
- 给一个路径
- 给一段内容
- 整个写进去
但在 Claude Code 里,Write 并不是 Edit 的简化版。
恰恰相反,它是一种风险更高的协议,因为它做的是:
整文件覆盖。
这意味着它必须解决的问题反而更重:
- 什么时候该用
Edit,什么时候必须用Write - 覆盖已有文件之前,怎么确认模型基于最新事实
- 全量改写后,怎么把影响同步给宿主系统
所以 Write 值得单独拆,不然很容易在 C# 版里把它做成一个过于粗暴的 WriteAllTextAsync() 包装。
2. 先说结论
我对这份实现的判断是:
FileWriteTool 的本质不是“写文件”,而是“受 Read 前提约束的整文件替换协议”。
它和 Edit 的边界很明确:
Edit负责局部、精确、最小改动Write负责新建文件,或对已有文件做明确的完整重写
Claude Code 在 Write 上额外做的治理主要有这些:
- prompt 层明确要求优先用
Edit - 现有文件必须先
Read - 覆盖前要做 stale check
- team memory 文件不能借
Write写入 secret - 写入前后同样要联动 file history、LSP、VS Code、telemetry
- transcript 侧会区分“create”和“update”
这说明作者对 Write 的定位很克制:
它不是默认写入工具,而是“最后手段”和“整文件协议”。
3. 源码锚点
建议先看这些位置:
src/tools/FileWriteTool/FileWriteTool.ts:94src/tools/FileWriteTool/FileWriteTool.ts:119src/tools/FileWriteTool/FileWriteTool.ts:125src/tools/FileWriteTool/FileWriteTool.ts:135src/tools/FileWriteTool/FileWriteTool.ts:153src/tools/FileWriteTool/FileWriteTool.ts:198src/tools/FileWriteTool/FileWriteTool.ts:223src/tools/FileWriteTool/FileWriteTool.ts:281src/tools/FileWriteTool/FileWriteTool.ts:305src/tools/FileWriteTool/FileWriteTool.ts:329src/tools/FileWriteTool/FileWriteTool.ts:332src/tools/FileWriteTool/FileWriteTool.ts:418src/tools/FileWriteTool/prompt.ts:7src/tools/FileWriteTool/UI.tsx:130src/tools/FileWriteTool/UI.tsx:144
4. 整体结构图
flowchart TD
A["模型发起 Write(file_path, content)"] --> B["backfillObservableInput()<br/>路径规范化"]
B --> C["checkPermissions()<br/>write 权限"]
C --> D["validateInput()"]
D --> D1["team memory secret guard"]
D --> D2["deny rule / UNC 保护"]
D --> D3{"文件存在吗"}
D3 -->|否| D4["允许创建"]
D3 -->|是| D5["必须先 Read"]
D5 --> D6["mtime stale check"]
D4 --> E["call()"]
D6 --> E
E --> F["skill discovery / diagnosticTracker / fileHistory"]
F --> G["同步读取当前文件元数据"]
G --> H["再次 stale check"]
H --> I["writeTextContent()<br/>整文件覆盖"]
I --> J["通知 LSP / VS Code"]
J --> K["更新 readFileState / telemetry / git diff"]
K --> L["按 create / update 返回结果"]
这里最关键的一点是:
Write 不是裸写磁盘,而是“全量覆盖前后的完整编排”。
5. Prompt 已经明确给它降级定位
src/tools/FileWriteTool/prompt.ts 里有三条话特别关键:
- 现有文件必须先
Read - 修改已有文件优先用
Edit - 除非用户明确要求,不要新建文档类文件
这三条连起来,就是 Claude Code 对 Write 的真实定位:
- 它不是默认编辑工具
- 它不是文档生产工具
- 它主要服务于新文件创建和完整重写
这非常重要,因为很多 agent 系统一不小心就会让“写文件”变成默认动作,最后整个代码库都是大面积重写。
Claude Code 显然在刻意避免这件事。
6. 输入校验阶段已经把一批风险挡在外面
6.1 路径先规范化
和 Read、Edit 一样,Write 也先 expandPath(file_path)。
原因还是同一套:
- hook allowlist 不能被绕过
- 权限匹配要统一
readFileState的 key 要统一
6.2 Write 走统一写权限
checkPermissions() 最终也是:
checkWritePermissionForTool(...)
这说明 Claude Code 在权限层不区分:
- 局部编辑
- 整文件覆盖
它们都属于 write 范畴,只是工具协议不同。
6.3 team memory secret guard 也在最前面
一上来就是:
checkTeamMemSecrets(fullFilePath, content)
这说明就算路径有权限,也不代表你能随便把任意内容写进去。
6.4 deny rule 和 UNC 路径防护照样有
这里逻辑和 Edit 很像:
- deny rule 直接拒绝
- UNC 路径不提前碰文件系统,避免 NTLM credential leak
这说明 Claude Code 在所有本地文件工具上,都统一执行“路径副作用前置治理”。
6.5 现有文件必须先 Read
这是 Write 最值钱的一条约束。
如果文件已经存在,而你没有读过它,或者只读了 partial view,就会直接拒绝:
- 先读,再写
这里的意思很清楚:
整文件覆盖不能建立在猜测上。
6.6 校验阶段就做第一次 stale check
如果文件存在,它会先 stat 一次拿 mtime,然后跟 readFileState 比。
只要文件在读取之后变了,就会直接报:
- 文件已经被用户或 linter 修改过,先重新读
也就是说,Claude Code 不允许模型拿着旧快照去做全量覆盖。
7. call() 真正负责的是“全量写入事务”
7.1 一开始先做 skill 发现
Write 跟 Read、Edit 一样,会根据路径:
- 发现 skill 目录
- 触发动态加载
- 激活条件 skill
这再次说明 Claude Code 把“你正在处理哪个文件”视为重要上下文信号。
7.2 写之前先接通外围系统
它在真正写盘前先做:
diagnosticTracker.beforeFileEdited(...)fileHistoryTrackEdit(...)
这说明 Write 虽然是整文件覆盖,但它仍然被纳入和 Edit 一样的历史与诊断链路。
7.3 目录创建和 file history 都刻意放在关键区外
源码注释强调得很明显:
mkdirfileHistoryTrackEdit
这些 await 不能插进“stale check 到真正写盘”的中间。
因为一旦插进去,并发编辑就可能穿插,导致原子性被打断。
这其实说明 Claude Code 在用一种很朴素但很有效的办法,维持写入关键区。
8. 真正写盘前,还会再做一次确认
8.1 再次同步读取当前文件内容和编码
它会用 readFileSyncWithMetadata(fullFilePath) 重新读取当前状态,拿到:
- 当前内容
- 编码
如果文件不存在,就当成新建。
8.2 stale check 会再跑一遍
如果文件存在,它还是会再次比较:
- 当前
mtime readFileState
而且这里也保留了和 Edit 一样的 Windows 兼容逻辑:
- 如果时间戳变了,但上次是整文件读取,且内容其实没变,就允许继续
这说明 Claude Code 把 stale protection 明确放进了真正写入阶段,而不是只放在表面校验里。
9. Write 最关键的一个设计点:它不会偷偷保留旧换行风格
源码里有一段注释非常值得注意。
写入时它调用的是:
writeTextContent(fullFilePath, content, enc, 'LF')
这里不是说“统一写成 LF”,而是说:
- 模型传进来的
content已经明确携带了它想写的换行 - 不应该再根据旧文件风格或 repo 采样结果偷偷重写一遍
源码注释还解释了为什么:
- 以前保留旧换行风格,可能会把 bash 脚本之类的内容在 Linux 上写坏
- repo 采样还可能被二进制文件污染
这个判断非常细,也很值钱。
它说明作者已经把 Write 的语义定成了:
“完整内容替换”
而不是:
“根据宿主风格帮你做二次加工的写入”
10. 写完以后,外围系统会被一并更新
10.1 LSP 会收到 didChange / didSave
写完以后它会:
- 清掉旧诊断
changeFile(...)saveFile(...)
这说明 Write 不只是磁盘动作,还是语言服务状态同步动作。
10.2 VS Code diff 会收到新旧内容
notifyVscodeFileUpdated(fullFilePath, oldContent, content) 说明 bridge / IDE 宿主可以立刻拿到:
- 旧内容
- 新内容
10.3 readFileState 也会被刷新
写完后,它会把:
- 新内容
- 新时间戳
重新写回 readFileState。
这一步很关键,因为后续所有“先读后写”的约束都依赖这份状态。
10.4 还有 telemetry 和远程 git diff
它会记录:
- 是否写入
CLAUDE.md - 远程环境下的单文件 git diff
- 行数变化统计
这说明 Claude Code 认为整文件写入是值得重点观测的高风险动作。
11. 返回给模型的,不是全文确认,而是“操作级确认”
Write 的 outputSchema 虽然包含:
contentstructuredPatchoriginalFile
但 mapToolResultToToolResultBlockParam(...) 真正送回模型的主结果很短:
- 如果是新建:
File created successfully at: ... - 如果是更新:
The file ... has been updated successfully.
也就是说,Claude Code 在这里仍然坚持一个原则:
文件系统已经发生的事实,不等于还要把全文再塞回主 tool_result 里。
详细内容由:
- 宿主 UI
- 结构化 patch
- 后续
Read
这些渠道共同承担。
12. UI 也显式区分“创建”和“更新”
src/tools/FileWriteTool/UI.tsx 里能看出两种完全不同的展示策略:
create更像“新文件预览”,会直接展示内容片段update更像“差异展示”,走 patch / diff 语义
另外 extractSearchText() 直接返回空字符串,注释也写得很明白:
- transcript 里真正显示出来的东西和 raw
content不是一回事 - 如果直接拿
content建索引,会出现 phantom 内容
这又一次说明 Claude Code 对工具结果有很强的“宿主视图 / 模型视图 / 搜索视图”分层意识。
13. Claude Code 在 Write 上做了哪些额外处理
把这些逻辑收一下,大概可以总结成五层增强。
13.1 使用边界增强
- prompt 层要求优先用
Edit - 没明确要求就别乱建文档文件
- 现有文件必须先
Read
13.2 安全与权限增强
- write 权限
- deny rule
- UNC 防护
- team memory secret guard
13.3 并发与事实增强
- 双重 stale check
readFileState强约束- 写入关键区尽量保持无 async 穿插
13.4 宿主联动增强
- file history
- diagnostic tracker
- LSP didChange / didSave
- VS Code diff 通知
13.5 transcript 与展示增强
- create / update 区分
- 结构化 patch
- result truncation 控制
- 搜索文本和原始内容分离
这说明 Write 不是单纯“内容落盘”,而是完整编码运行时的一部分。
14. 对 C# 版的翻译建议
如果你要转成 C#,我建议把 Write 和 Edit 分成两个明确接口,不要为了省事合并成一个“文件修改服务”。
一个更稳的拆法是:
14.1 协议层
FileWriteToolDefinitionFileWriteRequestFileWriteResultFileWriteMode(Create/Update)
14.2 前置校验层
IWritePermissionServiceIWriteIntentPolicyIReadStateGuardISecretContentGuard
14.3 写入事务层
IWritableFileLoaderIFullFileWriteCoordinatorITextEncodingPreserverIFileHistoryService
14.4 宿主同步层
ILanguageServerNotifierIIdeFileSyncNotifierIWriteTelemetry
14.5 展示投影层
IWriteResultProjectorIWriteUiRendererIWriteSearchIndexer
我会特别建议你保留两条设计:
- 现有文件必须先
Read Write和Edit明确分责
这两条会直接影响整个系统后续是不是容易出现大面积误写。
15. 我对这套设计的评价
FileWriteTool 最值得学的地方,不是“整文件覆盖”本身,而是作者对这个能力的克制。
Claude Code 没把它塑造成一个随手就用的万能写入器,反而不断提醒模型:
- 先读
- 能 edit 就别 write
- 文档不要乱建
- 覆盖前确认没有人动过
这种克制非常重要。
因为在 agent coding 里,最危险的往往不是“改错一行”,而是:
拿着半旧上下文,把整份文件盖掉。
Claude Code 显然在系统层面正面应对了这个风险。