Skip to content

FileEditTool 深度解读 —— 让 Agent 安全修改代码的工程实践

文件编辑是 Agent 最常见也最危险的操作之一。Claude Code 的文件操作系统不仅实现了编辑功能,更重要的是建立了一套防止错误编辑的安全模型

文件操作工具族:
┌──────────────────────────────────────────────────────┐
│ │
│ FileReadTool → 读取文件(支持文本/图片/PDF/Notebook)│
│ ↓ 建立 readFileState │
│ FileEditTool → 差异化编辑(old_string → new_string)│
│ ↓ 或 │
│ FileWriteTool → 全量写入(创建新文件或完全覆盖) │
│ ↓ 或 │
│ NotebookEditTool → Jupyter 单元格编辑 │
│ │
│ 辅助工具: │
│ GlobTool → 文件名搜索(glob 模式) │
│ GrepTool → 文件内容搜索(ripgrep 正则) │
│ │
└──────────────────────────────────────────────────────┘

关键文件tools/FileEditTool/

const inputSchema = z.object({
file_path: z.string(), // 绝对路径
old_string: z.string(), // 要替换的文本
new_string: z.string(), // 替换后的文本(必须与 old_string 不同)
replace_all: z.boolean() // 是否替换所有出现(默认 false)
.default(false),
})
Step 1: 精确匹配
fileContent.includes(old_string) → 找到 → 使用
Step 2: 引号规范化匹配(精确匹配失败时)
将 ' ' " " (弯引号) → ' " (直引号)
在规范化后的内容中匹配
→ 找到 → 返回文件中的原始子串(保留弯引号)
Step 3: 反转义匹配(规范化也失败时)
将 <fnr> → <function_results> 等 XML 标签还原
处理模型输出中的转义字符
Step 4: 唯一性验证
matches = file.split(actualOldString).length - 1
if (matches > 1 && !replace_all) → 拒绝(Error 9)
if (matches === 0) → 拒绝(Error 8)
// 当通过引号规范化找到匹配时,保留文件原有的引号风格
preserveQuoteStyle(newString, originalQuotes) {
// 判断上下文决定使用左引号还是右引号:
// - 前面是空白/句首/开括号 → 左引号 (')
// - 否则 → 右引号 (')
// - 两边都是字母 → 撇号(don't, it's)
}
Error条件含义
0检测到团队记忆中的 secret安全风险
1old_string === new_string无变化
2文件在被拒绝的权限目录中权限不足
3文件已存在但 old_string 为空应该用 Edit 而不是创建
4文件不存在但 old_string 非空文件找不到
5是 .ipynb 文件应该用 NotebookEditTool
6文件未被读取过必须先 Read
7文件自读取后被修改过期内容
8old_string 在文件中找不到匹配失败
9多处匹配但未设 replace_all歧义
10文件超过 1 GiB太大
call(input)
├─ 1. 创建父目录(如果不存在)
│ fs.mkdir(dirname(filePath))
├─ 2. 读取当前文件内容
│ const content = readFile(filePath)
├─ 3. 再次校验时间戳(防止竞态)
│ if (mtime > readTimestamp) → 错误
├─ 4. 查找实际匹配串
│ findActualString(content, old_string)
│ → 精确匹配 → 引号规范化 → 反转义
├─ 5. 保留引号风格
│ preserveQuoteStyle(new_string, matchedQuotes)
├─ 6. 执行替换
│ content.replace(actualOldString, styledNewString)
│ (或 replaceAll 如果 replace_all=true)
├─ 7. 生成 Patch
│ structuredPatch(oldContent, newContent)
│ → 带超时保护 (DIFF_TIMEOUT_MS)
├─ 8. 写入文件
│ writeTextContent(filePath, updatedContent, encoding, lineEndings)
│ → 保留原始编码(UTF-8 或 UTF-16LE)
│ → 保留原始换行符(LF 或 CRLF)
├─ 9. 通知 LSP
│ lspManager.changeFile() → 触发诊断更新
│ lspManager.saveFile()
├─ 10. 通知 VS Code
│ notifyVscodeFileUpdated(filePath, old, new)
├─ 11. 更新 readFileState
│ readFileState.set(filePath, { content: updated, timestamp: now })
└─ 12. 返回 Patch 结果
{ structuredPatch, type: 'update' }

2. 文件状态缓存 —— 「先读后改」的基石

Section titled “2. 文件状态缓存 —— 「先读后改」的基石”

关键文件utils/fileStateCache.ts

