《Claude Code 设计与实现》完整目录
- 前言
- 第1章 为什么需要理解 Claude Code
- 第2章 架构总览
- 第3章 CLI 启动与性能优化
- 第4章 Query 引擎:Agent 的心脏
- 第5章 流式消息与状态机
- 第6章 工具类型系统设计
- 第7章 工具编排与并发执行
- 第8章 核心工具实现剖析(当前)
- 第9章 多模式权限模型
- 第10章 Bash 安全与沙箱
- 第11章 MCP 协议集成
- 第12章 IDE Bridge 通信架构
- 第13章 LSP 与语言服务
- 第14章 多 Agent 协调与 Swarm
- 第15章 Skill 与插件系统
- 第16章 上下文管理与自动压缩
- 第17章 React + Ink 终端 UI
- 第18章 设计模式与架构决策
第8章 核心工具实现剖析
开篇引言
在 Claude Code 的整体架构中,工具(Tool)是 AI Agent 与外部世界交互的唯一桥梁。模型本身无法直接读写文件、执行命令或搜索代码------它是一个纯粹的文本推理引擎,只能通过声明式地调用工具来完成这些操作。因此,工具的实现质量直接决定了 Claude Code 的能力上限与安全下限。如果说模型是大脑,那么工具就是双手和眼睛,它们的精确性、安全性和性能表现决定了整个系统的实际效用。
Claude Code 的工具系统目前包含超过三十个工具,从最基础的文件读写到复杂的子 Agent 生成、从简单的文件搜索到安全的网页内容获取,覆盖了软件开发工作流中的方方面面。本章将深入剖析其中最核心的八个工具实现:BashTool(Shell 命令执行)、FileReadTool(文件读取)、FileEditTool(文件编辑)、FileWriteTool(文件写入)、GlobTool(文件模式匹配)、GrepTool(内容搜索)、AgentTool(子 Agent 生成)以及 WebFetchTool(网页内容获取)。我们不仅要理解每个工具"做什么",更要揭示其背后的设计决策------为什么要这样做,以及这些选择带来了哪些权衡。
每个工具的分析都将从三个维度展开:接口设计 (inputSchema/outputSchema 如何定义工具与模型之间的契约)、核心执行逻辑 (call 函数如何实现工具的实际功能)、安全与权限机制(checkPermissions/validateInput 如何构建多层防护)。读者将看到,这些看似独立的工具背后存在着高度统一的设计模式和工程理念,它们共同构成了 Claude Code 工具系统的骨架。掌握这些模式,将有助于读者理解如何为 AI Agent 系统设计安全、高效、可扩展的工具接口。
本章要点
- BashTool 是所有工具中最复杂的一个,它包含完整的命令解析、安全检测、沙箱执行、后台进程管理和输出截断机制
- 文件操作三件套(Read/Edit/Write)通过"先读后写"的强制约束和文件修改时间戳跟踪,构建了一套防止并发冲突的安全屏障
- FileEditTool 的字符串替换策略看似简单,实则包含引号规范化、唯一性校验等多层容错逻辑
- GrepTool 对 ripgrep 的封装不是简单的命令行包装,而是包含了结果排序、分页、输出模式切换等完整的搜索引擎语义
- AgentTool 通过工具子集限制、worktree 隔离和 fork 机制,实现了安全可控的多 Agent 协作
- WebFetchTool 采用域名预审批、LRU 缓存和二级模型摘要的三层架构处理网页内容
- 所有工具共享 buildTool 构建模式、lazySchema 延迟求值、expandPath 路径规范化等统一的基础设施
8.1 BashTool -- 最复杂的工具
下图展示了 BashTool 从接收命令到返回结果的完整执行流水线,涵盖安全检测、权限管理、沙箱执行等关键环节:
BashTool 是 Claude Code 工具系统中规模最大、复杂度最高的工具,也是功能最为强大的工具。一条 Shell 命令几乎可以做任何事情------安装依赖、编译代码、运行测试、操作版本控制系统、甚至删除整个文件系统。正因如此,BashTool 的实现必须在"赋予模型足够的能力"和"防止模型(或被诱导的模型)造成破坏"之间取得精确的平衡。
BashTool 的源码分布在十余个文件中(src/tools/BashTool/ 目录),涵盖命令解析、安全检测、权限管理、沙箱执行、后台进程、输出处理等完整的执行链条。这些文件各司其职、层层递进,共同构成了一个从命令提交到结果返回的完整流水线。理解 BashTool 的实现,就是理解 Claude Code 如何安全地让 AI 操控用户的计算机。
8.1.1 输入 Schema 与参数设计
BashTool 的输入 Schema 定义在 src/tools/BashTool/BashTool.tsx 中,使用 lazySchema 延迟构建以避免模块加载时的循环依赖:
typescript
// src/tools/BashTool/BashTool.tsx
const fullInputSchema = lazySchema(() => z.strictObject({
command: z.string().describe('The command to execute'),
timeout: semanticNumber(z.number().optional())
.describe(`Optional timeout in milliseconds (max ${getMaxTimeoutMs()})`),
description: z.string().optional()
.describe('Clear, concise description of what this command does in active voice.'),
run_in_background: semanticBoolean(z.boolean().optional())
.describe('Set to true to run this command in the background.'),
dangerouslyDisableSandbox: semanticBoolean(z.boolean().optional())
.describe('Set this to true to dangerously override sandbox mode'),
_simulatedSedEdit: z.object({
filePath: z.string(),
newContent: z.string()
}).optional().describe('Internal: pre-computed sed edit result from preview')
}));
这个设计有几个值得关注的要点。semanticNumber 和 semanticBoolean 是对 Zod 类型的增强包装器,它们允许模型传入语义等价但类型不精确的值(如字符串 "true" 被解析为布尔值 true),这提升了模型调用工具时的容错率。_simulatedSedEdit 是一个内部字段,用于 sed 命令编辑预览功能------用户在权限对话框中审批 sed 编辑后,该字段携带预计算的结果,确保最终写入的内容与用户预览的完全一致。出于安全考虑,这个字段被从模型可见的 Schema 中移除:
typescript
// src/tools/BashTool/BashTool.tsx
const inputSchema = lazySchema(() =>
isBackgroundTasksDisabled
? fullInputSchema().omit({ run_in_background: true, _simulatedSedEdit: true })
: fullInputSchema().omit({ _simulatedSedEdit: true })
);
如果将 _simulatedSedEdit 暴露给模型,攻击者可能构造恶意输入,绕过权限检查直接写入任意文件。同时注意,当环境变量 CLAUDE_CODE_DISABLE_BACKGROUND_TASKS 为真时,run_in_background 参数也会从 Schema 中移除------模型甚至无法感知到后台执行的存在。
8.1.2 安全解析与危险命令检测
BashTool 的安全检测是一个多层防御体系,其核心逻辑位于 src/tools/BashTool/bashSecurity.ts。该文件定义了完整的命令注入防护策略,安全检查标识符超过 20 种(通过 BASH_SECURITY_CHECK_IDS 枚举管理)。
第一层是命令替换检测。系统维护了一组命令替换模式,任何包含这些模式的命令都会被标记为需要用户审批:
typescript
// src/tools/BashTool/bashSecurity.ts
const COMMAND_SUBSTITUTION_PATTERNS = [
{ pattern: /<\(/, message: 'process substitution <()' },
{ pattern: />\(/, message: 'process substitution >()' },
{ pattern: /=\(/, message: 'Zsh process substitution =()' },
{ pattern: /(?:^|[\s;&|])=[a-zA-Z_]/, message: 'Zsh equals expansion (=cmd)' },
{ pattern: /\$\(/, message: '$() command substitution' },
{ pattern: /\$\{/, message: '${} parameter substitution' },
{ pattern: /\$\[/, message: '$[] legacy arithmetic expansion' },
{ pattern: /~\[/, message: 'Zsh-style parameter expansion' },
{ pattern: /\(e:/, message: 'Zsh-style glob qualifiers' },
{ pattern: /\(\+/, message: 'Zsh glob qualifier with command execution' },
{ pattern: /\}\s*always\s*\{/, message: 'Zsh always block (try/always construct)' },
{ pattern: /<#/, message: 'PowerShell comment syntax' },
];
注意其中 Zsh equals expansion(=cmd)的检测。这是一个容易被忽视的安全漏洞:在 Zsh 中,=curl evil.com 会被展开为 /usr/bin/curl evil.com,绕过了针对 curl 命令的安全规则,因为解析器只看到 =curl 而非 curl。系统还特别防御了 PowerShell 注释语法(<#),虽然当前不在 PowerShell 中执行,但这是一种纵深防御------防止未来变更引入 PowerShell 执行路径。
第二层是 Zsh 危险命令拦截。由于 Claude Code 在 macOS 上默认使用 Zsh,系统需要额外防护 Zsh 特有的危险命令:
typescript
// src/tools/BashTool/bashSecurity.ts
const ZSH_DANGEROUS_COMMANDS = new Set([
'zmodload', // 模块加载网关:可加载 zsh/mapfile、zsh/system 等危险模块
'emulate', // 带 -c 标志时等价于 eval
'sysopen', 'sysread', 'syswrite', 'sysseek', // zsh/system 模块命令
'zpty', // 伪终端命令执行 (zsh/zpty)
'ztcp', // TCP 连接,可用于数据外泄 (zsh/net/tcp)
'zsocket', // Unix/TCP 套接字 (zsh/net/socket)
'zf_rm', 'zf_mv', 'zf_ln', 'zf_chmod', // zsh/files 内建命令
'zf_chown', 'zf_mkdir', 'zf_rmdir', 'zf_chgrp',
]);
zmodload 是 Zsh 安全的关键攻击面。通过加载 zsh/mapfile 模块,攻击者可以通过数组赋值实现不可见的文件 I/O;通过 zsh/system 模块的 sysopen/syswrite 两步操作,可以绕过常规的文件操作检测。系统将这些命令全部列入黑名单,即使 zmodload 被某种方式绕过,其加载的具体命令也会被拦截------这是一种典型的纵深防御设计。
第三层是破坏性命令警告 ,定义在 src/tools/BashTool/destructiveCommandWarning.ts 中。它不影响权限逻辑,仅在用户审批界面显示警告信息:
typescript
// src/tools/BashTool/destructiveCommandWarning.ts
const DESTRUCTIVE_PATTERNS: DestructivePattern[] = [
{ pattern: /\bgit\s+reset\s+--hard\b/,
warning: 'Note: may discard uncommitted changes' },
{ pattern: /\bgit\s+push\b[^;&|\n]*[ \t](--force|--force-with-lease|-f)\b/,
warning: 'Note: may overwrite remote history' },
{ pattern: /\bgit\s+clean\b(?![^;&|\n]*(?:-[a-zA-Z]*n|--dry-run))[^;&|\n]*-[a-zA-Z]*f/,
warning: 'Note: may permanently delete untracked files' },
// 数据库操作、Kubernetes 操作等...
];
注意 git clean 的正则中包含了对 --dry-run 的负向前瞻------如果用户只是在预览 clean 效果(带 -n 或 --dry-run 标志),则不会触发警告。这类细节体现了工程团队对真实使用场景的深度理解。
8.1.3 复合命令权限检查
当用户或模型提交的命令包含管道(|)、逻辑连接符(&&、||)或分号(;)时,BashTool 需要对每个子命令分别进行权限检查。这个逻辑位于 src/tools/BashTool/bashCommandHelpers.ts:
typescript
// src/tools/BashTool/bashCommandHelpers.ts
async function bashToolCheckCommandOperatorPermissions(
input, bashToolHasPermissionFn, checkers, parsed
): Promise<PermissionResult> {
// 1. 检查是否包含不安全的复合命令(子 shell、命令组)
const tsAnalysis = parsed.getTreeSitterAnalysis();
const isUnsafeCompound = tsAnalysis
? tsAnalysis.compoundStructure.hasSubshell ||
tsAnalysis.compoundStructure.hasCommandGroup
: isUnsafeCompoundCommand_DEPRECATED(input.command);
if (isUnsafeCompound) {
return { behavior: 'ask', /* 需要用户审批 */ };
}
// 2. 获取管道分段(使用 ParsedCommand 保留引号信息)
const pipeSegments = parsed.getPipeSegments();
if (pipeSegments.length <= 1) {
return { behavior: 'passthrough', message: 'No pipes found' };
}
// 3. 移除输出重定向后,逐段检查权限
const segments = await Promise.all(
pipeSegments.map(segment => buildSegmentWithoutRedirections(segment))
);
return segmentedCommandPermissionResult(
input, segments, bashToolHasPermissionFn, checkers
);
}
系统使用 Tree-sitter 解析器来分析命令结构。如果 Tree-sitter 不可用,则回退到基于正则表达式的遗留检测方案(函数名中的 _DEPRECATED 后缀表明这是过渡期的兼容设计)。buildSegmentWithoutRedirections 函数在检查权限前去除输出重定向,避免将重定向目标文件名误判为命令。
一个特别重要的安全检查是跨段的 cd + git 组合检测:
typescript
// src/tools/BashTool/bashCommandHelpers.ts
// 安全要点:检测跨管道段的 cd+git 模式,防止 bare repo fsmonitor 绕过攻击
// 当 cd 和 git 位于不同的管道段时(如 "cd sub && echo | git status"),
// 每个段独立检查均可通过,但合在一起可能导致 bare repo 攻击
let hasCd = false;
let hasGit = false;
for (const segment of segments) {
const subcommands = splitCommand_DEPRECATED(segment);
for (const sub of subcommands) {
if (checkers.isNormalizedCdCommand(sub.trim())) hasCd = true;
if (checkers.isNormalizedGitCommand(sub.trim())) hasGit = true;
}
}
if (hasCd && hasGit) {
return { behavior: 'ask', /* 要求用户审批 */ };
}
这个检查防止的是一种真实的攻击向量:攻击者可以在 git 仓库中放置恶意的 .git/config,配置 fsmonitor 钩子指向恶意脚本。如果 cd 和 git 操作被分别检查且各自通过,合在一起就可能导致进入恶意目录后执行 git 命令,触发恶意钩子。
8.1.4 Shell 命令执行机制
BashTool 的命令执行涉及两个层次:上层的 runShellCommand 生成器函数(位于 BashTool.tsx)和底层的 exec 函数(位于 src/utils/Shell.ts)。
底层 exec 函数负责实际的进程创建。它首先通过 ShellProvider(src/utils/shell/bashProvider.ts)构建完整的执行命令,然后使用 Node.js 的 child_process.spawn 创建子进程:
typescript
// src/utils/Shell.ts
export async function exec(
command: string, abortSignal: AbortSignal,
shellType: ShellType, options?: ExecOptions,
): Promise<ShellCommand> {
const provider = await resolveProvider[shellType]();
const id = Math.floor(Math.random() * 0x10000).toString(16).padStart(4, '0');
const { commandString, cwdFilePath } =
await provider.buildExecCommand(command, {
id, sandboxTmpDir, useSandbox: shouldUseSandbox ?? false,
});
// 如果需要沙箱,包装命令
if (shouldUseSandbox) {
commandString = await SandboxManager.wrapWithSandbox(
commandString, sandboxBinShell, undefined, abortSignal,
);
}
const childProcess = spawn(spawnBinary, shellArgs, {
env: { ...subprocessEnv(), SHELL: binShell, GIT_EDITOR: 'true',
CLAUDECODE: '1', ...envOverrides },
cwd,
stdio: usePipeMode
? ['pipe', 'pipe', 'pipe']
: ['pipe', outputHandle?.fd, outputHandle?.fd],
detached: provider.detached,
windowsHide: true,
});
return wrapSpawn(childProcess, abortSignal, commandTimeout,
taskOutput, shouldAutoBackground);
}
bashProvider 中的 buildExecCommand 方法负责构建完整的命令字符串。这个过程包含多个精心设计的步骤:
typescript
// src/utils/shell/bashProvider.ts
async buildExecCommand(command, opts) {
const commandParts: string[] = [];
// 1. 加载 Shell 环境快照(避免每次执行都运行完整的 login shell 初始化)
if (snapshotFilePath) {
commandParts.push(`source ${quote([finalPath])} 2>/dev/null || true`);
}
// 2. 加载会话环境变量
const sessionEnvScript = await getSessionEnvironmentScript();
if (sessionEnvScript) commandParts.push(sessionEnvScript);
// 3. 禁用扩展 glob 模式(安全防护)
const disableExtglobCmd = getDisableExtglobCommand(shellPath);
if (disableExtglobCmd) commandParts.push(disableExtglobCmd);
// 4. 用 eval 包装用户命令(使别名在加载后可用)
commandParts.push(`eval ${quotedCommand}`);
// 5. 记录执行后的工作目录
commandParts.push(`pwd -P >| ${quote([shellCwdFilePath])}`);
return { commandString: commandParts.join(' && '), cwdFilePath };
}
Shell 环境快照机制是一个关键优化。系统在启动时创建一次完整的 Shell 环境快照(通过 createAndSaveSnapshot),后续每条命令只需 source 这个快照文件,而非每次都启动 login shell(-l 标志)。当快照文件不存在时(如被临时目录清理删除),access() 检查会检测到并自动回退到 login shell 模式。源码注释中特别解释了为什么这个 access() 检查不是纯 TOCTOU(检查时间/使用时间竞态)问题------它是 getSpawnArgs 的回退决策点,没有它,source ... || true 会静默失败,导致命令在既没有快照环境也没有 login profile 的空环境中运行。
getDisableExtglobCommand 函数为每条命令禁用扩展 glob 模式(bash 的 extglob,zsh 的 EXTENDED_GLOB)。这是一个安全措施:恶意文件名可能包含 glob 模式字符,在安全验证之后、实际执行时展开,绕过验证逻辑。当设置了 CLAUDE_CODE_SHELL_PREFIX(即实际执行 shell 可能不是 shellPath 指定的 shell)时,同时发出 bash 和 zsh 两种禁用命令,并将 stdout 和 stderr 都重定向到 /dev/null(因为 zsh 的 command_not_found_handler 会写到 stdout 而非 stderr)。
值得注意的是 stdout/stderr 的捕获方式。在标准模式下,两个流都重定向到同一个文件描述符:
typescript
// src/utils/Shell.ts
stdio: usePipeMode
? ['pipe', 'pipe', 'pipe'] // Hook 模式:分离的 pipe
: ['pipe', outputHandle?.fd, outputHandle?.fd], // 标准模式:共享文件 fd
在 POSIX 系统上,O_APPEND 标志确保每次写入都是原子的(先 seek-to-end 再 write),因此 stdout 和 stderr 按时间顺序交错而不会撕裂。在 Windows 上,由于文件句柄共享同一个 FILE_OBJECT 且带有 FILE_SYNCHRONOUS_IO_NONALERT 标志,所有 I/O 通过单一内核锁串行化,同样保证了原子性。但 Windows 上不能使用 'a'(append)模式打开文件,因为 libuv 的 fs__open 在 append 模式下会剥离 FILE_WRITE_DATA 权限,导致 MSYS2/Cygwin 的子进程将句柄视为只读并丢弃所有输出。安全方面,O_NOFOLLOW 标志防止沙箱内的符号链接跟随攻击。
8.1.5 后台进程支持
BashTool 的后台进程管理有三种触发路径,上层的 runShellCommand 生成器函数(BashTool.tsx)统一处理:
typescript
// src/tools/BashTool/BashTool.tsx
async function* runShellCommand({ input, abortController, ... }) {
const { command, timeout, run_in_background } = input;
const timeoutMs = timeout || getDefaultTimeoutMs();
const shellCommand = await exec(command, abortController.signal, 'bash', {
timeout: timeoutMs,
onProgress(lastLines, allLines, totalLines, totalBytes, isIncomplete) {
lastProgressOutput = lastLines;
fullOutput = allLines;
resolveProgress?.(); // 唤醒生成器 yield 新进度
},
shouldUseSandbox: shouldUseSandbox(input),
shouldAutoBackground
});
// 路径一:模型显式请求后台执行
if (run_in_background === true && !isBackgroundTasksDisabled) {
const shellId = await spawnBackgroundTask();
return { stdout: '', stderr: '', code: 0, interrupted: false,
backgroundTaskId: shellId };
}
// 路径二:命令超时后自动后台化
if (shellCommand.onTimeout && shouldAutoBackground) {
shellCommand.onTimeout(backgroundFn => {
startBackgrounding('tengu_bash_command_timeout_backgrounded', backgroundFn);
});
}
// 路径三:Assistant 模式下主线程阻塞超过 15 秒
if (getKairosActive() && isMainThread) {
setTimeout(() => {
if (shellCommand.status === 'running') {
assistantAutoBackgrounded = true;
startBackgrounding('tengu_bash_command_assistant_auto_backgrounded');
}
}, ASSISTANT_BLOCKING_BUDGET_MS).unref(); // 15 秒
}
}
startBackgrounding 内部的实现需要处理一个微妙的竞态条件。如果前台任务已经通过 registerForeground 注册,系统会通过 backgroundExistingForegroundTask 将其原地转为后台,而非重新 spawn。重新 spawn 会覆盖 tasks[taskId]、发出重复的 task_started SDK 事件,并泄露第一个清理回调。此外,后台化完成后还需要手动唤醒生成器的 Promise.race(通过调用 resolveProgress),否则当共享轮询器停止发送 tick 且进程挂在 I/O 上时,生成器会死锁。
进度报告使用 AsyncGenerator 模式实现。命令开始后,系统等待 2 秒(PROGRESS_THRESHOLD_MS):如果命令在此期间完成,直接返回结果;否则启动进度轮询。TaskOutput 类通过共享轮询器每秒检查输出文件的变化,触发 onProgress 回调唤醒生成器 yield 进度更新。自动后台化仅适用于"安全"的命令------sleep 等命令被排除在外(DISALLOWED_AUTO_BACKGROUND_COMMANDS),因为它们应该在前台运行除非用户明确请求后台。
8.1.6 命令语义解释
src/tools/BashTool/commandSemantics.ts 实现了对不同命令退出码的语义化解释。许多常见命令使用非零退出码传递正常信息(如 grep 返回 1 表示未找到匹配),而非表示错误:
typescript
// src/tools/BashTool/commandSemantics.ts
const COMMAND_SEMANTICS: Map<string, CommandSemantic> = new Map([
['grep', (exitCode) => ({
isError: exitCode >= 2,
message: exitCode === 1 ? 'No matches found' : undefined,
})],
['rg', (exitCode) => ({ // ripgrep 与 grep 语义相同
isError: exitCode >= 2,
message: exitCode === 1 ? 'No matches found' : undefined,
})],
['diff', (exitCode) => ({
isError: exitCode >= 2,
message: exitCode === 1 ? 'Files differ' : undefined,
})],
['find', (exitCode) => ({
isError: exitCode >= 2,
message: exitCode === 1 ? 'Some directories were inaccessible' : undefined,
})],
]);
对于复合命令,系统通过 heuristicallyExtractBaseCommand 提取最后一个子命令的名称来确定语义规则,因为管道链的退出码由最后一个命令决定。这种语义化解释避免了模型误将正常的搜索结果"无匹配"当作命令执行失败。
8.1.7 输出捕获与截断
BashTool 的输出处理包含图像检测、大输出持久化和截断三个层面。src/tools/BashTool/utils.ts 中的 formatOutput 函数负责截断逻辑:
typescript
// src/tools/BashTool/utils.ts
export function formatOutput(content: string) {
const isImage = isImageOutput(content); // 检测 data:image/... 前缀
if (isImage) return { totalLines: 1, truncatedContent: content, isImage };
const maxOutputLength = getMaxOutputLength(); // 默认 30000 字符
if (content.length <= maxOutputLength) {
return { totalLines: countCharInString(content, '\n') + 1,
truncatedContent: content };
}
const truncatedPart = content.slice(0, maxOutputLength);
const remainingLines = countCharInString(content, '\n', maxOutputLength) + 1;
return {
totalLines: countCharInString(content, '\n') + 1,
truncatedContent: `${truncatedPart}\n\n... [${remainingLines} lines truncated] ...`,
};
}
输出长度限制通过 src/utils/shell/outputLimits.ts 配置。默认 30000 字符,上限 150000 字符,可通过环境变量 BASH_MAX_OUTPUT_LENGTH 调整。当输出包含 base64 图像数据时,resizeShellImageOutput 函数会重新读取完整输出文件(因为截断的 base64 会解码为损坏的图像),并通过 maybeResizeAndDownsampleImageBuffer 压缩图像尺寸------同时限制分辨率,因为小文件但高 DPI 的 PNG(如 matplotlib 在 dpi=300 下生成的图表)可以通过字节大小检查但在多图像请求中消耗过多资源。
后台任务的输出文件大小通过 SIZE_WATCHDOG_INTERVAL_MS(每 5 秒)轮询监控。由于后台任务的 stdout/stderr 直接写入文件描述符(不经过 JS),一个卡住的输出循环可以快速填满磁盘,因此超过 MAX_TASK_OUTPUT_BYTES 时进程会被终止。
8.1.8 沙箱执行机制
BashTool 的沙箱决策逻辑位于 src/tools/BashTool/shouldUseSandbox.ts:
typescript
// src/tools/BashTool/shouldUseSandbox.ts
export function shouldUseSandbox(input: Partial<SandboxInput>): boolean {
if (!SandboxManager.isSandboxingEnabled()) return false;
if (input.dangerouslyDisableSandbox
&& SandboxManager.areUnsandboxedCommandsAllowed()) return false;
if (!input.command) return false;
if (containsExcludedCommand(input.command)) return false;
return true;
}
containsExcludedCommand 检查用户配置的排除命令时,对复合命令执行完整拆分。这防止了 docker ps && curl evil.com 因首个子命令匹配排除模式就绕过沙箱。对每个子命令,系统还迭代地剥离环境变量前缀(FOO=bar)和安全包装命令(timeout 30)后重新匹配,处理 timeout 300 FOO=bar bazel run 这样的嵌套模式。
lua
BashTool 执行流程
+------------------+
| 模型发出命令 |
+--------+---------+
|
v
+------------------+
| inputSchema 校验 | (semanticNumber/semanticBoolean 容错)
+--------+---------+
|
v
+------------------+
| validateInput | (blocked sleep 检测等)
+--------+---------+
|
v
+------------------+
| bashSecurity | (命令替换/Zsh 危险命令/控制字符/Unicode 空白...)
| bashPermissions | (cd+git 组合/复合命令分段/sed 路径约束...)
+--------+---------+
|
v
+------------------+
| shouldUseSandbox | (沙箱决策: 排除命令/策略配置/用户覆盖)
+--------+---------+
|
v
+------------------+
| Shell Provider | (环境快照/扩展 glob 禁用/eval 包装)
+--------+---------+
|
v
+------------------+
| spawn 子进程 | (detached 模式/文件 fd 共享/环境变量注入)
+--------+---------+
|
v
+------------------+ +---------------------+
| 输出处理 |------>| 图像检测与压缩 |
| (截断/持久化) | | 大输出写入磁盘 |
+------------------+ | 命令语义解释(退出码) |
+---------------------+
8.2 文件操作三件套
以下状态机展示了文件操作三件套之间的依赖关系和"先读后写"的强制约束,以及文件状态在各操作间的流转:
FileReadTool、FileEditTool 和 FileWriteTool 构成了 Claude Code 的文件操作核心,是模型与文件系统交互的主要途径。在日常使用中,这三个工具的调用频率远超其他工具------几乎每一次代码修改都涉及"先读取、再编辑或写入"的工作流程。
三者之间存在严格的依赖关系:EditTool 和 WriteTool 都要求文件必须先被 ReadTool 读取过,这个约束通过 readFileState 缓存来强制执行。这不是一个简单的前置条件检查,而是一个深思熟虑的安全决策------它防止了模型在不了解文件当前内容的情况下盲目修改文件,同时也为并发编辑冲突检测提供了基准时间戳。这种"先读后写"的模式借鉴了数据库事务中"乐观锁"的思想,在不引入重量级锁机制的前提下实现了基本的一致性保证。
8.2.1 FileReadTool -- 多格式文件读取
FileReadTool 是整个工具链的起点。它的设计目标不仅仅是"读取文件内容",而是要成为一个智能的文件内容提供者------能够根据文件类型自动选择最佳的呈现方式,在保持信息完整性的同时控制输出的 token 消耗。
FileReadTool 的实现位于 src/tools/FileReadTool/FileReadTool.ts,它支持文本文件、图片、PDF、Jupyter Notebook 等多种格式。输入 Schema 包含了 offset/limit 分页参数和 PDF 专用的 pages 参数:
typescript
// src/tools/FileReadTool/FileReadTool.ts
const inputSchema = lazySchema(() => z.strictObject({
file_path: z.string().describe('The absolute path to the file to read'),
offset: semanticNumber(z.number().int().nonnegative().optional())
.describe('The line number to start reading from'),
limit: semanticNumber(z.number().int().positive().optional())
.describe('The number of lines to read'),
pages: z.string().optional()
.describe('Page range for PDF files (e.g., "1-5")'),
}));
输出 Schema 使用了 Zod 的 discriminatedUnion(带标签的联合类型),通过 type 字段区分五种不同的返回格式:text(普通文本)、image(图片 base64)、notebook(Jupyter 单元格)、pdf(PDF 页面)和 file_unchanged(文件未变更的轻量 stub)。这是一个关键的设计选择------相比简单的 union 类型,discriminatedUnion 让模型可以根据返回的 type 字段直接判断内容格式和后续处理方式。
文件未变去重 是一个重要的优化。当同一文件被重复读取且内容未变时,系统返回 file_unchanged 类型的轻量级 stub 而非重新发送完整内容。这个机制直接节省了 API 的 cache_creation token 消耗。注意系统只对来自 Read 工具设置的状态(offset !== undefined)进行去重,而 Edit/Write 写入的状态不参与去重,因为它们反映的是编辑后的内容,去重可能导致模型引用到过时信息。
Token 限制与文件大小守卫 采用双重防线设计,定义在 src/tools/FileReadTool/limits.ts 中:
typescript
// src/tools/FileReadTool/limits.ts
// 两个限制并行生效:
// maxSizeBytes: 256KB -- stat 系统调用检查总文件大小,读取前拦截,成本极低
// maxTokens: 25000 -- API 调用计算实际输出 token 数,读取后检查,更精确
export const DEFAULT_MAX_OUTPUT_TOKENS = 25000;
源码注释中记录了一次有意义的技术决策回退:团队曾测试过在超出字节限制时截断输出(而非抛出错误),结果发现虽然工具错误率下降了,但平均 token 消耗反而上升了------因为抛出错误只生成约 100 字节的 tool_result,而截断仍然输出约 25K token 的内容。这种基于数据的决策回退是工程实践中值得借鉴的做法。
FileReadTool 还维护了一个设备文件黑名单 ,防止读取会导致进程挂起的特殊文件(如 /dev/zero 的无限输出、/dev/stdin 的等待输入),以及一个macOS 截图路径兼容处理------不同版本的 macOS 在截图文件名中的 AM/PM 前使用不同的空格字符(普通空格 vs U+202F 窄不换行空格),系统会在文件不存在时自动尝试替代路径。
8.2.2 FileEditTool -- 字符串替换的艺术
FileEditTool 是 Claude Code 中最富创造力的设计之一。传统的文件编辑工具通常基于行号定位或正则表达式替换,但这些方案在 AI Agent 场景下存在固有缺陷:行号可能因为之前的编辑而发生偏移,正则表达式则过于强大容易造成意外替换。Claude Code 选择了一种看似简单却非常稳健的方案------基于精确字符串匹配的替换。模型只需指定要替换的原始文本和新文本,系统负责在文件中找到精确匹配并执行替换。这种设计让编辑操作具有天然的幂等性和可预测性。
核心输入非常简洁(src/tools/FileEditTool/types.ts):
typescript
// 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 (must be different from old_string)'),
replace_all: semanticBoolean(z.boolean().default(false).optional())
.describe('Replace all occurrences of old_string (default false)'),
}));
但简洁的接口背后是复杂的验证和容错逻辑。
唯一性校验 是 FileEditTool 最重要的安全机制。当 old_string 在文件中出现多次且 replace_all 为 false 时,编辑会被拒绝。这个设计防止了模型在不确定替换目标时进行"盲目替换"。当存在多个匹配时,模型必须提供更多上下文使 old_string 在文件中唯一,或者明确声明替换所有实例。
引号规范化 处理了一个微妙的跨平台问题。许多文件使用弯引号(curly quotes),但 Claude 模型输出的总是直引号(straight quotes)。src/tools/FileEditTool/utils.ts 中的 findActualString 函数会先尝试精确匹配,失败后尝试引号规范化匹配:
typescript
// src/tools/FileEditTool/utils.ts
export function findActualString(
fileContent: string, searchString: string
): string | null {
// 优先尝试精确匹配
if (fileContent.includes(searchString)) return searchString;
// 尝试引号规范化匹配:将弯引号统一为直引号后再搜索
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;
}
配套的 preserveQuoteStyle 函数确保在替换时保持文件原有的引号风格------如果文件使用弯引号,替换后的新文本也会使用弯引号。
文件修改时间戳检查防止了编辑冲突。在执行编辑前,系统会检查文件的最后修改时间是否晚于最近一次读取时间。注意系统在 Windows 上的特殊处理:由于云同步、杀毒软件等因素可能导致文件时间戳变化但内容不变,系统会在完整读取的情况下回退到内容比较,避免误报。
FileEditTool 的执行逻辑(src/tools/FileEditTool/FileEditTool.ts)还集成了 LSP 通知和 Git diff 计算。编辑完成后,系统通知 LSP 服务器文件内容变更(didChange)和文件保存(didSave),触发语言服务器重新计算诊断信息。文件大小上限为 1 GiB(MAX_EDIT_FILE_SIZE),这是 V8/Bun 的字符串长度极限(约 2^30 字符)的安全近似值。
8.2.3 FileWriteTool -- 全文写入
FileWriteTool(src/tools/FileWriteTool/FileWriteTool.ts)相比 FileEditTool 更为简单直接,它接受完整的文件内容并一次性写入。其核心价值在于创建新文件或完整重写现有文件。
FileWriteTool 的 prompt 明确引导模型优先使用 EditTool。这段 prompt 是工具引导模型行为的典型范例------通过在工具描述中嵌入使用指南,系统可以在不修改模型本身的情况下影响其工具选择策略。
FileWriteTool 的"先读后写"验证尤其严格。对于已存在的文件,它不仅检查 readFileState 中是否有记录,还检查该记录是否为完整读取(isPartialView 为 false):
typescript
// src/tools/FileWriteTool/FileWriteTool.ts
const readTimestamp = toolUseContext.readFileState.get(fullFilePath);
if (!readTimestamp || readTimestamp.isPartialView) {
return {
result: false,
message: 'File has not been read yet. Read it first before writing to it.',
errorCode: 2,
};
}
写入时,系统有意不重写行尾符号。源码注释解释了原因:写入操作是全内容替换,模型发送的 content 中包含了明确的行尾符。之前的实现会保留旧文件的行尾风格或采样仓库中的主流行尾符,但这导致了实际问题------例如在 Linux 上覆写一个 CRLF 文件时引入了错误的 \r,或者 cwd 中的二进制文件干扰了仓库采样。最终决策是始终使用 LF 行尾,让模型控制行尾风格。
lua
文件操作三件套的协作关系
FileReadTool FileEditTool FileWriteTool
+---------------+ +----------------+ +----------------+
| 读取文件内容 | | 字符串精确替换 | | 全文覆写 |
| 更新 | | old_string -> | | 整体写入新内容 |
| readFileState | | new_string | | |
+-------+-------+ +--------+-------+ +--------+-------+
| | |
| readFileState | readFileState | readFileState
+--------> 检查文件是否已读取 <---------+------------>检查 <---+
检查修改时间戳
检查是否被外部修改
8.3 搜索工具
8.3.1 GlobTool -- 文件模式匹配
在大型代码库中,快速定位文件是一个高频需求。GlobTool(src/tools/GlobTool/GlobTool.ts)提供了快速的文件名模式匹配能力,是模型在面对"找到所有测试文件"、"定位配置文件"等任务时的首选工具。相比通过 BashTool 执行 find 命令,GlobTool 具有更好的权限控制、更一致的跨平台行为和更紧凑的输出格式。
GlobTool 的底层实现(src/utils/glob.ts)有一个巧妙之处------它实际上也使用 ripgrep 作为执行引擎。extractGlobBaseDirectory 函数从 glob 模式中提取静态基础目录(第一个通配符之前的部分),然后将 ripgrep 限定在该目录下搜索:
typescript
// src/utils/glob.ts
export function extractGlobBaseDirectory(pattern: string) {
const globChars = /[*?[{]/;
const match = pattern.match(globChars);
if (!match || match.index === undefined) {
return { baseDir: dirname(pattern), relativePattern: basename(pattern) };
}
const staticPrefix = pattern.slice(0, match.index);
const lastSepIndex = Math.max(
staticPrefix.lastIndexOf('/'), staticPrefix.lastIndexOf(sep)
);
// ...
}
GlobTool 有几个重要的设计选择。第一,结果数量默认限制为 100 个文件(可通过 globLimits.maxResults 覆盖),防止模式过于宽泛时返回海量结果占用上下文窗口。第二,路径会被转换为相对路径(toRelativePath)以节省 token。第三,GlobTool 声明了 isConcurrencySafe() 和 isReadOnly() 均返回 true,这意味着多个 GlobTool 调用可以并发执行且不会被序列化。
8.3.2 GrepTool -- ripgrep 集成
如果说 GlobTool 回答的是"哪些文件存在",那么 GrepTool 回答的是"哪些文件包含特定内容"。GrepTool(src/tools/GrepTool/GrepTool.ts)是 Claude Code 中功能最丰富的搜索工具,底层封装了 ripgrep(rg)。之所以选择 ripgrep 而非系统自带的 grep,是因为 ripgrep 默认尊重 .gitignore 规则、支持 Unicode、搜索速度极快,且在所有主流操作系统上行为一致。
ripgrep 的集成方式值得关注。src/utils/ripgrep.ts 中的 getRipgrepConfig 函数定义了三层回退策略:
typescript
// src/utils/ripgrep.ts
const getRipgrepConfig = memoize((): RipgrepConfig => {
// 1. 如果用户希望使用系统 ripgrep
if (userWantsSystemRipgrep) {
const { cmd: systemPath } = findExecutable('rg', []);
if (systemPath !== 'rg') {
// 安全:使用命令名 'rg' 而非 systemPath,防止 PATH 劫持
return { mode: 'system', command: 'rg', args: [] };
}
}
// 2. 原生编译模式:ripgrep 静态编译到 bun-internal 中
if (isInBundledMode()) {
return { mode: 'embedded', command: process.execPath,
args: ['--no-config'], argv0: 'rg' };
}
// 3. 回退到 vendor 目录中的预编译二进制
return { mode: 'builtin', command: vendorPath, args: ['--no-config'] };
});
在 bundled 模式下,ripgrep 被静态编译到 Bun 运行时中,通过 argv0='rg' 让进程以 ripgrep 身份执行。这种内嵌方式消除了外部依赖,同时通过 --no-config 标志确保不会加载用户的 ripgrep 配置文件。
GrepTool 提供了三种输出模式:
files_with_matches(默认):只返回匹配文件的路径列表,按修改时间降序排序content:返回匹配行的实际内容,支持-A/-B/-C上下文参数和行号显示count:返回每个文件中的匹配次数,附带总计统计
分页机制 是 GrepTool 的关键设计。默认 head_limit 为 250,防止无限制的搜索结果填满上下文窗口。applyHeadLimit 函数仅在截断实际发生时才设置 appliedLimit,这让模型知道可能还有更多结果可以通过调整 offset 来获取,形成了一种隐式的分页协议。模型可以传入 head_limit=0 作为无限制的逃生通道,但 prompt 中建议"谨慎使用------大结果集浪费上下文"。
在 files_with_matches 模式下,结果排序使用 Promise.allSettled 而非 Promise.all,确保单个文件的 stat 失败不会导致整个搜索失败。GrepTool 在构建 ripgrep 参数时,自动排除版本控制系统目录(.git、.svn、.hg、.bzr、.jj、.sl)并限制行长度为 500 字符(--max-columns 500),防止 minified JavaScript 或 base64 编码数据等超长行占用大量 token。一个性能优化细节:在 content 模式下,applyHeadLimit 在路径相对化之前执行,避免对即将被丢弃的行做无用的字符串处理。
8.4 AgentTool -- 子 Agent 生成
AgentTool 的子 Agent 生成涉及工具过滤、上下文继承和隔离机制等多个环节,下图展示了从父 Agent 创建子 Agent 的完整架构:
AgentTool 是 Claude Code 实现多 Agent 协作的核心工具,也是整个工具系统中概念最为复杂的工具之一。它的存在使得 Claude Code 从一个单线程的命令执行器进化为一个可以并行处理多任务的协作系统。AgentTool 允许主 Agent 生成子 Agent 来并行处理复杂任务,每个子 Agent 拥有独立的对话上下文和工具集,可以在不同的工作目录中独立运行。这种设计本质上是将计算机科学中"进程分叉"的概念引入了 AI Agent 领域,但需要处理更多维度的隔离与共享问题------上下文窗口的继承与裁剪、工具权限的收窄、文件系统的隔离、以及 prompt cache 的复用。
8.4.1 工具子集限制
子 Agent 的工具集是被严格限制的。src/tools/AgentTool/agentToolUtils.ts 中的 filterToolsForAgent 函数负责过滤:
typescript
// src/tools/AgentTool/agentToolUtils.ts
export function filterToolsForAgent({
tools, isBuiltIn, isAsync, permissionMode
}: { tools: Tools; isBuiltIn: boolean;
isAsync?: boolean; permissionMode?: PermissionMode }): Tools {
return tools.filter(tool => {
if (tool.name.startsWith('mcp__')) return true; // MCP 工具始终可用
if (ALL_AGENT_DISALLOWED_TOOLS.has(tool.name)) return false;
if (!isBuiltIn && CUSTOM_AGENT_DISALLOWED_TOOLS.has(tool.name)) return false;
if (isAsync && !ASYNC_AGENT_ALLOWED_TOOLS.has(tool.name)) return false;
return true;
});
}
这个过滤机制分为三层:ALL_AGENT_DISALLOWED_TOOLS 是所有 Agent 都不可用的工具集(如 AgentTool 自身------防止无限递归);CUSTOM_AGENT_DISALLOWED_TOOLS 额外限制自定义 Agent(但不限制内建 Agent);异步 Agent 则只能使用 ASYNC_AGENT_ALLOWED_TOOLS 中的工具子集。MCP 工具是例外------它们始终对所有 Agent 可用,因为 MCP 服务器已经通过自身的权限体系控制了访问。
resolveAgentTools 函数更进一步,支持基于 Agent 定义的精确工具列表和黑名单。对于 Agent 工具规格中的 Agent(x,y) 语法,系统会解析出 allowedAgentTypes,限制子 Agent 只能生成指定类型的孙 Agent。当 isMainThread 为 true 时(主线程代理模式),跳过 filterToolsForAgent 过滤,因为主线程的工具池已经由 useMergedTools() 正确组装。
8.4.2 Fork 机制与上下文继承
AgentTool 的 Fork 子 Agent 功能(src/tools/AgentTool/forkSubagent.ts)是一种独特的 Agent 生成方式。与传统的子 Agent(从零上下文开始)不同,Fork 子 Agent 继承父 Agent 的完整对话上下文:
typescript
// src/tools/AgentTool/forkSubagent.ts
export const FORK_AGENT = {
agentType: FORK_SUBAGENT_TYPE,
tools: ['*'], // 继承父 Agent 的完整工具集
maxTurns: 200,
model: 'inherit', // 使用与父 Agent 相同的模型(共享 prompt cache)
permissionMode: 'bubble', // 权限请求冒泡到父终端
source: 'built-in',
getSystemPrompt: () => '', // fork 直接传递父 Agent 渲染后的系统提示
} satisfies BuiltInAgentDefinition;
Fork 的关键优势在于 prompt cache 共享 。由于 Fork 子 Agent 使用相同的模型和系统提示,它可以复用父 Agent 的 prompt cache,避免重新计算 cache creation token。这里 getSystemPrompt: () => '' 不是疏忽------Fork 路径通过 toolUseContext.renderedSystemPrompt 直接传递父 Agent 已渲染的系统提示字节,而非调用 getSystemPrompt() 重新生成。重新生成可能因 GrowthBook 状态变化(cold 到 warm)而产生字节差异,从而破坏 prompt cache。
防止递归 fork 的机制通过检测对话历史中的 FORK_BOILERPLATE_TAG 实现。Fork 子 Agent 保留了完整的工具集(包括 AgentTool)以保持 API 前缀的 cache 一致性,但在运行时通过 isInForkChild 检测阻止实际的 fork 操作。Fork 与 coordinator 模式互斥------coordinator 已经拥有自己的委派模型,两者会产生冲突。
8.4.3 Agent 专属 MCP 服务器
Agent 可以在其定义中声明专属的 MCP 服务器连接。src/tools/AgentTool/runAgent.ts 中的 initializeAgentMcpServers 函数处理两种形式的规格:字符串形式引用已有的 MCP 配置(通过 connectToServer 的 memoization 机制共享连接,不需要清理),以及对象形式的内联定义(Agent 专属,结束时由 cleanup 函数关闭)。当 MCP 被策略限制为仅插件模式时,用户自定义 Agent 的 frontmatter MCP 服务器会被跳过,但内建和插件提供的 Agent 不受影响。
8.4.4 Worktree 隔离模式
AgentTool 支持 isolation: "worktree" 参数,将子 Agent 运行在独立的 git worktree 中。这意味着子 Agent 对文件的所有修改都在一个隔离的工作副本中进行,不会影响主工作目录。当子 Agent 完成且未做任何修改时,worktree 会被自动清理;如果有修改,worktree 路径和分支信息会返回给父 Agent,由其决定是否合并。
8.4.5 Agent 的 Prompt 设计与生命周期
AgentTool 的 prompt(src/tools/AgentTool/prompt.ts)是动态生成的,根据多个条件调整内容。当 fork 功能启用时,prompt 包含详细的 fork 使用指南------何时 fork(研究问题、实现任务)、如何写 fork 指令(directive 而非 description)、以及严格的行为约束(不窥视输出文件、不编造结果)。这些约束通过 prompt 而非代码强制执行,体现了 prompt engineering 与代码逻辑互补的设计哲学。
Agent 列表可以通过 feature flag 选择嵌入在 prompt 中还是作为 attachment 消息注入。源码注释记录了动机:动态 Agent 列表占了全量 cache_creation token 的约 10.2%,因为 MCP 异步连接、插件重载或权限模式变更都会改变列表内容,导致工具 Schema 的 prompt cache 频繁失效。将列表移到 attachment 消息中可以保持工具描述的静态性,大幅减少 cache bust。
一个子 Agent 的完整生命周期包括:创建阶段(解析定义、确定工具子集、初始化 MCP 连接)、上下文构建(通过 createSubagentContext 创建独立的 fileStateCache 和 abort controller)、执行阶段(query() 对话循环,受 maxTurns 限制)、通知阶段(通过 enqueueAgentNotification 将结果通知父 Agent)、清理阶段(终止 shell 任务、断开 MCP 连接、清理临时资源)。异步 Agent 的生命周期由 runAsyncAgentLifecycle 驱动,其中一个重要的设计是:先标记任务完成状态(completeAsyncAgent),再执行 classifier 检查和 worktree 清理------因为后两者涉及 API 调用和 git 操作可能挂起,不应阻塞状态转换。
8.5 WebFetchTool
WebFetchTool(src/tools/WebFetchTool/)允许 Claude Code 获取和分析网页内容。然而,网页获取是一个充满安全风险的操作------恶意网站可能包含钓鱼内容、攻击性脚本或受版权保护的素材。因此,WebFetchTool 的实现在功能性和安全性之间维持了精心的平衡。
8.5.1 域名安全与预审批
WebFetchTool 的安全模型基于域名级别的访问控制。系统维护了一个预审批域名列表(src/tools/WebFetchTool/preapproved.ts),包含主流的编程语言文档站点和技术平台。对于不在预审批列表中的域名,系统执行两步检查:首先在 checkPermissions 中检查用户配置的允许/拒绝规则(基于 domain:hostname 格式的权限键);其次在实际获取前调用 Anthropic 的域名黑名单 API:
typescript
// src/tools/WebFetchTool/utils.ts
export async function checkDomainBlocklist(domain: string): Promise<DomainCheckResult> {
if (DOMAIN_CHECK_CACHE.has(domain)) return { status: 'allowed' };
const response = await axios.get(
`https://api.anthropic.com/api/web/domain_info?domain=${encodeURIComponent(domain)}`,
{ timeout: DOMAIN_CHECK_TIMEOUT_MS } // 10 秒
);
if (response.status === 200 && response.data.can_fetch === true) {
DOMAIN_CHECK_CACHE.set(domain, true);
return { status: 'allowed' };
}
return { status: 'blocked' };
}
域名检查结果缓存在独立的 DOMAIN_CHECK_CACHE(LRUCache,128 条目,5 分钟 TTL)中,与 URL 级别的 URL_CACHE 分离。分离的原因是:URL 缓存按完整 URL 索引,同一域名下不同路径的请求会触发重复的域名预检调用,hostname 级别的缓存消除了这种冗余。只缓存"允许"结果------被拒绝或检查失败的域名在下次尝试时重新检查。企业客户如果因安全策略无法连接 claude.ai,可以通过 settings.skipWebFetchPreflight 跳过黑名单检查。
8.5.2 HTTP 请求与重定向安全
WebFetchTool 对 HTTP 重定向的处理非常谨慎。系统禁止自动跟随重定向(maxRedirects: 0),而是通过 getWithPermittedRedirects 函数手动递归处理每个重定向,确保每一跳都通过安全检查:
typescript
// src/tools/WebFetchTool/utils.ts
export function isPermittedRedirect(originalUrl: string, redirectUrl: string): boolean {
const parsedOriginal = new URL(originalUrl);
const parsedRedirect = new URL(redirectUrl);
if (parsedRedirect.protocol !== parsedOriginal.protocol) return false;
if (parsedRedirect.port !== parsedOriginal.port) return false;
if (parsedRedirect.username || parsedRedirect.password) return false;
const stripWww = (h: string) => h.replace(/^www\./, '');
return stripWww(parsedOriginal.hostname) === stripWww(parsedRedirect.hostname);
}
只允许同源重定向(忽略 www 前缀差异),防止开放重定向攻击 。最大重定向深度为 10 次,防止重定向循环。系统还检测代理封锁:当响应为 403 且包含 X-Proxy-Error: blocked-by-allowlist 头时,抛出特定的 EgressBlockedError。HTTP 请求头中发送 Accept: text/markdown, text/html, */* 以优先获取 Markdown 格式,并自动将 http:// 升级为 https://。
8.5.3 缓存与内容处理
WebFetchTool 使用 LRU 缓存(15 分钟 TTL,50MB 大小限制)存储获取的内容。HTML 内容通过 Turndown 库转换为 Markdown,Turndown 的初始化使用了懒加载单例模式------因为其导入开销约 1.4MB 堆内存,而大多数会话可能根本不使用 WebFetch:
typescript
// src/tools/WebFetchTool/utils.ts
let turndownServicePromise: Promise<InstanceType<TurndownCtor>> | undefined;
function getTurndownService(): Promise<InstanceType<TurndownCtor>> {
return (turndownServicePromise ??= import('turndown').then(m => {
const Turndown = (m as unknown as { default: TurndownCtor }).default;
return new Turndown();
}));
}
一个有意的内存优化是在 Turndown 处理前释放 axios 持有的 ArrayBuffer 副本(最多 10MB),让 GC 在 Turndown 构建 DOM 树(可达 HTML 大小的 3-5 倍)前回收内存。
转换后的 Markdown 被截断为 100K 字符(MAX_MARKDOWN_LENGTH),然后交给 Haiku 模型进行摘要处理。这个双模型架构是一个精妙的设计:主模型负责决策和推理,而内容摘要交给更快、更便宜的 Haiku 模型。二级模型的 prompt 根据域名是否预审批调整------非预审批域名强制执行严格的引用限制以降低版权风险。对于二进制内容(如 PDF),系统将原始字节保存到磁盘后,仍然将 UTF-8 解码的文本传递给 Haiku------PDF 等格式中的 ASCII 结构通常足以生成有用的摘要。
8.6 跨工具的共同设计模式
分析完各个核心工具后,我们可以提炼出贯穿整个工具系统的共同设计模式。这些模式不是事后总结的巧合相似性,而是刻意设计的架构一致性------它们降低了新工具的开发成本,统一了错误处理和安全检查的行为,也让整个系统的行为更加可预测和可维护。
8.6.1 输入校验的四阶段流水线
所有工具都遵循统一的输入校验流程:Schema 校验 -> validateInput -> checkPermissions -> call。
Schema 校验 由 Zod 自动完成,使用 strictObject 模式拒绝未知字段。semanticNumber 和 semanticBoolean 包装器提供了类型容错------模型有时会输出字符串 "42" 而非数字 42,这些包装器通过 Zod 的 preprocess 机制自动转换。
validateInput 执行不涉及权限的业务逻辑验证。例如,FileEditTool 在此阶段检查文件是否存在、old_string 是否能找到、是否满足唯一性约束。一个重要的设计原则是:validateInput 中的检查应尽量避免 I/O 操作,或至少将昂贵的 I/O 推迟到必要时刻。
checkPermissions 确定操作是否需要用户审批,返回 allow、ask 或 deny 三种结果。每个工具通过 preparePermissionMatcher 提供一个匹配函数,用于将工具输入与权限规则进行匹配。
8.6.2 路径处理与安全防护
几乎所有涉及文件路径的工具都使用 expandPath 进行路径规范化。backfillObservableInput 钩子在输入被任何其他逻辑处理之前运行,确保 hooks 系统看到的路径始终是绝对路径------这防止了通过相对路径或 ~ 绕过 hook 的 allowlist 配置。所有工具还一致地实现了 UNC 路径安全检查,在 Windows 上跳过对 UNC 路径的文件系统操作以防止 NTLM 凭据泄露。
8.6.3 lazySchema 延迟求值
所有工具的 Schema 都使用 lazySchema 包装。这不仅解决了模块循环依赖问题,更确保了运行时动态配置(GrowthBook 特性开关、环境变量)在 Schema 构建时被正确读取。BashTool 的 Schema 就是典型案例------getMaxTimeoutMs() 的返回值取决于运行时配置,run_in_background 参数的有无取决于环境变量,这些都必须在 Schema 构建时才能确定。
8.6.4 并发安全声明
工具通过 isConcurrencySafe() 和 isReadOnly() 方法声明其并发特性。FileReadTool、GlobTool、GrepTool 和 WebFetchTool 都声明了两者为 true。BashTool 则通过 isSearchOrReadBashCommand 函数动态判断------检查管道链中的每个命令是否都属于搜索/读取类别(BASH_SEARCH_COMMANDS、BASH_READ_COMMANDS、BASH_LIST_COMMANDS),语义中性命令(echo、printf、true、false、:)在任何位置都不影响判断。只有当所有非中性命令都是只读时,整条命令才被视为并发安全。
8.6.5 渲染与显示分层
每个工具定义了一组渲染函数:renderToolUseMessage(工具调用发起时)、renderToolResultMessage(结果返回时)、renderToolUseErrorMessage(错误时)。getToolUseSummary 提供简短的操作摘要,extractSearchText 允许工具声明哪些输出内容应该被 UI 搜索索引------这个方法需要谨慎设计,避免将未在 UI 中显示的内容加入索引(源码中称为"phantom"命中)或遗漏显示的内容("under-count")。BashTool 的 userFacingName 方法甚至会根据输入动态返回名称:检测到 sed 编辑命令时返回文件编辑的名称,沙箱启用时返回 "SandboxedBash"。
8.6.6 buildTool 统一构建
所有工具最终通过 buildTool 函数注册到系统中。buildTool 接受一个满足 ToolDef 接口的对象:
typescript
buildTool({
name, // 工具名称(协议级唯一标识)
searchHint, // ToolSearchTool 用于匹配的搜索关键词
maxResultSizeChars, // 输出持久化阈值
strict, // 是否使用严格 JSON Schema
shouldDefer, // 是否延迟加载(Schema 按需获取)
inputSchema, outputSchema,
description, prompt,
validateInput, checkPermissions, call,
mapToolResultToToolResultBlockParam,
// ...
}) satisfies ToolDef<InputSchema, Output>
satisfies ToolDef 的使用是 TypeScript 的精妙应用------它既确保了类型完整性(所有必须字段都存在),又保留了对象字面量的具体类型信息(如 name 保持字面量类型而非 string),使得工具的输入/输出类型能够被精确推导。shouldDefer 标记标识那些不需要在启动时加载 Schema 的工具(如 WebFetchTool),配合 ToolSearchTool 实现按需加载,减少初始化时的 token 消耗。
小结
本章深入剖析了 Claude Code 八个核心工具的实现细节。从宏观视角看,这些工具共同展现了以下设计哲学:
安全优先的分层防御。BashTool 的命令安全检测有超过 20 种独立的检查项,从语法分析(Tree-sitter)到模式匹配(正则表达式)到权限系统(规则匹配与用户审批),任何一层的漏洞都不会直接导致安全问题。FileEditTool 的"先读后写"约束、文件修改时间戳检查和唯一性校验,从不同角度防止了错误的文件修改。WebFetchTool 的域名预审批、黑名单 API、重定向安全检查构成了三道网络安全防线。
优雅的性能退化。Tree-sitter 不可用时回退到正则表达式解析,stat 失败的文件在排序中获得 mtime 0 而非导致整体失败,Turndown 使用懒加载避免不必要的内存开销,Shell 环境快照丢失时自动回退到 login shell 模式。系统在每个层面都预设了降级路径。
模型行为引导。工具的 prompt 不仅描述功能,更引导模型的使用策略------如 BashTool 的 prompt 明确要求使用专用工具而非 shell 命令读写文件,FileWriteTool 的 prompt 引导模型优先使用 FileEditTool,AgentTool 的 prompt 详细规定了 fork 的使用约束和沟通协议。
统一的抽象与特化的实现 。所有工具共享 buildTool、lazySchema、expandPath、semanticNumber/semanticBoolean 等基础设施,但每个工具的核心逻辑都针对其特定场景进行了深度优化。这种"统一框架 + 特化实现"的模式,在保持代码库一致性的同时,允许每个工具充分发挥其领域特性。
理解这些工具的实现,不仅有助于理解 Claude Code 的工作原理,更能为读者设计自己的 AI Agent 工具系统提供宝贵的参考。下一章,我们将进入权限与安全系统的深入分析,看这些工具的安全机制是如何与更宏观的权限架构协同工作的。