Book 第九章:Write 为什么更像受控覆盖
第三部分:本地执行面

第九章:Write 为什么更像受控覆盖

拆 Write 这条高风险整文件覆盖链路,以及 Claude Code 为它补上的保护措施。

1. 为什么 Write 不能和 Edit 混着看

从表面看,Write 很简单:

  • 给一个路径
  • 给一段内容
  • 整个写进去

但在 Claude Code 里,Write 并不是 Edit 的简化版。

恰恰相反,它是一种风险更高的协议,因为它做的是:

整文件覆盖。

这意味着它必须解决的问题反而更重:

  1. 什么时候该用 Edit,什么时候必须用 Write
  2. 覆盖已有文件之前,怎么确认模型基于最新事实
  3. 全量改写后,怎么把影响同步给宿主系统

所以 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:94
  • src/tools/FileWriteTool/FileWriteTool.ts:119
  • src/tools/FileWriteTool/FileWriteTool.ts:125
  • src/tools/FileWriteTool/FileWriteTool.ts:135
  • src/tools/FileWriteTool/FileWriteTool.ts:153
  • src/tools/FileWriteTool/FileWriteTool.ts:198
  • src/tools/FileWriteTool/FileWriteTool.ts:223
  • src/tools/FileWriteTool/FileWriteTool.ts:281
  • src/tools/FileWriteTool/FileWriteTool.ts:305
  • src/tools/FileWriteTool/FileWriteTool.ts:329
  • src/tools/FileWriteTool/FileWriteTool.ts:332
  • src/tools/FileWriteTool/FileWriteTool.ts:418
  • src/tools/FileWriteTool/prompt.ts:7
  • src/tools/FileWriteTool/UI.tsx:130
  • src/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 的真实定位:

  1. 它不是默认编辑工具
  2. 它不是文档生产工具
  3. 它主要服务于新文件创建和完整重写

这非常重要,因为很多 agent 系统一不小心就会让“写文件”变成默认动作,最后整个代码库都是大面积重写。

Claude Code 显然在刻意避免这件事。

6. 输入校验阶段已经把一批风险挡在外面

6.1 路径先规范化

ReadEdit 一样,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 发现

WriteReadEdit 一样,会根据路径:

  • 发现 skill 目录
  • 触发动态加载
  • 激活条件 skill

这再次说明 Claude Code 把“你正在处理哪个文件”视为重要上下文信号。

7.2 写之前先接通外围系统

它在真正写盘前先做:

  • diagnosticTracker.beforeFileEdited(...)
  • fileHistoryTrackEdit(...)

这说明 Write 虽然是整文件覆盖,但它仍然被纳入和 Edit 一样的历史与诊断链路。

7.3 目录创建和 file history 都刻意放在关键区外

源码注释强调得很明显:

  • mkdir
  • fileHistoryTrackEdit

这些 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. 返回给模型的,不是全文确认,而是“操作级确认”

WriteoutputSchema 虽然包含:

  • content
  • structuredPatch
  • originalFile

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#,我建议把 WriteEdit 分成两个明确接口,不要为了省事合并成一个“文件修改服务”。

一个更稳的拆法是:

14.1 协议层

  • FileWriteToolDefinition
  • FileWriteRequest
  • FileWriteResult
  • FileWriteMode (Create / Update)

14.2 前置校验层

  • IWritePermissionService
  • IWriteIntentPolicy
  • IReadStateGuard
  • ISecretContentGuard

14.3 写入事务层

  • IWritableFileLoader
  • IFullFileWriteCoordinator
  • ITextEncodingPreserver
  • IFileHistoryService

14.4 宿主同步层

  • ILanguageServerNotifier
  • IIdeFileSyncNotifier
  • IWriteTelemetry

14.5 展示投影层

  • IWriteResultProjector
  • IWriteUiRenderer
  • IWriteSearchIndexer

我会特别建议你保留两条设计:

  1. 现有文件必须先 Read
  2. WriteEdit 明确分责

这两条会直接影响整个系统后续是不是容易出现大面积误写。

15. 我对这套设计的评价

FileWriteTool 最值得学的地方,不是“整文件覆盖”本身,而是作者对这个能力的克制。

Claude Code 没把它塑造成一个随手就用的万能写入器,反而不断提醒模型:

  • 先读
  • 能 edit 就别 write
  • 文档不要乱建
  • 覆盖前确认没有人动过

这种克制非常重要。

因为在 agent coding 里,最危险的往往不是“改错一行”,而是:

拿着半旧上下文,把整份文件盖掉。

Claude Code 显然在系统层面正面应对了这个风险。