type FileState = {
content: string // 文件完整内容(CRLF → LF 规范化)
timestamp: number // 文件 mtime(毫秒)
offset: number | undefined // 读取起始行
limit: number | undefined // 读取行数
isPartialView?: boolean // 是否为注入内容(非磁盘真实内容)
}
// LRU 缓存
const cache = new LRUCache<string, FileState>({
maxEntries: 100, // 最多 100 个文件
maxSizeBytes: 25_000_000, // 最大 25 MB
sizeCalculation: (entry) => Buffer.byteLength(entry.content)
})
模型尝试编辑 foo.ts:
检查 readFileState.get('foo.ts')
├─ 不存在 → 拒绝(Error 6: "File has not been read yet")
├─ isPartialView = true → 拒绝(注入内容不是真实文件)
└─ 存在 → 继续
检查时间戳
├─ file.mtime > readTimestamp → 拒绝(Error 7: "File modified since read")
│ (Windows 回退: 比较内容哈希,因为云同步/杀毒软件可能改 mtime)
└─ 时间戳一致 → 允许编辑
CLAUDE.md 注入时:
- 原始内容中有 HTML 注释被剥离
- 注入的内容与磁盘不一致
→ isPartialView = true
→ 模型必须显式 Read 才能编辑
MEMORY.md 截断时:
- 超过大小限制被截断
→ isPartialView = true
操作offsetlimit说明
FileReadTool设置设置记录读取范围
FileEditTool (成功后)undefinedundefined标记为完整视图
FileWriteTool (成功后)undefinedundefined标记为完整视图

关键文件tools/FileWriteTool/

维度FileEditToolFileWriteTool
操作方式查找替换全量覆盖
新文件不支持支持(无需先 Read)
已有文件需要先 Read需要先 Read
换行符保留原始统一 LF
编码保留原始保留原始
输出Patch diff完整 diff
Error条件
0检测到 secret
1文件在被拒绝的权限目录中
2已有文件未被读取过
3文件自读取后被修改

4. FileReadTool —— 多格式文件读取

Section titled “4. FileReadTool —— 多格式文件读取”

关键文件tools/FileReadTool/

const inputSchema = z.object({
file_path: z.string(), // 绝对路径
offset: z.number().optional(), // 起始行号(1-based)
limit: z.number().optional(), // 读取行数
pages: z.string().optional(), // PDF 页码范围(如 "1-5")
})
文件类型判断:
├─ .png/.jpg/.gif/.webp → 图片模式
│ └─ 缩放 + Token 限制 + 坐标映射
├─ .pdf → PDF 模式
│ └─ 必须指定 pages(大文件)
│ └─ 最大 PDF_MAX_PAGES_PER_READ 页/次
├─ .ipynb → Notebook 模式
│ └─ 解析 JSON → 返回 cells 数组
└─ 其他 → 文本模式
└─ cat -n 格式(行号 + 内容)
// 防止读取会导致挂起或无限输出的设备文件
BLOCKED_DEVICE_PATHS = [
'/dev/zero', // 无限零字节
'/dev/random', // 无限随机
'/dev/stdin', // 阻塞等待输入
'/dev/tty', // 终端
'/proc/*/fd/0', // Linux stdio 别名
// ... 更多
]
// 相同文件、相同范围、未被修改 → 返回 stub
if (同一文件 && 同一 offset/limit && mtime 未变) {
return { type: 'file_unchanged' }
// 避免重复读取浪费 Token
}
// macOS 截图文件名中 AM/PM 前可能是空格或 thin-space (U+202F)
// "Screenshot 2024-01-01 at 10.30.00 AM.png" ← 空格
// "Screenshot 2024-01-01 at 10.30.00\u202FAM.png" ← thin-space
getAlternateScreenshotPath(path) // 尝试替代路径

const DANGEROUS_DIRECTORIES = [
'.git', // 版本控制(修改可能破坏仓库)
'.vscode', // VS Code 设置
'.idea', // JetBrains 设置
'.claude', // Claude Code 配置
]
const DANGEROUS_FILES = [
'.gitconfig', '.gitmodules',
'.bashrc', '.bash_profile', // Shell 配置(可能注入命令)
'.zshrc', '.zprofile', '.profile',
'.ripgreprc',
'.mcp.json', '.claude.json',
]
写入操作:
├─ 检查 deny 规则(按路径模式匹配)
├─ 检查 allow 规则
├─ 检查是否为受保护目录/文件
│ ├─ .git/ → 始终询问(即使 bypass 模式)
│ ├─ .claude/settings.json → 始终询问
│ └─ .bashrc → 始终询问
└─ 默认 → 按权限模式处理
读取操作:
├─ 检查 deny 规则
└─ 其他情况 → 放行
// 防止通过大小写绕过保护
normalizeCaseForComparison('.cLauDe/Settings.json')
'.claude/settings.json' // 统一小写后匹配
// 防止 SMB 凭据泄露
if (path.startsWith('\\\\') || path.startsWith('//')) {
// 不调用 fs.existsSync()(会触发 SMB 认证)
// 直接交给权限系统处理
}
// 允许精确到单个 Skill 目录的权限授予
getClaudeSkillScope('.claude/skills/my-skill/prompt.md')
→ { skillName: 'my-skill', pattern: '/.claude/skills/my-skill/**' }
// 用户可以只授权编辑特定 Skill,而不是整个 .claude/ 目录

// BOM 检测
const encoding = (buffer[0] === 0xff && buffer[1] === 0xfe)
? 'utf16le' // UTF-16 LE (Windows 常见)
: 'utf8' // 默认 UTF-8
// 编辑/写入时使用相同编码
writeTextContent(path, content, encoding, lineEndings)
// FileEditTool: 检测并保留原始换行符
const lineEndings = detectLineEndings(originalContent)
// 'LF' (\n) 或 'CRLF' (\r\n)
// FileWriteTool: 统一使用 LF
writeTextContent(path, content, encoding, 'LF')

input: { pattern: string, path?: string }
output: { durationMs, numFiles, filenames[], truncated }
特点:
- 尊重 .gitignore(通过 ignore 库)
- 结果按修改时间排序
- 最多返回 100 个文件
- 并发安全(只读)
input: {
pattern: string, // ripgrep 正则
path?: string,
glob?: string, // 文件过滤(如 "*.ts")
output_mode: 'content' | 'files_with_matches' | 'count',
'-A'?, '-B'?, '-C'?, // 上下文行
'-i'?, // 大小写不敏感
multiline?: boolean, // 跨行匹配
head_limit?: number, // 结果限制(默认 250)
offset?: number, // 分页偏移
}
特点:
- 底层使用 ripgrep(rg CLI
- 自动排除 VCS 目录(.git/.svn/.hg 等)
- head_limit 默认 250 行(防止上下文膨胀)
- 支持分页(offset + head_limit)

关键文件tools/NotebookEditTool/

input: {
notebook_path: string,
cell_id?: string, // 目标单元格
new_source: string, // 新内容
cell_type?: 'code' | 'markdown', // 插入时必填
edit_mode?: 'replace' | 'insert' | 'delete',
}
操作模式:
replace → 替换单元格内容
insert → 在指定单元格后插入新单元格
delete → 删除单元格
验证:
- 必须是 .ipynb 文件
- 必须先读取(同 FileEditTool)
- 时间戳检查(同 FileEditTool)
- insert 模式必须指定 cell_type

9. 架构洞察:文件操作的安全工程

Section titled “9. 架构洞察:文件操作的安全工程”

模式 1: 先读后改(Read-Before-Edit)

Section titled “模式 1: 先读后改(Read-Before-Edit)”
这是整个文件安全模型的基石:
- 模型必须「看到」文件内容才能编辑
- 防止盲目编辑(如编辑一个不存在的函数)
- 时间戳检查防止竞态(文件在读取后被外部修改)
- isPartialView 防止基于不完整信息做编辑

工程启示:在 Agent 系统中,任何修改操作都应该有「前置感知」步骤,确保 Agent 基于最新、完整的信息做决策。

为什么 replace_all 默认为 false?
- 强制模型提供足够的上下文(larger old_string)
- 防止「意外批量替换」(如把所有 `x` 替换为 `y`)
- 精确编辑比批量编辑更安全

工程启示:限制操作的「爆炸半径」是安全设计的核心原则。

常见的 Agent 编辑 Bug:
- 把 CRLF 文件全部改成 LF → Windows 项目 diff 爆炸
- 把 UTF-16LE 文件当 UTF-8 写入 → 乱码
- 把弯引号替换成直引号 → 文档风格不一致
Claude Code 的做法:
- 检测并保留原始编码
- 检测并保留原始换行符
- 检测并保留引号风格
→ 编辑后文件只有 old_string → new_string 的变化,没有副作用

工程启示:Agent 的文件编辑应该是「最小侵入性」的 —— 只改必须改的,不引入任何副作用。

每次编辑都生成 structuredPatch:
- 用户可以在 UI 中看到精确的 diff
- 知道模型改了什么、改了多少行
- 如果编辑错误,可以基于 patch 撤回
这与 "直接覆盖文件" 的做法形成鲜明对比:
- 覆盖: 用户不知道改了什么
- Patch: 用户可以审查每一行变更

工程启示:Agent 的所有修改操作都应该是可审计、可回滚的。

即使 permissionMode = 'bypass':
- .git/ → 必须询问
- .bashrc → 必须询问
- .claude/settings.json → 必须询问
这些路径被修改的后果太严重:
- .git/ → 破坏版本历史
- .bashrc → 注入恶意命令
- settings.json → 修改 Agent 自身的权限规则

工程启示:安全关键路径不应该有任何绕过方式,即使用户显式请求 bypass。