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 事件循环可能在 read 和 write 之间插入其他操作(比如另一个并发工具也在写同一个文件),导致 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 中通过检查顺序实现:
- deny 规则(Step 1)--- 最高优先级,直接拒绝
- ask 规则(Step 2)--- 次高优先级,询问用户
- 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(路径回填) | backfillObservableInput → expandPath |
✅ 一致 | --- |
| 九步执行管线步骤 8(权限决策) | checkWritePermissionForTool 八步流水线 |
✅ 一致 | 源码更细粒度(8 步) |
| 确定性写入(设计模式) | getPatchForEdit → applyEditToFile → writeTextContent |
✅ 一致 | --- |
| Fail-Closed 默认值 | buildTool 默认 isReadOnly: false |
✅ 一致 | --- |
| --- | checkPathSafetyForAutoEdit 四道检查 |
📝 源码独有 | 原书未详细描述危险文件/目录检测 |
| --- | hasSuspiciousWindowsPathPattern 七种模式 |
📝 源码独有 | 原书未提及 Windows 路径攻击 |
| --- | 符号链接双重检查(getPathsForPermissionCheck) |
📝 源码独有 | 原书未详细描述符号链接防御 |
| --- | .claude/ 会话级 allow 规则旁路 |
📝 源码独有 | 原书未提及 |
| --- | 秘密扫描(checkTeamMemSecrets) |
📝 源码独有 | 原书未提及 |
| --- | 引号归一化(findActualString + preserveQuoteStyle) |
📝 源码独有 | 原书未提及 |
| --- | API 去消毒(DESANITIZATIONS) |
📝 源码独有 | 原书未提及 |
| --- | 大小写不敏感匹配(normalizeCaseForComparison) |
📝 源码独有 | 防止 .cLauDe 绕过 |
13. 验证清单
- 六层防御是否真的是"输入验证→权限检查→沙箱隔离→路径约束→输出过滤→审计日志"? → 源码实际为六层:输入验证 + 权限决策 + 路径安全 + 路径约束 + 脏写防护 + 审计日志,与原书描述的六层概念一致但具体划分不同
- 秘密扫描是否在文件写入前执行? → 是 ,
checkTeamMemSecrets在validateInput第一道检查执行,先于所有其他检查 - 路径遍历防御是否处理了符号链接? → 是 ,
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): deny 和 Edit(**): allow,.env 文件仍然被拒绝。这与 BashTool 的 deny/allow 不对称剥离策略一脉相承。
14.4 平台兼容性与安全的平衡
Content Fallback 是这种平衡的典型例子------时间戳检测可能产生误报(Windows 云同步/杀毒),内容比较更准确但更昂贵。解决方案是:先尝试时间戳(快速),不匹配时再比较内容(准确),且仅对完整读取执行内容比较(避免部分读取的误报)。
下一篇:4.5 共享安全逻辑 --- tools/shared/ 工具共享权限检查