Claude code学习12 FileEditTool
FileEditTool 的防线
FileEditTool的核心不是覆盖写入,而是在写入前证明模型掌握当前文件状态。LLM 很容易基于训练记忆、旧上下文或错误行号生成看似合理的补丁;工具 Harness 必须把这种概率性判断挡在物理文件系统之外。
编辑请求必须绑定当前文件快照。系统要求当前会话存在对应的 readFileState;如果模型没有在本轮读过目标文件,或者只读过局部视图,就不能声称知道应该改哪一处。
读后写入还要比较 mtime 与实际内容。如果用户、formatter、linter 或其他 Agent 在读取后改过文件,工具会要求重新读取,避免把旧快照写回去造成并发脏写。
FileEditTool 的核心理念是“精确替换“而非“全文重写“——通过 old_string 和 new_string 实现对文件的最小化修改
old_string 必须在当前文件中精确且唯一地匹配。匹配不到或匹配多处时,工具要求提供更多上下文;智能引号规范化只用于容忍排版字符差异,不允许模型靠猜测选择目标位置。底层替换还会把新文本作为函数返回值传入,避免 $1、$& 等正则替换元语义污染模型生成的内容。
// src/tools/FileEditTool/types.ts
const inputSchema = lazySchema(() =>
z.strictObject({
file_path: z.string().describe("The absolute path to the file to modify"),
old_string: z.string().describe("The text to replace"),
new_string: z.string().describe("The text to replace it with"),
replace_all: z.boolean().default(false).describe("Replace all occurrences of old_string"),
})
);
唯一性校验。 当 replace_all 为 false 时,old_string 必须在文件中唯一出现。如果有多个匹配,工具会报错要求提供更多上下文来消除歧义。
// In its own file to avoid circular dependencies
export const FILE_EDIT_TOOL_NAME = "Edit";
// Permission pattern for granting session-level access to the project's .claude/ folder
export const CLAUDE_FOLDER_PERMISSION_PATTERN = "/.claude/**";
// Permission pattern for granting session-level access to the global ~/.claude/ folder
export const GLOBAL_CLAUDE_FOLDER_PERMISSION_PATTERN = "~/.claude/**";
export const FILE_UNEXPECTEDLY_MODIFIED_ERROR = "File has been unexpectedly modified. Read it again before attempting to write it.";
并发修改检测。 利用 readFileState(FileStateCache)跟踪文件的最后读取时间戳。如果文件在上次读取后被外部修改,编辑会被拒绝
async checkPermissions(input, context): Promise<PermissionDecision> {
const appState = context.getAppState()
return checkWritePermissionForTool(
FileEditTool,
input,
appState.toolPermissionContext,
)
},
const readTimestamp = toolUseContext.readFileState.get(fullFilePath)
if (!readTimestamp || readTimestamp.isPartialView) {
return {
result: false,
behavior: 'ask',
message:
'File has not been read yet. Read it first before writing to it.',
meta: {
isFilePathAbsolute: String(isAbsolute(file_path)),
},
errorCode: 6,
}
}
整体的流程如下
- 检查是否读取文件
- 检查文件是否被修改
- 有没有找到字符串
- 字符串匹配的是不是唯一的
const readTimestamp = toolUseContext.readFileState.get(fullFilePath)
if (!readTimestamp || readTimestamp.isPartialView) {
return {
result: false,
behavior: 'ask',
message:
'File has not been read yet. Read it first before writing to it.',
meta: {
isFilePathAbsolute: String(isAbsolute(file_path)),
},
errorCode: 6,
}
}
// Check if file exists and get its last modified time
if (readTimestamp) {
const lastWriteTime = getFileModificationTime(fullFilePath)
if (lastWriteTime > readTimestamp.timestamp) {
// Timestamp indicates modification, but on Windows timestamps can change
// without content changes (cloud sync, antivirus, etc.). For full reads,
// compare content as a fallback to avoid false positives.
const isFullRead =
readTimestamp.offset === undefined &&
readTimestamp.limit === undefined
if (isFullRead && fileContent === readTimestamp.content) {
// Content unchanged, safe to proceed
} else {
return {
result: false,
behavior: 'ask',
message:
'File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.',
errorCode: 7,
}
}
}
}
const file = fileContent
// Use findActualString to handle quote normalization
const actualOldString = findActualString(file, old_string)
if (!actualOldString) {
return {
result: false,
behavior: 'ask',
message: `String to replace not found in file.\nString: ${old_string}`,
meta: {
isFilePathAbsolute: String(isAbsolute(file_path)),
},
errorCode: 8,
}
}
const matches = file.split(actualOldString).length - 1
// Check if we have multiple matches but replace_all is false
if (matches > 1 && !replace_all) {
return {
result: false,
behavior: 'ask',
message: `Found ${matches} matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.\nString: ${old_string}`,
meta: {
isFilePathAbsolute: String(isAbsolute(file_path)),
actualOldString,
},
errorCode: 9,
}
}
// Additional validation for Claude settings files
const settingsValidationResult = validateInputForSettingsFileEdit(
fullFilePath,
file,
() => {
// Simulate the edit to get the final content using the exact same logic as the tool
return replace_all
? file.replaceAll(actualOldString, new_string)
: file.replace(actualOldString, new_string)
},
)
if (settingsValidationResult !== null) {
return settingsValidationResult
}
return { result: true, meta: { actualOldString } }
},
复杂工具的工具内校验和全局权限双层防线
Claude Code 执行工具时的两道安全/正确性检查防线。把它拆开来看:
- 工具内校验(validateInput):每个复杂工具自己内部的”业务正确性”检查
- 全局权限(permissions.ts + checkPermissions):整个系统统一的”是否允许做这件事”的权限检查
两者不是重复的,而是互补的。
第一道防线:validateInput —— 工具内部校验
在 src/Tool.ts:489 中,Tool 类型定义了一个可选方法:
validateInput?(
input: z.infer<Input>,
context: ToolUseContext,
): Promise<ValidationResult>
注释写得很清楚:
▎ Determines if this tool is allowed to run with this input in the current context. ▎ It informs the model of why the tool use failed, and does not directly display any UI.
它的职责是:这个输入在当前上下文中能不能运行这个工具。它关注的是:
- 输入参数是否合法(但注意:基础 schema 校验由 inputSchema.safeParse 先做了)
- 工具特定的业务规则是否满足
- 是否会导致明显的错误/危险
代码调用位置
在 src/services/tools/toolExecution.ts:683:
const isValidCall = await tool.validateInput?.(
parsedInput.data,
toolUseContext,
)
if (isValidCall?.result === false) {
// 直接返回 tool_use_error,不进入权限系统
return [...]
}
关键点:validateInput 在 checkPermissions 之前执行。如果校验失败,工具根本不会到达权限询问阶段,直接作为输入错误返回给模型。
例子 1:BashTool.validateInput
src/tools/BashTool/BashTool.tsx:524:
async validateInput(input: BashToolInput): Promise<ValidationResult> {
if (feature('MONITOR_TOOL') && !isBackgroundTasksDisabled && !input.run_in_background) {
const sleepPattern = detectBlockedSleepPattern(input.command);
if (sleepPattern !== null) {
return {
result: false,
message: `Blocked: ${sleepPattern}. Run blocking commands in the background...`,
errorCode: 10
};
}
}
return { result: true };
}
这里检查的是:你这个 bash 命令是不是在长时间 sleep? 如果是,就阻止,建议用 Monitor 工具或后台运行。这不是权限问题,而是”你这么用会卡住 UI”的用法问题。
例子 2:FileEditTool.validateInput
src/tools/FileEditTool/FileEditTool.ts:137:
async validateInput(input: FileEditInput, toolUseContext: ToolUseContext) {
const fullFilePath = expandPath(file_path)
// 1. 防止把 secret 写入 team memory 文件
const secretError = checkTeamMemSecrets(fullFilePath, new_string)
if (secretError) {
return { result: false, message: secretError, errorCode: 0 }
}
// 2. old_string 和 new_string 一样就没必要编辑
if (old_string === new_string) {
return { result: false, behavior: 'ask', message: 'No changes to make...', errorCode: 1 }
}
chingRuleForInput(fullFilePath, ..., 'deny')
if (denyRule !== null) {
return { result: false, behavior: 'ask', message: 'File is in a directory that is denied...', errorCode: 2 }
}
// 4. Windows UNC 路径安全跳过
if (fullFilePath.startsWith('\\\\') || fullFilePath.startsWith('//')) {
return { result: true }
}
// 5. 文件太大不能编辑
const { size } = await fs.stat(fullFilePath)
if (size > MAX_EDIT_FILE_SIZE) { ... }
// 6. 文件不存在时,old_string 必须为空
// 7. old_string 必须能在文件中找到...
}
这些都是工具特有的正确性检查:
- secret 不能乱写
- 没变化的编辑直接拒绝
- 超大文件不编辑
input: z.infer<Input>,
context: ToolUseContext,
): Promise<PermissionResult>
注释说明:
▎ Determines if the user is asked for permission. Only called after validateInput() passes. ▎ General permission logic is in permissions.ts. This method contains tool-specific logic.
所以 checkPermissions 只在 validateInput 通过后才会调用,而且它又分为两层:
- 通用权限逻辑(src/utils/permissions/permissions.ts)
- 整个工具是否被全局 deny/ask?
- auto mode 分类器
- bypass/acceptEdits 模式
- 安全路径检查
- 工具特定权限逻辑(各工具的 checkPermissions 方法)
- 例如 BashTool 的子命令规则
- FileEditTool 的写权限规则
代码调用位置
在 src/utils/permissions/permissions.ts:1119:
const parsedInput = tool.inputSchema.parse(input) toolPermissionResult = await tool.checkPermissions(parsedInput, context)
在 src/utils/permissions/permissions.ts:1113 之前,已经先走了:
- getDenyRuleForTool(全局 deny 规则)
- getAskRuleForTool(全局 ask 规则)
- 然后才到工具自己的 checkPermissions
例子:BashTool.checkPermissions
src/tools/BashTool/BashTool.tsx:539:
async checkPermissions(input, context): Promise
它会检查命令前缀、是否在沙箱中、是否匹配允许规则等。
例子:FileEditTool.checkPermissions
src/tools/FileEditTool/FileEditTool.ts:125:
async checkPermissions(input, context): Promise<PermissionDecision> {
const appState = context.getAppState()
return checkWritePermissionForTool(
FileEditTool,
input,
appState.toolPermissionContext,
)
}
它调用通用的写权限检查。
为什么要”双层防线”?
┌─────────────────────────────┬────────────────────────────────┬──────────────────────────────────┬─────────────────────┐
│ 防线 │ 关注点 │ 失败后的行为 │ 谁来决定 │
├─────────────────────────────┼────────────────────────────────┼─────────────────────────────
│ validateInput │ 这个输入能不能正确执行这个工具 │ 直接返回 tool_use_error 给模型 │ 工具自己 │
├─────────────────────────────┼────────────────────────────────┼─────────────────────────────
│ checkPermissions + 全局权限 │ 这个操作是否被用户/系统允许 │ 弹出权限询问、deny、或 auto 分类 │ 系统规则 + 工具规则 │
└─────────────────────────────┴────────────────────────────────┴─────────────────────────────
- 职责分离,避免权限系统被”错误输入”淹没
如果没有 validateInput,那么:
- old_string === new_string 的编辑也会进入权限询问
- 不存在的文件编辑也会问用户”是否允许编辑”
- 大文件编辑也会先问权限,然后才发现根本做不了
validateInput 先把明显不能做的事过滤掉,权限系统只处理”能做但需不需要允许”的事。
- 安全上的纵深防御
看 src/tools/PowerShellTool/PowerShellTool.tsx:215 的注释:
/**
- Checked in BOTH validateInput (clean tool-runner error) and call()
- (redundant with validateInput). The call() guard is the load-bearing one. */
复杂工具会在多个层次做检查,因为:
- validateInput 可能被绕过(比如测试直接调用 call())
- 权限系统可能配置错误
- 模型可能构造出意想不到的输入
多一层检查就多一层保险。
- 错误信息更精确
validateInput 返回的 errorCode 和 message 是工具特定的,能告诉模型具体怎么修正。比如 “File does not exist. Did you mean xxx?” 这比冷冰冰的 “Permission denied” 有用得多。
- 权限系统更纯粹
全局权限系统(permissions.ts)不用关心每个工具的细微业务规则,只需要处理:
- 用户设置的 allow/deny/ask 规则
- 模式转换(auto / dontAsk / acceptEdits)
- 安全分类器
- 高危路径保护
工具自己处理工具自己的异常。
执行流程总结
模型调用工具 ↓ toolExecution.ts: inputSchema.safeParse() // schema 校验 ↓ toolExecution.ts: tool.validateInput() // 第一道防线:工具内校验 ↓ 失败:直接返回 tool_use_error permissions.ts: 全局 deny/ask 规则检查 ↓ permissions.ts: tool.checkPermissions() // 第二道防线:工具特定权限 ↓ permissions.ts: auto 分类器 / 模式转换 / 用户询问 ↓ tool.call() // 真正执行
所以”工具内校验和全局权限双层防线”的意思是:复杂工具先用 validateInput 保证输入合法、可执行,再用 checkPermissions + 全局权限系统保证操作被授权。两者分工不同、层级不同,但都不可或缺。
★ Insight ─────────────────────────────────────
- validateInput 是”能不能做”,不是”允不允许做” —— 它的失败通常意味着工具会出错或没意义,而不是用户没授权。
- 权限系统默认”fail-closed”(默认拒绝),而 validateInput 则是”fail-fast”(尽快发现错误)—— TS,连 isReadOnly 都默认 false,就是假设写入。
- 这种分层让工具作者只写业务规则,让权限系统只写安全策略;如果混在一起,任一改动都容易误伤另一层。 ─────────────────────────────────────────────────
为什么工具 schema 排序、超长输出截断和错误扣留都属于工具安全架构,而不只是性能优化?
因为这三者都决定了模型能够看到什么,执行什么,这直接决定了模型会不会被误导、被淹没、或者被注入。
- Schema 排序(Tool Pool Ordering)—— 决定工具的”身份边界”
代码在 src/tools.ts:345 的 assembleToolPool:
export function assembleToolPool(permissionContext: ToolPermissionContext, mcpTools: Tools): Tools {
const builtInTools = getTools(permissionContext);
const allowedMcpTools = filterToolsByDenyRules(mcpTools, permissionContext);
// Sort each partition for prompt-cache stability, keeping built-ins as a
// contiguous prefix. The server's claude_code_system_cache_policy places a
// global cache breakpoint after the last prefix-matched built-in tool; a flat
// sort would interleave MCP tools into built-ins and invalidate all downstream
// cache keys whenever an MCP tool sorts between existing built-ins. uniqBy
// preserves insertion order, so built-ins win on name conflict.
const byName = (a: Tool, b: Tool) => a.name.localeCompare(b.name);
return uniqBy([...builtInTools].sort(byName).concat(allowedMcpTools.sort(byName)), "name");
}
这里有两个安全决策:
第一,built-in 必须作为连续前缀,不能被 MCP 工具插进来。
服务端的 claude_code_system_cache_policy 会在”最后一个匹配的 built-in 工具”后面放一个全局缓存断点。如果扁平排序把 MCP 工具插到 built-in 中间,那么每次 MCP 工具变化时,断点后面的所有内容都会失效。
这有什么安全问题?缓存位置决定 prompt 结构。如果 MCP 工具能影响断点位置,它就能间接改变 built-in 工具在 prompt 中的上下文窗口位置、注意力分布,甚至被推到模型”看不太清”的地方。排序是在保证:built-in 工具的位置是稳定、可预测、不受外部 MCP 服务器影响的。
第二,uniqBy 保留插入顺序,built-in 在命名冲突时优先。
return uniqBy([...builtInTools].sort(byName).concat(allowedMcpTools.sort(byName)), "name");
built-in 在前,MCP 在后。如果某个 MCP 工具也叫 Read 或 Bash,它会被忽略。这是防止外部 MCP 服务器劫持内置工具的名字。如果没有这个排序 + 去重策略,一个恶意的 MCP 服务器可以注册一个同名的工具,覆盖你的文件读取/命令执行语义。
所以 schema 排序不是”排好看一点”,而是在维护 built-in 工具的命名空间和 prompt 中的稳定边界。
- 超长输出截断 / 持久化 —— 控制模型的”摄入边界”
代码主要在 src/utils/toolResultStorage.ts 和 src/constants/toolLimits.ts。
每个工具声明自己的上限:
// src/Tool.ts:466 maxResultSizeChars: number
Bash 工具只给 30K:
// src/tools/BashTool/BashTool.tsx:424 maxResultSizeChars: 30_000,
而 Read 工具设为 Infinity,注释说得很明确:
// src/Tool.ts:461 /**
- Set to Infinity for tools whose output must never be persisted (e.g. Read,
- where persisting creates a circular Read→file→Read loop and the tool
- already self-bounds via its own limits). */
全局默认上限是 50K:
// src/constants/toolLimits.ts:13 export const DEFAULT_MAX_RESULT_SIZE_CHARS = 50_000
实际处理在 maybePersistLargeToolResult:
// src/utils/toolResultStorage.ts:272
async function maybePersistLargeToolResult(
toolResultBlock: ToolResultBlockParam,
toolName: string,
persistenceThreshold?: number
): Promise<ToolResultBlockParam> {
const size = contentSize(content);
const threshold = persistenceThreshold ?? MAX_TOOL_RESULT_BYTES;
if (size <= threshold) {
return toolResultBlock;
}
// 太大就写入文件,模型只看到引用 + 预览
const result = await persistToolResult(content, toolResultBlock.tool_use_id);
const message = buildLargeToolResultMessage(result);
return { ...toolResultBlock, content: message };
}
为什么不是单纯的性能优化?
三个安全原因:
第一,防止 DoW(Denial of Wallet/Context)。
| 一个命令 cat /dev/urandom | base64 或者 find / -type f 可能产生几 MB 输出。如果不截断/持久化,这些输出会直接塞进上下文窗口,烧光 token、拖慢响应,甚至让模型完全无法工作。这不只是”慢”,而是可用性攻击。 |
第二,防止结果中隐藏恶意指令。
超大输出可以被用来:
- 在几万行日志中埋一段 “ignore previous instructions…”
- 用大量噪音淹没关键信息
- 利用模型对长文本的注意力衰减,把有害内容藏在后面
截断 + 持久化后,模型看到的是固定大小的预览,必须主动用 Read 去读取完整文件。这就把”被动接收”变成了”主动选择再看”——多了一层审查机会。
第三,空结果注入防护。
maybePersistLargeToolResult 里有一段非常有趣的注释:
// src/utils/toolResultStorage.ts:280
// Empty tool_result content at the prompt tail causes some models
// (notably capybara) to emit the \n\nHuman: stop sequence and end their turn
// with zero output. The server renderer inserts no \n\nAssistant: marker after
// tool results, so a bare </function_results>\n\n pattern-matches to a turn
// boundary.
也就是说,空 tool_result 可能被模型误判为对话结束标记,导致模型提前停止或行为异常。代码会注入一个占位符:
return {
...toolResultBlock,
content: `(${toolName} completed with no output)`,
}
这直接是在防御 prompt injection / 停止序列操控。
- 错误扣留(Error Withholding)—— 防止错误状态的级联泄露
代码在 src/query.ts:175:
/**
* Is this a max_output_tokens error message? If so, the streaming loop should
* withhold it from SDK callers until we know whether the recovery loop can
* continue. Yielding early leaks an intermediate error to SDK callers (e.g.
* cowork/desktop) that terminate the session on any `error` field — the
* recovery loop keeps running but nobody is listening.
*/
function isWithheldMaxOutputTokens(
msg: Message | StreamEvent | undefined,
): msg is AssistantMessage {
return msg?.type === 'assistant' && msg.apiError === 'max_output_tokens'
}
实际扣留逻辑在 src/query.ts:788:
// Withhold recoverable errors (prompt-too-long, max-output-tokens)
// until we know whether recovery (collapse drain / reactive
// compact / truncation retry) can succeed. Still pushed to
// assistantMessages so the recovery checks below find them.
let withheld = false
if (feature('CONTEXT_COLLAPSE')) {
if (contextCollapse?.isWithheldPromptTooLong(message, ...)) {
withheld = true
}
}
if (reactiveCompact?.isWithheldPromptTooLong(message)) {
withheld = true
}
if (isWithheldMaxOutputTokens(message)) {
withheld = true
}
if (!withheld) {
yield yieldMessage
}
为什么不是单纯的性能优化?
这里扣留的不是任意错误,而是 recoverable errors(可恢复错误):prompt-too-long 和 max_output_tokens。
如果直接 yield 出去:
- SDK 调用方(比如桌面端、cowork)可能一看到 error 字段就终止整个 session
- 但系统其实可以通过 context collapse 或 reactive compact 自动压缩历史、然后重试
- 提前暴露错误 = 把内部中间状态泄露给外部消费者,导致他们做出错误的终止决策
这是状态一致性和故障隔离的安全问题:
- 防止外部观察者基于未完成的内部状态做决策 错误还没最终确定,外面的人如果先看到,会以为真的失败了。
- 保持恢复回路的原子性 扣留期间系统继续尝试恢复;如果恢复成功,用户/调用方根本不会知道这个错误存在。这是一种”事务性”的错误处理。
- 避免错误信息被下游传播放大 一旦错误被 yield 出去,它可能进入日志、UI、telemetry,甚至被其他组件当作终止信号。扣留是在控制错误信息的可见性生命周期。
总结:它们都属于”控制面”安全
┌─────────────────────┬──────────┬────────────────────────────────────────────────────────────┐
│ 机制 │ 表面看 │ 实际安全职责 │
├─────────────────────┼──────────┼────────────────────────────────────────────────────────────┤
│ Schema 排序 │ 缓存优化 │ 维持 built-in/MCP 边界,防止工具命名冲突和 prompt 结构漂移 │
├─────────────────────┼──────────┼────────────────────────────────────────────────────────────┤
│ 超长输出截断/持久化 │ 省 token │ 防止 DoW、隐藏指令注入、空结果停止序列攻击 │
├─────────────────────┼──────────┼────────────────────────────────────────────────────────────┤
│ 错误扣留 │ 用户体验 │ 防止中间错误泄露给外部消费者,保持恢复回路原子性 │
└─────────────────────┴──────────┴────────────────────────────────────────────────────────────┘
这三件事的共同点是:它们都在限制模型和外部世界之间的信息流动方式。安全架构的核心不是”能不能做”,而是”在什么条件下、以什么形式、看到什么”。性能优化只关心”快不快”,而这些机制关心的是”边界是否可控”。
Enjoy Reading This Article?
Here are some more articles you might like to read next:
- Google Gemini updates: Flash 1.5, Gemma 2 and Project Astra
- Displaying External Posts on Your al-folio Blog
- Graph RAG with Milvus —— 纯向量库造图的多跳推理
- Hierarchical Indices 层级索引 —— 先粗后细的两级检索
- HyDE 与 HyPE —— 假设检索技术的两个方向
- 二叉树刷题总结
- RAG 技术体系化分类——从 Pipeline 阶段到失败模式
- MemoRAG 记忆增强型 RAG 总结
- Microsoft GraphRAG 基于知识图谱的 RAG 总结
- Multimodal RAG with Captioning 图像描述型多模态 RAG 总结