4.4 文件编辑安全 — 权限决策与脏写防护的工程实现

4.4 文件编辑安全 --- 权限决策与脏写防护的工程实现

对应原书 :第4章 4.5节"九步执行管线"(步骤 4 工具自定义验证 + 步骤 6 输入回填 + 步骤 8 权限决策)、4.8节"FileEditTool --- 原子性与脏写防护"、4.12节"设计模式提炼"(确定性写入 + Fail-Closed 默认值)

辅助源码tools/FileEditTool/(FileEditTool.ts 626行、types.ts 86行、utils.ts 776行、constants.ts 12行、prompt.ts 28行)、utils/permissions/filesystem.ts(1778行)

重点关注:文件编辑权限决策八步流水线、双重时间戳校验、同步原子写入、路径安全检查(危险文件/危险目录/Windows路径模式/UNC路径/符号链接)、read-before-write 校验、引号归一化、秘密扫描、设置文件验证


1. 导语:FileEditTool 的安全挑战

原书 4.8 节指出 FileEditTool 面对的核心问题:在 LLM 读取文件和写入修改之间,文件可能被外部进程修改(IDE 自动保存、git 操作、另一个 Agent 工具)。这是经典的 TOCTOU(Time-of-Check-to-Time-of-Use)问题。

但脏写防护只是 FileEditTool 安全体系的一环。源码揭示了一个更完整的六层安全架构:

复制代码
┌─────────────────────────────────────────────────────────────────┐
│ Layer 1: 输入验证 (validateInput)                                │
│   read-before-write + 文件存在性 + old_string 匹配 + 秘密扫描    │
├─────────────────────────────────────────────────────────────────┤
│ Layer 2: 权限决策 (checkPermissions → checkWritePermissionForTool)│
│   deny 规则 → 内部路径放行 → .claude/ 会话规则 → 安全检查 →      │
│   ask 规则 → acceptEdits 模式 → allow 规则 → 默认询问            │
├─────────────────────────────────────────────────────────────────┤
│ Layer 3: 路径安全 (checkPathSafetyForAutoEdit)                   │
│   Windows 路径模式 + Claude 配置文件 + 危险文件/目录 + UNC 路径  │
├─────────────────────────────────────────────────────────────────┤
│ Layer 4: 路径约束 (pathInAllowedWorkingPath)                     │
│   工作目录边界 + 符号链接解析 + 路径遍历防御                      │
├─────────────────────────────────────────────────────────────────┤
│ Layer 5: 脏写防护 (双重时间戳校验 + 同步原子写入)                 │
│   validateInput 阶段预检 + call 阶段二次校验 + 内容后备           │
├─────────────────────────────────────────────────────────────────┤
│ Layer 6: 审计日志 (logFileOperation + logEvent)                  │
│   文件操作记录 + CLAUDE.md 编辑标记 + 编辑长度统计                │
└─────────────────────────────────────────────────────────────────┘

2. FileEditTool 工具定义:Fail-Closed 的默认值

2.1 buildTool 与工具元数据

FileEditTool.ts 第 86 行使用 buildTool() 构建工具定义:

typescript 复制代码
export const FileEditTool = buildTool({
  name: FILE_EDIT_TOOL_NAME,           // 'Edit'
  searchHint: 'modify file contents in place',
  maxResultSizeChars: 100_000,
  strict: true,                         // 严格模式:Zod schema 不允许额外字段
  // ...
})

关键安全属性继承自 buildTool 的 Fail-Closed 默认值:

  • isReadOnly 默认 false --- 假设会写入,需要权限检查
  • isConcurrencySafe 默认 false --- 假设不并发安全,串行执行
  • isDestructive 默认 false --- 不标记为破坏性(但实际会修改文件)

2.2 输入 Schema

types.ts 定义了严格的输入 schema:

typescript 复制代码
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: semanticBoolean(
      z.boolean().default(false).optional(),
    ).describe('Replace all occurrences of old_string (default false)'),
  }),
)

使用 z.strictObject()(非 z.object())意味着任何额外字段都会导致验证失败------这是防止 Prompt 注入添加意外参数的防线。

2.3 路径回填:防 Hook 白名单绕过

原书 4.5 节步骤 6"输入回填"在 FileEditTool 中的实现:

typescript 复制代码
backfillObservableInput(input) {
  // hooks.mdx documents file_path as absolute; expand so hook allowlists
  // can't be bypassed via ~ or relative paths.
  if (typeof input.file_path === 'string') {
    input.file_path = expandPath(input.file_path)
  }
},

expandPath()~ 展开为绝对路径。注释明确说明了安全意义:如果 Hook 配置了路径白名单(如"只允许读 /home/user/project/ 下的文件"),~/./../etc/passwd 在回填前可以绕过白名单。

2.4 权限匹配器

typescript 复制代码
async preparePermissionMatcher({ file_path }) {
  return pattern => matchWildcardPattern(pattern, file_path)
},

preparePermissionMatcher 返回一个匹配函数,用于后续的通配符权限规则匹配。注意这里使用的是 file_path 原始值(未展开),匹配时由 matchingRuleForInput 内部调用 expandPath


3. Layer 1: 输入验证 --- validateInput 的十道检查

validateInput 是 FileEditTool 最核心的安全函数,在 call() 之前执行,包含十道检查:

3.1 检查流程

typescript 复制代码
async validateInput(input: FileEditInput, toolUseContext: ToolUseContext) {
  const { file_path, old_string, new_string, replace_all = false } = input
  const fullFilePath = expandPath(file_path)

  // 检查 1: 秘密扫描 --- 阻止向团队记忆文件写入密钥
  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, ... errorCode: 1 }

  // 检查 3: deny 规则 --- 权限设置中拒绝编辑的文件
  const denyRule = matchingRuleForInput(fullFilePath, ..., 'edit', 'deny')
  if (denyRule) return { result: false, ... errorCode: 2 }

  // 检查 4: UNC 路径 --- 跳过文件系统操作,交给权限检查处理
  if (fullFilePath.startsWith('\\\\') || fullFilePath.startsWith('//'))
    return { result: true }

  // 检查 5: 文件大小限制 --- 防止 OOM(1 GiB 上限)
  const { size } = await fs.stat(fullFilePath)
  if (size > MAX_EDIT_FILE_SIZE) return { result: false, ... errorCode: 10 }

  // 检查 6: 文件存在性 + 编码检测(UTF-8/UTF-16LE)
  // 检查 7: 文件不存在时 old_string 必须为空(创建新文件)
  // 检查 8: Jupyter Notebook 检测 --- 必须使用 NotebookEditTool
  // 检查 9: read-before-write + 脏写检测(详见第 6 节)
  // 检查 10: old_string 匹配 + replace_all 唯一性验证 + 设置文件验证
}

3.2 秘密扫描

typescript 复制代码
// Reject edits to team memory files that introduce secrets
const secretError = checkTeamMemSecrets(fullFilePath, new_string)
if (secretError) {
  return { result: false, message: secretError, errorCode: 0 }
}

checkTeamMemSecrets 检查 new_string 是否包含密钥模式(API key、密码等),防止 LLM 被注入后将密钥写入团队记忆同步文件。

3.3 UNC 路径安全处理

typescript 复制代码
// SECURITY: Skip filesystem operations for UNC paths to prevent NTLM credential leaks.
// On Windows, fs.existsSync() on UNC paths triggers SMB authentication which could
// leak credentials to malicious servers. Let the permission check handle UNC paths.
if (fullFilePath.startsWith('\\\\') || fullFilePath.startsWith('//')) {
  return { result: true }
}

这是一个关键的安全设计:在 validateInput 阶段跳过 UNC 路径的文件系统操作 ,因为 fs.existsSync() 在 Windows 上访问 UNC 路径会触发 SMB 认证,可能向恶意服务器泄漏 NTLM 凭据。UNC 路径的安全检查交给后续的权限检查层处理。

3.4 文件大小限制

typescript 复制代码
const MAX_EDIT_FILE_SIZE = 1024 * 1024 * 1024 // 1 GiB (stat bytes)

注释解释:V8/Bun 字符串长度限制约 2^30 字符(~10 亿),对于 ASCII/Latin-1 文件,1 字节 = 1 字符,因此 1 GiB 是安全的字节级上限,防止 OOM。

3.5 read-before-write 检查

typescript 复制代码
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.',
    errorCode: 6,
  }
}

这是原书提到的 read-before-write 检查 (4.5 节步骤 4)。readFileState 是一个 Map,记录每个文件被读取的时间戳和内容。如果文件未被读取过(!readTimestamp),或者只读取了部分内容(isPartialView),则拒绝编辑。

isPartialView 检查特别重要------如果 LLM 只读取了文件的某一行(通过 offset/limit),它可能不了解文件的完整上下文,此时编辑可能导致意外修改。

3.6 old_string 匹配与引号归一化

typescript 复制代码
// 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', errorCode: 8 }
}

const matches = file.split(actualOldString).length - 1
if (matches > 1 && !replace_all) {
  return { result: false, behavior: 'ask', message: 'Found N matches but replace_all is false', errorCode: 9 }
}

findActualString 是一个容错匹配函数(utils.ts 第 73 行),处理 LLM 输出直引号但文件中使用弯引号的情况:

typescript 复制代码
export function findActualString(
  fileContent: string,
  searchString: string,
): string | null {
  // 1. 先尝试精确匹配
  if (fileContent.includes(searchString)) return searchString
  // 2. 尝试引号归一化后匹配
  const normalizedSearch = normalizeQuotes(searchString)
  const normalizedFile = normalizeQuotes(fileContent)
  const searchIndex = normalizedFile.indexOf(normalizedSearch)
  if (searchIndex !== -1) {
    // 返回文件中的实际字符串(保留原始引号风格)
    return fileContent.substring(searchIndex, searchIndex + searchString.length)
  }
  return null
}

normalizeQuotes 将四种 Unicode 弯引号(' ' " ")归一化为直引号,这是因为 LLM 无法输出弯引号,但文件中可能使用弯引号。

3.7 preserveQuoteStyle:引号风格保留

typescript 复制代码
// Preserve curly quotes in new_string when the file uses them
const actualNewString = preserveQuoteStyle(old_string, actualOldString, new_string)

old_string 通过引号归一化匹配时,preserveQuoteStyle 会将 new_string 中的直引号转换回文件使用的弯引号风格,保持文件的排版一致性。该函数使用开/闭引号启发式判断:

typescript 复制代码
function isOpeningContext(chars: string[], index: number): boolean {
  if (index === 0) return true
  const prev = chars[index - 1]
  return prev === ' ' || prev === '\t' || prev === '\n' ||
         prev === '(' || prev === '[' || prev === '{' ||
         prev === '\u2014' || prev === '\u2013'  // em dash / en dash
}

