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,
    }
  }

整体的流程如下

  1. 检查是否读取文件
  2. 检查文件是否被修改
  3. 有没有找到字符串
  4. 字符串匹配的是不是唯一的
    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 执行工具时的两道安全/正确性检查防线。把它拆开来看:

  1. 工具内校验(validateInput):每个复杂工具自己内部的”业务正确性”检查
  2. 全局权限(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 通过后才会调用,而且它又分为两层:

  1. 通用权限逻辑(src/utils/permissions/permissions.ts)
  • 整个工具是否被全局 deny/ask?
  • auto mode 分类器
  • bypass/acceptEdits 模式
  • 安全路径检查
  1. 工具特定权限逻辑(各工具的 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 { return bashToolHasPermission(input, context); }

它会检查命令前缀、是否在沙箱中、是否匹配允许规则等。

例子: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 分类 │ 系统规则 + 工具规则 │
└─────────────────────────────┴────────────────────────────────┴─────────────────────────────
  1. 职责分离,避免权限系统被”错误输入”淹没

如果没有 validateInput,那么:

  • old_string === new_string 的编辑也会进入权限询问
  • 不存在的文件编辑也会问用户”是否允许编辑”
  • 大文件编辑也会先问权限,然后才发现根本做不了

validateInput 先把明显不能做的事过滤掉,权限系统只处理”能做但需不需要允许”的事。

  1. 安全上的纵深防御

看 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())
  • 权限系统可能配置错误
  • 模型可能构造出意想不到的输入

多一层检查就多一层保险。

  1. 错误信息更精确

validateInput 返回的 errorCode 和 message 是工具特定的,能告诉模型具体怎么修正。比如 “File does not exist. Did you mean xxx?” 这比冷冰冰的 “Permission denied” 有用得多。

  1. 权限系统更纯粹

全局权限系统(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 ─────────────────────────────────────

  1. validateInput 是”能不能做”,不是”允不允许做” —— 它的失败通常意味着工具会出错或没意义,而不是用户没授权。
  2. 权限系统默认”fail-closed”(默认拒绝),而 validateInput 则是”fail-fast”(尽快发现错误)—— TS,连 isReadOnly 都默认 false,就是假设写入。
  3. 这种分层让工具作者只写业务规则,让权限系统只写安全策略;如果混在一起,任一改动都容易误伤另一层。 ─────────────────────────────────────────────────

为什么工具 schema 排序、超长输出截断和错误扣留都属于工具安全架构,而不只是性能优化?

因为这三者都决定了模型能够看到什么,执行什么,这直接决定了模型会不会被误导、被淹没、或者被注入。

  1. 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 中的稳定边界。


  1. 超长输出截断 / 持久化 —— 控制模型的”摄入边界”

代码主要在 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 / 停止序列操控。


  1. 错误扣留(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 自动压缩历史、然后重试
  • 提前暴露错误 = 把内部中间状态泄露给外部消费者,导致他们做出错误的终止决策

这是状态一致性和故障隔离的安全问题:

  1. 防止外部观察者基于未完成的内部状态做决策 错误还没最终确定,外面的人如果先看到,会以为真的失败了。
  2. 保持恢复回路的原子性 扣留期间系统继续尝试恢复;如果恢复成功,用户/调用方根本不会知道这个错误存在。这是一种”事务性”的错误处理。
  3. 避免错误信息被下游传播放大 一旦错误被 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: