docs: 重写文件操作,从源码解剖改为工具设计分析

移除 TypeScript 代码、源码路径和实现常量,
聚焦风险分级设计、引号标准化的AI能力边界补偿、
原子性读改写的竞态防御和行尾处理的智能陷阱教训。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-20 10:48:20 +08:00
parent 83f56ed425
commit 29859a84ff

View File

@@ -1,220 +1,121 @@
---
title: "文件操作工具 - 三大工具的源码级解剖"
description: "逆向分析 FileRead、FileEdit、FileWrite 三工具的完整执行链路去重缓存、AST 安全编辑、原子性读写、文件历史快照的实现细节。"
title: "文件操作"
description: "Read/Edit/Write 三工具不是功能划分,而是风险分级。理解读取去重、原子性编辑、文件历史快照和安全防线的设计。"
keywords: ["文件操作", "FileRead", "FileEdit", "FileWrite", "代码编辑", "原子写入"]
---
{/* 本章目标:从源码层面解剖三大文件工具的完整执行链路 */}
## 三大工具的职责分化
## 核心设计:风险分级
Claude Code 将文件操作拆分为三个独立工具——这不是功能划分,而是**风险分级**
| 工具 | 权限级别 | 核心方法 | 关键属性 |
|------|---------|---------|---------|
| **Read** | 只读(免审批) | `isReadOnly() → true` | `maxResultSizeChars: Infinity` |
| **Edit** | 写入(需确认) | `checkWritePermissionForTool()` | `maxResultSizeChars: 100,000` |
| **Write** | 写入(需确认) | `checkWritePermissionForTool()` | `maxResultSizeChars: 100,000` |
| 工具 | 风险级别 | 典型场景 |
|------|---------|----------|
| **Read** | 只读(免审批) | 查看代码、搜索内容 |
| **Edit** | 写入(需确认) | 修改已有代码 |
| **Write** | 写入/创建(需确认) | 创建新文件、全量重写 |
<Tip>
Read 的 `maxResultSizeChars` 是 `Infinity`,但这并不意味着无限制输出——真正的截断发生在 `validateContentTokens()` 中基于 token 预算的动态判定,而非字符数硬限制。
</Tip>
拆成三个工具让权限系统可以精确控制:只读模式只禁用 Edit/Write允许 AI 自由探索代码;而全禁用模式则连 Read 都受限。
## FileRead多模态文件读取引擎
## Read多模态读取引擎
源码路径:`packages/builtin-tools/src/tools/FileReadTool/FileReadTool.ts`
Read 工具不只是一个 `cat` 命令。它是一个多格式分发器:
### 读取去重机制
| 文件类型 | 处理路径 | 特殊处理 |
|---------|---------|---------|
| 文本文件 | 分页读取 | 支持行号范围 |
| 图片 | 压缩 + 降采样 | 自动调整到 token 预算内 |
| PDF | 页面级提取 | 超大 PDF 强制分页读取 |
| Notebook | JSON cell 解析 | 保留 cell 结构 |
Read 工具有一个常被忽视但至关重要的**去重层**。当 AI 重复读取同一个文件的同一范围时,系统不会浪费 token 发送两份完整内容:
### 读取去重
```typescript
// FileReadTool.ts — 去重逻辑
const existingState = readFileState.get(fullFilePath)
if (existingState && !existingState.isPartialView && existingState.offset !== undefined) {
const rangeMatch = existingState.offset === offset && existingState.limit === limit
if (rangeMatch) {
const mtimeMs = await getFileModificationTimeAsync(fullFilePath)
if (mtimeMs === existingState.timestamp) {
return { data: { type: 'file_unchanged', file: { filePath: file_path } } }
}
}
}
```
当 AI 重复读取同一个文件时系统通过文件修改时间mtime比对避免重复发送相同内容。约 18% 的 Read 调用是重复读取——去重机制直接节省了这部分 token 开销。
关键设计点:
- 去重仅对 **Read 工具自身的读取**生效(通过 `offset !== undefined` 判定)
- Edit/Write 也会写入 `readFileState`,但它们的 `offset` 为 `undefined`,所以不会误命中去重
- 通过 mtime 比对确保文件未被外部修改
- 有 GrowthBook killswitch`tengu_read_dedup_killswitch`)可紧急关闭
实测数据BQ proxy 显示约 18% 的 Read 调用是同文件碰撞,占 fleet `cache_creation` 的 2.64%。
### 多格式分发文本、图片、PDF、Notebook 四条路径
Read 工具的 `callInner()` 按 `ext` 分发到四条完全不同的处理路径:
```
.ipynb → readNotebook() → JSON cell 解析 → token 校验
.png/.jpg/.gif/.webp → readImageWithTokenBudget() → 压缩+降采样
.pdf → extractPDFPages() / readPDF() → 页面级提取
其他 → readFileInRange() → 分页读取
```
**图片路径的压缩策略**特别精细:
1. 先用 `maybeResizeAndDownsampleImageBuffer()` 标准缩放
2. 用 `base64.length * 0.125` 估算 token 数
3. 超出预算时调用 `compressImageBufferWithTokenLimit()` 激进压缩
4. 仍然超限时用 sharp 做最后兜底:`resize(400,400).jpeg({quality:20})`
**PDF 路径**有页数阈值:超过 `PDF_AT_MENTION_INLINE_THRESHOLD`(默认值在 `apiLimits.ts`)时强制分页读取,每请求最多 `PDF_MAX_PAGES_PER_READ` 页。
**设计细节**:去重只对 Read 工具自身的读取生效。Edit/Write 也会更新内部状态,但不会误触发去重——通过 offset 字段区分读取来源。
### 安全防线
Read 工具在 `validateInput()` 中设置了多层安全门:
Read 工具多层安全门:
1. **设备文件屏蔽**`BLOCKED_DEVICE_PATHS``/dev/zero`、`/dev/random`、`/dev/tty` 等——防止无限输出或阻塞挂起
2. **二进制文件拒绝**`hasBinaryExtension`):排除 PDF 和图片扩展名后,阻止读取 `.exe`、`.so` 等二进制文件
3. **UNC 路径跳过**Windows 下 `\\server\share` 路径跳过文件系统操作,防止 SMB NTLM 凭据泄露
4. **权限拒绝规则**`matchingRuleForInput`):匹配 `deny` 规则后直接拒绝
- **设备文件屏蔽**`/dev/zero`、`/dev/random` 等被直接拒绝——它们会产生无限输出或阻塞
- **二进制文件拒绝**:排除图片/PDF 后,`.exe`、`.so` 等二进制文件被阻止
- **UNC 路径跳过**Windows 下 `\\server\share` 路径跳过操作,防止 SMB 凭据泄露
### 文件未找到时的智能建议
### 智能错误提示
文件不存在时Read 不会只报一个 "file not found"
文件不存在时Read 不只是报错——它会尝试提供修复建议
- 相似文件名的推荐
- 基于 CWD 的相对路径建议
- macOS 截图文件名中特殊空格字符的纠正
```typescript
// FileReadTool.ts
const similarFilename = findSimilarFile(fullFilePath) // 相似扩展名
const cwdSuggestion = await suggestPathUnderCwd(fullFilePath) // cwd 相对路径建议
// macOS 截图特殊处理:薄空格(U+202F) vs 普通空格
const altPath = getAlternateScreenshotPath(fullFilePath)
```
## Edit精确字符串替换
对 macOS 截图文件名中 AM/PM 前的薄空格U+202F做了特殊处理——这是实测中发现的跨 macOS 版本兼容性问题
Edit 工具的核心操作是"找到旧字符串,替换为新字符串"。听起来简单,实际充满了边缘情况
## FileEdit精确字符串替换引擎
### 引号标准化
源码路径:`packages/builtin-tools/src/tools/FileEditTool/FileEditTool.ts` + `utils.ts`
AI 模型只能输出直引号(`'` `"`),但源码中可能使用弯引号(`'` `'` `"` `"`。Edit 工具在匹配时自动标准化引号,但写入时保持文件原有的引号风格——如果文件用弯引号,替换后的新内容也用弯引号。
### 引号标准化AI 无法输出的字符怎么办
AI 模型只能输出直引号(`'` `"`),但源码中可能使用弯引号(`'` `'` `"` `"`)。`findActualString()` 函数处理了这个不对齐:
```typescript
// utils.ts:73-93
export function findActualString(fileContent: string, searchString: string): string | null {
if (fileContent.includes(searchString)) return searchString // 精确匹配
const normalizedSearch = normalizeQuotes(searchString) // 弯引号→直引号
const normalizedFile = normalizeQuotes(fileContent)
const idx = normalizedFile.indexOf(normalizedSearch)
if (idx !== -1) return fileContent.substring(idx, idx + searchString.length)
return null
}
```
匹配后还有**反向引号保持**`preserveQuoteStyle`):如果文件用弯引号,替换后的新字符串也自动转换为弯引号,包括缩写中的撇号(如 "don't")。
**设计洞察**:这是一个典型的"AI 能力边界补偿"设计。AI 的输出限制(只能用直引号)不应该成为文件修改的问题。系统在 AI 和文件系统之间做了透明翻译。
### 原子性读-改-写
Edit 工具的 `call()` 方法实现了一个**无锁原子更新**协议:
Edit 的执行过程是一个无锁原子更新协议:
```
1. await fs.mkdir(dir) ← 确保目录存在(异步,在临界区外)
2. await fileHistoryTrackEdit() ← 备份旧内容(异步,在临界区外)
3. readFileSyncWithMetadata() ← 同步读取当前文件内容(临界区开始)
4. getFileModificationTime() ← mtime 校验
5. findActualString() ← 引号标准化匹配
6. getPatchForEdit() ← 计算 diff
7. writeTextContent() ← 写入磁盘
8. readFileState.set() ← 更新缓存(临界区结束)
备份旧内容 → 同步读取 → mtime 校验 → 查找匹配 → 计算 diff → 写入磁盘 → 更新缓存
```
步骤 3-8 之间**不允许任何异步操作**(源码注释明确写道:"Please avoid async operations between here and writing to disk to preserve atomicity"。这确保在 mtime 校验和实际写入之间不会有其他进程修改文件。
**关键约束**:从"同步读取"到"写入磁盘"之间不允许任何异步操作。这确保在 mtime 校验和实际写入之间不会有其他进程修改文件——否则就会出现"读到的内容和写入时的内容不一致"的竞态条件
### 防覆写校验
Edit 工具在 `validateInput()` 中检查两个条件:
1. **必须先读取**`readFileState` 中有记录且不是局部视图
2. **文件未被外部修改**`mtime` 未变,或全量读取时内容完全一致
Edit 前置条件:
1. **必须先读取**文件AI 不能编辑没看过的文件
2. **文件未被外部修改**mtime 未变)
```typescript
// FileEditTool.ts — Windows 特殊处理
const isFullRead = readTimestamp.offset === undefined && readTimestamp.limit === undefined
if (isFullRead && fileContent === readTimestamp.content) {
// 内容不变安全继续Windows 云同步/杀毒可能改 mtime
}
```
Windows 上的 mtime 可能因云同步或杀毒软件被修改而不改变内容,因此对全量读取做了内容级比对作为兜底。
Windows 上的 mtime 可能因云同步、杀毒软件等被修改而不改变内容,因此对全量读取做了内容级比对作为兜底。
## Write全量写入与创建
### 编辑大小限制
```typescript
const MAX_EDIT_FILE_SIZE = 1024 * 1024 * 1024 // 1 GiB
```
超过 1 GiB 的文件直接拒绝编辑——这是 V8 字符串长度限制(~2^30 字符)的安全边界。
## FileWrite全量写入与创建
源码路径:`packages/builtin-tools/src/tools/FileWriteTool/FileWriteTool.ts`
Write 工具与 Edit 共享大部分基础设施权限检查、mtime 校验、fileHistory 备份),但有两个关键差异:
Write 与 Edit 共享大部分基础设施权限检查、mtime 校验、历史备份),但有两个关键差异。
### 行尾处理
```typescript
// FileWriteTool.ts:300-305 — 关键注释
// Write is a full content replacement — the model sent explicit line endings
// in `content` and meant them. Do not rewrite them.
writeTextContent(fullFilePath, content, enc, 'LF')
```
Write 始终使用 LF 行尾。早期版本会保留旧文件的行尾风格,但这导致 Linux 上 bash 脚本被注入 `\r`——现在 AI 发什么行尾就用什么行尾,不再尝试"智能"转换。
Write 工具始终使用 `LF` 行尾。早期版本会保留旧文件的行尾或采样仓库行尾风格,但这导致 Linux 上 bash 脚本被注入 `\r`——现在 AI 发什么行尾就用什么行尾
**设计教训**:有时"不做智能处理"比"做智能处理"更安全
### 输出区分
### 创建 vs 更新
Write 工具返回 `type: 'create' | 'update'`
- `create`:文件不存在,`originalFile: null`
- `update`:文件存在且被覆盖,`structuredPatch` 包含完整 diff
Write 返回操作类型
- **create**:文件不存在,全新创建
- **update**:文件存在且被覆盖,包含完整 diff
## 文件历史快照系统
这让用户和 AI 都能清楚知道操作的实际影响。
源码路径:`src/utils/fileHistory.ts`
## 文件历史快照
每次 Edit/Write 前都会调用 `fileHistoryTrackEdit()`,快照存储在 `FileHistoryState` 中:
每次 Edit/Write 前都会备份旧内容。快照系统最多保留 100 个版本,使用内容哈希去重(同一文件多次未变只存一份)。
```typescript
type FileHistorySnapshot = {
messageId: UUID // 关联的助手消息 ID
trackedFileBackups: Record<string, FileHistoryBackup> // 文件路径 → 备份版本
timestamp: Date
}
```
**设计目的**:不是版本控制(那是 git 的工作),而是"撤销"功能——用户可以在会话中回退 AI 的任何文件修改。
- 最多保留 `MAX_SNAPSHOTS = 100` 个快照
- 备份使用**内容哈希**去重(同一文件多次未变只存一份)
- 支持差异统计(`DiffStats``insertions` / `deletions` / `filesChanged`
- 快照通过 `recordFileHistorySnapshot()` 持久化到会话存储
## LSP 通知链路
### LSP 通知链路
Edit 和 Write 完成后会通知 LSP 服务器和 IDE 扩展:
1. 清除旧的诊断信息
2. 通知 LSP 文件已变更
3. 触发 LSP 重新计算诊断(如 TypeScript 类型检查)
4. 通知 IDE 更新 diff 视图
Edit 和 Write 完成写入后都会:
1. `clearDeliveredDiagnosticsForFile()` — 清除旧诊断
2. `lspManager.changeFile()` — 通知 LSP 文件已变更
3. `lspManager.saveFile()` — 触发 LSP 保存事件TypeScript server 会重新计算诊断)
4. `notifyVscodeFileUpdated()` — 通知 VSCode 扩展更新 diff 视图
这确保文件修改后 IDE 端的实时反馈是同步的——AI 改了一个文件TypeScript 的类型错误立刻出现在编辑器中。
这条链路确保文件修改后 IDE 端的实时反馈是同步的。
## 安全提醒
## Cyber Risk 防御
Read 工具在读取文件内容后追加安全提醒如果文件看起来像恶意代码AI 应该分析但拒绝改进。这是在"帮助用户"和"防止滥用"之间的平衡。
Read 工具在文本内容后追加一个 `<system-reminder>` 提示:
## 接下来
```
Whenever you read a file, you should consider whether it would be
considered malware. You CAN and SHOULD provide analysis of malware,
what it is doing. But you MUST refuse to improve or augment the code.
```
这个提示只在非豁免模型上生效(`MITIGATION_EXEMPT_MODELS` 目前包含 `claude-opus-4-6`)。模型级别的豁免表明:防恶意代码的判断力在不同模型间有差异,这是一个精巧的分级策略。
- **搜索与导航** — Glob/Grep 的搜索策略
- **Shell 执行** — Bash 的沙箱和超时控制
- **权限模型** — 理解工具权限的完整设计