对于单引号,还额外处理缩写(contractions)------don't 中的 ' 是撇号而非引号,应使用右单弯引号 '

3.8 设置文件验证

typescript 复制代码
// Additional validation for Claude settings files
const settingsValidationResult = validateInputForSettingsFileEdit(
  fullFilePath, file,
  () => replace_all
    ? file.replaceAll(actualOldString, new_string)
    : file.replace(actualOldString, new_string),
)
if (settingsValidationResult !== null) return settingsValidationResult

validateInputForSettingsFileEdit 对 Claude Code 自己的配置文件(.claude/settings.json.claude/settings.local.json)执行额外验证,确保编辑不会破坏配置文件的结构。


4. Layer 2: 权限决策 --- checkWritePermissionForTool 八步流水线

filesystem.ts 第 1205 行的 checkWritePermissionForTool 是文件写入权限决策的核心函数,执行八步检查:

复制代码
┌──────────────────────────────────────────────────────────────────┐
│ Step 1: deny 规则检查(含符号链接解析)                           │
│   matchingRuleForInput(path, ..., 'edit', 'deny')                │
│   → deny 规则匹配 → 直接拒绝                                     │
├──────────────────────────────────────────────────────────────────┤
│ Step 1.5: 内部可编辑路径放行                                     │
│   checkEditableInternalPath() --- 计划文件/草稿本/Agent记忆/任务目录 │
│   → 匹配内部路径 → 放行                                          │
├──────────────────────────────────────────────────────────────────┤
│ Step 1.6: .claude/ 会话级 allow 规则                             │
│   匹配 /.claude/** 或 ~/.claude/** 的会话级规则                   │
│   → 匹配且无 .. 遍历 → 放行                                      │
├──────────────────────────────────────────────────────────────────┤
│ Step 1.7: 综合安全验证                                           │
│   checkPathSafetyForAutoEdit() --- Windows路径/配置文件/危险文件    │
│   → 不安全 → 询问用户(带建议)                                   │
├──────────────────────────────────────────────────────────────────┤
│ Step 2: ask 规则检查                                             │
│   matchingRuleForInput(path, ..., 'edit', 'ask')                 │
│   → ask 规则匹配 → 询问用户                                      │
├──────────────────────────────────────────────────────────────────┤
│ Step 3: acceptEdits 模式 + 工作目录检查                          │
│   mode === 'acceptEdits' && pathInAllowedWorkingPath()           │
│   → 工作目录内 → 放行                                            │
├──────────────────────────────────────────────────────────────────┤
│ Step 4: allow 规则检查                                           │
│   matchingRuleForInput(path, ..., 'edit', 'allow')               │
│   → allow 规则匹配 → 放行                                        │
├──────────────────────────────────────────────────────────────────┤
│ Step 5: 默认询问                                                 │
│   → 生成建议(setMode:acceptEdits 或 addDirectories)             │
└──────────────────────────────────────────────────────────────────┘

4.1 deny 规则优先 + 符号链接解析

typescript 复制代码
// 1. Check for deny rules - check both the original path and resolved symlink path
const pathsToCheck = precomputedPathsToCheck ?? getPathsForPermissionCheck(path)
for (const pathToCheck of pathsToCheck) {
  const denyRule = matchingRuleForInput(pathToCheck, ..., 'edit', 'deny')
  if (denyRule) {
    return { behavior: 'deny', message: `Permission to edit ${path} has been denied.` }
  }
}

关键安全设计getPathsForPermissionCheck(path) 返回原始路径 符号链接解析后的路径。这意味着即使攻击者创建一个指向 .bashrc 的符号链接 ~/project/link-to-bashrc,deny 规则也能通过解析后的路径匹配到 .bashrc 并阻止编辑。

4.2 内部可编辑路径

typescript 复制代码
const internalEditResult = checkEditableInternalPath(absolutePathForEdit, input)
if (internalEditResult.behavior !== 'passthrough') {
  return internalEditResult
}

checkEditableInternalPath 放行以下内部路径:

  • 计划文件{plansDir}/{planSlug}.md(当前会话的计划文件)
  • 草稿本/tmp/claude-{uid}/{sanitized-cwd}/{sessionId}/scratchpad/(Feature Flag 控制)
  • Job 目录~/.claude/jobs/{jobDir}/(模板任务,含 hijack guard 和 symlink guard)
  • Agent 记忆:Agent 工具的持久化记忆目录
  • 自动记忆~/.claude/ 下的跨会话学习记忆(仅在无路径覆盖时)
  • launch.json.claude/launch.json 桌面预览配置

每个内部路径检查都使用 normalize() 防止 .. 路径遍历绕过。

4.3 .claude/ 会话级 allow 规则

这是一个精心设计的安全旁路------允许会话级规则绕过 .claude/ 的危险目录检查:

typescript 复制代码
// 1.6. Check for .claude/** allow rules BEFORE safety checks
// This allows session-level permissions to bypass the safety blocks for .claude/
const claudeFolderAllowRule = matchingRuleForInput(
  path,
  { ...toolPermissionContext,
    alwaysAllowRules: { session: toolPermissionContext.alwaysAllowRules.session ?? [] }
  },
  'edit', 'allow',
)

为什么要限制为会话级? 注释解释:防止用户意外永久授予 .claude/ 文件夹的广泛访问权限。只有会话级规则(临时、会话结束后失效)才能绕过安全检查。

额外安全检查

  • 规则内容必须以 /.claude/~/.claude/ 开头
  • 不允许包含 ..(防止 /.claude/../** 泄漏到 .claude/ 外)
  • 必须以 /** 结尾
  • 支持 /.claude/skills/my-skill/** 的窄化规则(允许单个 skill 而非整个 .claude/

4.4 acceptEdits 模式

typescript 复制代码
if (toolPermissionContext.mode === 'acceptEdits' && isInWorkingDir) {
  return {
    behavior: 'allow',
    decisionReason: { type: 'mode', mode: 'acceptEdits' }
  }
}

acceptEdits 模式自动放行工作目录内的编辑,但不影响工作目录外的文件 ------isInWorkingDir 检查确保了这一点。这与 BashTool 的 modeValidation.ts 不同------FileEditTool 的模式验证内嵌在权限决策流程中,而非独立的前置检查。


5. Layer 3: 路径安全 --- checkPathSafetyForAutoEdit

filesystem.ts 第 620 行的 checkPathSafetyForAutoEdit 是文件编辑安全的核心防线,执行四道检查:

5.1 Windows 可疑路径模式检测

typescript 复制代码
function hasSuspiciousWindowsPathPattern(path: string): boolean {
  // 1. NTFS 备用数据流(ADS):file.txt::$DATA, .bashrc:hidden
  if (getPlatform() === 'windows' || getPlatform() === 'wsl') {
    const colonIndex = path.indexOf(':', 2)  // 跳过盘符 C:
    if (colonIndex !== -1) return true
  }
  // 2. 8.3 短文件名:GIT~1, BASHRC~1
  if (/~\d/.test(path)) return true
  // 3. 长路径前缀:\\?\C:\..., \\.\C:\...
  if (path.startsWith('\\\\?\\') || path.startsWith('\\\\.\\') || ...) return true
  // 4. 尾部点号和空格:.git., .bashrc... (Windows 解析时去除)
  if (/[.\s]+$/.test(path)) return true
  // 5. DOS 设备名:.git.CON, settings.json.PRN
  if (/\.(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i.test(path)) return true
  // 6. 三个以上连续点作为路径组件:.../file.txt
  if (/(^|\/|\\)\.{3,}(\/|\\|$)/.test(path)) return true
  // 7. UNC 路径
  if (containsVulnerableUncPath(path)) return true
  return false
}

每种模式都有明确的攻击向量:

模式 攻击示例 安全风险
NTFS ADS file.txt:hidden 隐藏数据流绕过内容检查
8.3 短名 GIT~1/config 绕过 .git 目录检测
长路径前缀 \\?\C:\... 绕过路径长度限制和规范化
尾部点/空格 .git..git Windows 解析去除尾部点,绕过字符串匹配
DOS 设备名 .git.CON Windows 将 CON 作为设备而非文件
连续三点 .../file 路径混淆,可能绕过遍历检测

注释特别解释了为什么用检测而非规范化:规范化依赖文件系统状态(文件必须存在),存在 TOCTOU 竞态条件,且需要平台特定 API。模式检测更可预测,不依赖外部系统状态。

5.2 危险文件和危险目录

typescript 复制代码
export const DANGEROUS_FILES = [
  '.gitconfig', '.gitmodules',     // Git 配置(可注入恶意 hook)
  '.bashrc', '.bash_profile',      // Bash 启动脚本(代码执行)
  '.zshrc', '.zprofile', '.profile', // Shell 启动脚本
  '.ripgreprc',                     // ripgrep 配置
  '.mcp.json', '.claude.json',      // Claude Code 配置
] as const

export const DANGEROUS_DIRECTORIES = [
  '.git',      // Git 仓库(hook 注入、数据渗出)
  '.vscode',   // VS Code 配置(设置操纵、代码执行)
  '.idea',     // JetBrains 配置
  '.claude',   // Claude Code 配置
] as const

isDangerousFilePathToAutoEdit 对路径的每一段进行大小写不敏感匹配:

typescript 复制代码
function isDangerousFilePathToAutoEdit(path: string): boolean {
  const pathSegments = absolutePath.split(sep)
  for (let i = 0; i < pathSegments.length; i++) {
    const segment = pathSegments[i]!
    const normalizedSegment = normalizeCaseForComparison(segment)
    for (const dir of DANGEROUS_DIRECTORIES) {
      if (normalizedSegment === normalizeCaseForComparison(dir)) {
        // 特例:.claude/worktrees/ 是结构路径,跳过
        if (dir === '.claude' && nextSegment === 'worktrees') break
        return true
      }
    }
  }
  // ... 检查危险文件名
}

大小写不敏感匹配 是关键------.cLauDe/Settings.locaL.json 不能绕过 .claude 目录检测。normalizeCaseForComparison 统一转小写:

typescript 复制代码
export function normalizeCaseForComparison(path: string): string {
  return path.toLowerCase()
}

5.3 Claude 配置文件检测

typescript 复制代码
function isClaudeConfigFilePath(filePath: string): boolean {
  if (isClaudeSettingsPath(filePath)) return true  // settings.json / settings.local.json
  // .claude/commands/, .claude/agents/, .claude/skills/
  return pathInWorkingPath(filePath, commandsDir) ||
         pathInWorkingPath(filePath, agentsDir) ||
         pathInWorkingPath(filePath, skillsDir)
}

Claude Code 的配置文件(命令、Agent、Skill 定义)受到额外保护,因为这些文件可以间接执行代码 ------编辑 .claude/commands/deploy.md 等于注入新的命令定义,编辑 .claude/agents/hacker.md 等于创建一个新的 Agent。

5.4 符号链接双重检查

typescript 复制代码
export function checkPathSafetyForAutoEdit(path: string, precomputedPathsToCheck?: readonly string[]) {
  // Get all paths to check (original + symlink resolved paths)
  const pathsToCheck = precomputedPathsToCheck ?? getPathsForPermissionCheck(path)

  // Check for suspicious Windows path patterns on all paths
  for (const pathToCheck of pathsToCheck) {
    if (hasSuspiciousWindowsPathPattern(pathToCheck)) return { safe: false, ... }
  }
  // Check for Claude config files on all paths
  for (const pathToCheck of pathsToCheck) {
    if (isClaudeConfigFilePath(pathToCheck)) return { safe: false, ... }
  }
  // Check for dangerous files on all paths
  for (const pathToCheck of pathsToCheck) {
    if (isDangerousFilePathToAutoEdit(pathToCheck)) return { safe: false, ... }
  }
  return { safe: true }
}

每道检查都对原始路径和符号链接解析路径执行 。这防止了符号链接绕过------攻击者创建 ~/project/link~/.bashrc,即使 link 不在危险文件列表中,解析后的 .bashrc 也会被检测到。


6. Layer 5: 脏写防护 --- 双重时间戳校验与同步原子写入

6.1 双重时间戳校验(原书 4.8.1 节)

原书 4.8.1 节描述了双重时间戳校验。源码验证了这一设计------时间戳校验在两个阶段执行:

阶段 1:validateInput 中(call 之前)

typescript 复制代码
// validateInput 中的脏写检测
const readTimestamp = toolUseContext.readFileState.get(fullFilePath)
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...', errorCode: 7 }
    }
  }
}

阶段 2:call 中(写入之前)

typescript 复制代码
// call 中的二次校验
const { content: originalFileContents, fileExists } = readFileForEdit(absoluteFilePath)
if (fileExists) {
  const lastWriteTime = getFileModificationTime(absoluteFilePath)
  const lastRead = readFileState.get(absoluteFilePath)
  if (!lastRead || lastWriteTime > lastRead.timestamp) {
    const isFullRead = lastRead && lastRead.offset === undefined && lastRead.limit === undefined
    const contentUnchanged = isFullRead && originalFileContents === lastRead.content
    if (!contentUnchanged) {
      throw new Error(FILE_UNEXPECTEDLY_MODIFIED_ERROR)
    }
  }
}

6.2 Content Fallback:Windows 兼容

注释解释了 Content Fallback 的必要性:

Windows timestamps can change without content changes (cloud sync, antivirus, etc.). For full reads, compare content as a fallback to avoid false positives.

Windows 上的云同步软件(OneDrive、Dropbox)和杀毒扫描经常更新文件的 mtime 而不改变内容。如果只看时间戳,会产生大量误报。内容比较是更昂贵但更准确的后备手段------仅在完整读取 (非 offset/limit 读取)时才执行,因为部分读取没有完整的文件内容用于比较。

6.3 同步原子写入(原书 4.8.2 节)

原书 4.8.2 节描述了同步原子写入。源码验证了这一设计:

typescript 复制代码
// call 中的同步读取
function readFileForEdit(absoluteFilePath: string): { ... } {
  // eslint-disable-next-line custom-rules/no-sync-fs
  const meta = readFileSyncWithMetadata(absoluteFilePath)  // 同步 API
  return { content: meta.content, ... }
}

// call 中的关键区域
// 2. Load current state and confirm no changes since last read
// Please avoid async operations between here and writing to disk to preserve atomicity
const { content: originalFileContents, ... } = readFileForEdit(absoluteFilePath)

// ...(staleness check --- 同步)

// 3. Use findActualString to handle quote normalization
const actualOldString = findActualString(originalFileContents, old_string) || old_string
const actualNewString = preserveQuoteStyle(old_string, actualOldString, new_string)

// 4. Generate patch
const { patch, updatedFile } = getPatchForEdit({ ... })

// 5. Write to disk
writeTextContent(absoluteFilePath, updatedFile, encoding, endings)

注释明确警告:"Please avoid async operations between here and writing to disk to preserve atomicity" 。如果使用 await,JavaScript 事件循环可能在 readwrite 之间插入其他操作(比如另一个并发工具也在写同一个文件),导致 TOCTOU 问题。

6.4 文件历史备份

typescript 复制代码
if (fileHistoryEnabled()) {
  // Backup captures pre-edit content --- safe to call before the staleness
  // check (idempotent v1 backup keyed on content hash; if staleness fails
  // later we just have an unused backup, not corrupt state).
  await fileHistoryTrackEdit(updateFileHistoryState, absoluteFilePath, parentMessage.uuid)
}

文件历史备份在 staleness check 之前执行------如果后续 staleness check 失败,只是多了一个未使用的备份,不会造成状态损坏。备份使用内容哈希作为键(幂等),避免重复备份相同内容。


7. Layer 4: 路径约束 --- 工作目录边界

7.1 pathInAllowedWorkingPath

typescript 复制代码
export function pathInAllowedWorkingPath(
  path: string,
  toolPermissionContext: ToolPermissionContext,
  precomputedPathsToCheck?: readonly string[],
): boolean {
  const pathsToCheck = precomputedPathsToCheck ?? getPathsForPermissionCheck(path)
  const workingPaths = Array.from(allWorkingDirectories(toolPermissionContext))
    .flatMap(wp => getResolvedWorkingDirPaths(wp))

  // All paths must be within allowed working paths
  // If any resolved path is outside, deny access
  return pathsToCheck.every(pathToCheck =>
    workingPaths.some(workingPath =>
      pathInWorkingPath(pathToCheck, workingPath),
    ),
  )
}

关键设计every + some 的组合意味着所有 解析路径(原始 + 符号链接)都必须在至少一个工作目录内。如果原始路径在工作目录内但符号链接指向外部,仍然拒绝。

7.2 pathInWorkingPath:路径遍历防御

typescript 复制代码
export function pathInWorkingPath(path: string, workingPath: string): boolean {
  const absolutePath = expandPath(path)
  // macOS 符号链接归一化:/var → /private/var, /tmp → /private/tmp
  const normalizedPath = absolutePath
    .replace(/^\/private\/var\//, '/var/')
    .replace(/^\/private\/tmp(\/|$)/, '/tmp$1')
  // 大小写归一化(macOS/Windows 大小写不敏感文件系统)
  const caseNormalizedPath = normalizeCaseForComparison(normalizedPath)
  const relative = relativePath(caseNormalizedWorkingPath, caseNormalizedPath)

  if (relative === '') return true           // 同一路径
  if (containsPathTraversal(relative)) return false  // 路径遍历
  return !posix.isAbsolute(relative)          // 相对路径 = 在工作目录内
}

containsPathTraversal 检查相对路径是否包含 .. 段,防止 project/../../../etc/passwd 类遍历。

7.3 macOS 符号链接处理

macOS 上 /var/private/var 的符号链接,/tmp/private/tmp 的符号链接。如果不归一化,/private/var/... 的解析路径不会匹配 /var/... 的工作目录,导致误拒。代码手动处理这个 macOS 特有的问题。


8. 权限规则匹配 --- matchingRuleForInput

8.1 gitignore 模式匹配

matchingRuleForInput 使用 ignore 库(gitignore 规范)进行路径模式匹配:

typescript 复制代码
export function matchingRuleForInput(
  path: string,
  toolPermissionContext: ToolPermissionContext,
  toolType: 'edit' | 'read',
  behavior: 'allow' | 'deny' | 'ask',
): PermissionRule | null {
  const patternsByRoot = getPatternsByRoot(toolPermissionContext, toolType, behavior)
  for (const [root, patternMap] of patternsByRoot.entries()) {
    const patterns = Array.from(patternMap.keys()).map(pattern => {
      // Remove /** suffix - ignore library treats 'path' as matching both
      if (adjustedPattern.endsWith('/**')) adjustedPattern = adjustedPattern.slice(0, -3)
      return adjustedPattern
    })
    const ig = ignore().add(patterns)
    const relativePathStr = relativePath(root ?? getCwd(), fileAbsolutePath)
    const igResult = ig.test(relativePathStr)
    if (igResult.ignored) {
      return patternMap.get(originalPattern) ?? null
    }
  }
  return null
}

8.2 规则根路径解析

权限规则可以指定不同的根路径前缀:

前缀 根路径 示例
// /(文件系统根) //etc/passwd
~/ homedir() ~/.bashrc
/ 设置文件所在目录 /src/**
无前缀 null(匹配任何位置) .env

Windows 上 //c/Users/... 会被转换为 C:\Users\... 的 POSIX 格式。

8.3 deny > ask > allow 优先级

权限决策的优先级在 checkWritePermissionForTool 中通过检查顺序实现:

  1. deny 规则(Step 1)--- 最高优先级,直接拒绝
  2. ask 规则(Step 2)--- 次高优先级,询问用户
  3. allow 规则(Step 4)--- 最低优先级,自动放行

这确保了显式拒绝不可被允许覆盖 ------如果用户配置了 Edit(.env): deny,即使同时有 Edit(**): allow.env 文件仍然被拒绝。


9. call() 执行流程:八步写入管线

FileEditTool.call() 的执行流程:

复制代码
1. 技能发现 --- discoverSkillDirsForPaths(非阻塞,fire-and-forget)
2. 诊断前置 --- diagnosticTracker.beforeFileEdited
3. 目录创建 --- fs.mkdir(dirname(absoluteFilePath))
4. 文件历史备份 --- fileHistoryTrackEdit(内容哈希幂等)
5. 同步读取 + staleness 二次校验 --- readFileForEdit(同步 API)
6. 引号归一化 --- findActualString + preserveQuoteStyle
7. 生成补丁 --- getPatchForEdit(applyEditToFile + getPatchFromContents)
8. 写入磁盘 --- writeTextContent(同步写入)
9. LSP 通知 --- lspManager.changeFile + lspManager.saveFile
10. VSCode 通知 --- notifyVscodeFileUpdated
11. 更新读取状态 --- readFileState.set(更新时间戳和内容)
12. 审计日志 --- logFileOperation + logEvent

9.1 技能发现(非阻塞)

typescript 复制代码
// Discover skills from this file's path (fire-and-forget, non-blocking)
if (!isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) {
  const newSkillDirs = await discoverSkillDirsForPaths([absoluteFilePath], cwd)
  if (newSkillDirs.length > 0) {
    for (const dir of newSkillDirs) {
      dynamicSkillDirTriggers?.add(dir)
    }
    // Don't await - let skill loading happen in the background
    addSkillDirectories(newSkillDirs).catch(() => {})
  }
  activateConditionalSkillsForPaths([absoluteFilePath], cwd)
}

编辑文件可能触发新技能的发现(如编辑 .claude/skills/ 下的文件),但技能加载是后台非阻塞的,不影响编辑操作的延迟。

9.2 审计日志

typescript 复制代码
// CLAUDE.md 编辑标记
if (absoluteFilePath.endsWith(`${sep}CLAUDE.md`)) {
  logEvent('tengu_write_claudemd', {})
}

// 行变更统计
countLinesChanged(patch)

// 文件操作记录
logFileOperation({
  operation: 'edit',
  tool: 'FileEditTool',
  filePath: absoluteFilePath,
})

// 编辑字符串长度统计
logEvent('tengu_edit_string_lengths', {
  oldStringBytes: Buffer.byteLength(old_string, 'utf8'),
  newStringBytes: Buffer.byteLength(new_string, 'utf8'),
  replaceAll: replace_all,
})

10. 引号归一化与去消毒

10.1 DESANITIZATIONS:API 消毒逆向

utils.ts 第 531 行定义了一组"去消毒"映射,用于处理 LLM 输出中被 API 消毒的字符串:

typescript 复制代码
const DESANITIZATIONS: Record<string, string> = {
  '<fnr>': '<function_results>',
  '<n>': '<name>', '</n>': '</name>',
  '<o>': '<output>', '</o>': '</output>',
  '<e>': '<error>', '</e>': '</error>',
  '<s>': '<system>', '</s>': '</system>',
  '<r>': '<result>', '</r>': '</result>',
  '< META_START >': '<META_START>',
  '< EOT >': '<EOT>',
  '\n\nH:': '\n\nHuman:',
  '\n\nA:': '\n\nAssistant:',
}

API 会将这些 XML 标签和特殊标记消毒(如 <function_results><fnr>),LLM 看到的是消毒后的版本,因此编辑时输出的 old_string 可能包含消毒后的形式。normalizeFileEditInput 尝试逆向消毒以匹配文件中的原始内容。

10.2 尾部空白处理

typescript 复制代码
// Markdown uses two trailing spaces as a hard line break --- stripping would
// silently change semantics. Skip stripTrailingWhitespace for .md/.mdx.
const isMarkdown = /\.(md|mdx)$/i.test(file_path)
const normalizedNewString = isMarkdown ? new_string : stripTrailingWhitespace(new_string)

对 Markdown 文件跳过尾部空白剥离------Markdown 中行尾两个空格表示硬换行,剥离会改变语义。


11. getPatchForEdit:补丁生成

11.1 applyEditToFile

typescript 复制代码
export function applyEditToFile(
  originalContent: string,
  oldString: string,
  newString: string,
  replaceAll: boolean = false,
): string {
  const f = replaceAll
    ? (content, search, replace) => content.replaceAll(search, () => replace)
    : (content, search, replace) => content.replace(search, () => replace)

  if (newString !== '') {
    return f(originalContent, oldString, newString)
  }

  // 删除操作:智能处理尾部换行
  const stripTrailingNewline =
    !oldString.endsWith('\n') && originalContent.includes(oldString + '\n')
  return stripTrailingNewline
    ? f(originalContent, oldString + '\n', newString)
    : f(originalContent, oldString, newString)
}

使用 () => replace 而非直接传 replace 字符串,防止 replace 中的 $&$$ 等特殊模式被解释。

11.2 多编辑冲突检测

typescript 复制代码
// Check if old_string is a substring of any previously applied new_string
for (const previousNewString of appliedNewStrings) {
  if (oldStringToCheck !== '' && previousNewString.includes(oldStringToCheck)) {
    throw new Error('Cannot edit file: old_string is a substring of a new_string from a previous edit.')
  }
}

在多编辑场景中,如果一个编辑的 old_string 是前一个编辑 new_string 的子串,会抛出错误------因为前一个编辑已经改变了文件内容,后一个编辑的 old_string 可能匹配到刚写入的新内容而非原始内容。


12. 原书对照验证表

原书描述 源码验证 一致性 补充说明
双重时间戳校验 validateInput + call 两次 getFileModificationTime ✅ 一致 源码额外有 Content Fallback
Content Fallback(Windows 兼容) isFullRead && fileContent === readTimestamp.content ✅ 一致 原书提到 Windows 误报问题
同步原子写入 readFileSyncWithMetadata + writeTextContent(同步 API) ✅ 一致 注释明确警告避免 async 操作
read-before-write 检查 readFileState.get(fullFilePath) + isPartialView ✅ 一致 源码额外检查部分读取
九步执行管线步骤 4(工具自定义验证) validateInput 十道检查 ✅ 一致 源码更细粒度(10 道检查)
九步执行管线步骤 6(路径回填) backfillObservableInputexpandPath ✅ 一致 ---
九步执行管线步骤 8(权限决策) checkWritePermissionForTool 八步流水线 ✅ 一致 源码更细粒度(8 步)
确定性写入(设计模式) getPatchForEditapplyEditToFilewriteTextContent ✅ 一致 ---
Fail-Closed 默认值 buildTool 默认 isReadOnly: false ✅ 一致 ---
--- checkPathSafetyForAutoEdit 四道检查 📝 源码独有 原书未详细描述危险文件/目录检测
--- hasSuspiciousWindowsPathPattern 七种模式 📝 源码独有 原书未提及 Windows 路径攻击
--- 符号链接双重检查(getPathsForPermissionCheck 📝 源码独有 原书未详细描述符号链接防御
--- .claude/ 会话级 allow 规则旁路 📝 源码独有 原书未提及
--- 秘密扫描(checkTeamMemSecrets 📝 源码独有 原书未提及
--- 引号归一化(findActualString + preserveQuoteStyle 📝 源码独有 原书未提及
--- API 去消毒(DESANITIZATIONS 📝 源码独有 原书未提及
--- 大小写不敏感匹配(normalizeCaseForComparison 📝 源码独有 防止 .cLauDe 绕过

13. 验证清单

  • 六层防御是否真的是"输入验证→权限检查→沙箱隔离→路径约束→输出过滤→审计日志"? → 源码实际为六层:输入验证 + 权限决策 + 路径安全 + 路径约束 + 脏写防护 + 审计日志,与原书描述的六层概念一致但具体划分不同
  • 秘密扫描是否在文件写入前执行? → checkTeamMemSecretsvalidateInput 第一道检查执行,先于所有其他检查
  • 路径遍历防御是否处理了符号链接? → getPathsForPermissionCheck 返回原始路径和符号链接解析路径,所有检查对两组路径都执行
  • 双重时间戳校验是否在两个阶段执行? → ,validateInput 阶段预检 + call 阶段二次校验
  • 同步原子写入是否避免了 TOCTOU? → ,使用同步 API + 注释警告避免 async 操作
  • deny 规则是否优先于 allow 规则? → ,Step 1 检查 deny → Step 4 检查 allow
  • 危险文件/目录是否被保护? → ,10 个危险文件 + 4 个危险目录,大小写不敏感匹配
  • Windows 路径攻击是否被防御? → ,7 种 Windows 可疑路径模式检测
  • UNC 路径是否被防御? → ,validateInput 跳过 UNC 文件系统操作 + 权限检查拦截
  • read-before-write 是否检查部分读取? → isPartialView 检查

14. 设计哲学总结

14.1 纵深防御

FileEditTool 的六层防御覆盖了不同攻击向量:

  • 输入验证防御不合理编辑(空操作/未读取/不匹配)
  • 权限决策防御未授权编辑(deny/ask/allow 规则)
  • 路径安全防御危险文件编辑(配置文件/shell 脚本/IDE 配置)
  • 路径约束防御工作目录外编辑(符号链接/路径遍历)
  • 脏写防护防御并发编辑冲突(双重时间戳 + 同步写入)
  • 审计日志提供事后追溯能力

14.2 Fail-Closed 原则

每个检查的默认行为都是"拒绝"或"询问":

  • 文件未读取 → 拒绝编辑
  • 无匹配规则 → 询问用户
  • 路径在危险目录 → 询问用户
  • 路径在工作目录外 → 询问用户
  • 时间戳不匹配 → 拒绝编辑

14.3 防御不对称

deny 规则的优先级高于 allow 规则,体现了"安全优先"原则。即使用户同时配置了 Edit(.env): denyEdit(**): allow.env 文件仍然被拒绝。这与 BashTool 的 deny/allow 不对称剥离策略一脉相承。

14.4 平台兼容性与安全的平衡

Content Fallback 是这种平衡的典型例子------时间戳检测可能产生误报(Windows 云同步/杀毒),内容比较更准确但更昂贵。解决方案是:先尝试时间戳(快速),不匹配时再比较内容(准确),且仅对完整读取执行内容比较(避免部分读取的误报)。


下一篇:4.5 共享安全逻辑 --- tools/shared/ 工具共享权限检查

相关推荐
Cx330❀3 小时前
【MySQL基础】一文吃透“表的约束”:从 Null/Default 到主外键的终极安全法则
linux·服务器·数据库·c++·mysql·安全
醉颜凉3 小时前
“三把钥匙开一把锁”:多签钱包深度解析与资产安全提升指南
安全·区块链
Haoxuekeji3 小时前
山东 AI 智能批改校园电子阅卷企业
大数据·人工智能·深度学习·安全·ai
程序员JerrySUN3 小时前
Jetson 刷机深度解析:flash.sh vs l4t_initrd_flash.sh(含安全与磁盘加密对比)
linux·网络·arm开发·安全·系统安全
银河麒麟操作系统4 小时前
闸机高效稳跑,通勤提速升级!银河麒麟赋能北京轨交新基建
运维·服务器·安全·交通物流
鹿鸣天涯5 小时前
kali 2026.2 2026年第二个版本发布-新增 9 款工具并优化虚拟机启动性能
安全·web安全·kali
无忧智库5 小时前
中大型企业整体网络安全解决方案:从“边界防火墙”到安全运营闭环(PPT)
安全·web安全
Qimooidea6 小时前
祁木 CAD Translator 工程图纸出海实战指南
服务器·前端·安全
m0_738120726 小时前
AI安全实战系列(六):Lab06 Model Extraction——模型提取攻击
人工智能·安全·网络安全·语言模型·系统安全