Skip to content

第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')
}));

这个设计有几个值得关注的要点。semanticNumbersemanticBoolean 是对 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 钩子指向恶意脚本。如果 cdgit 操作被分别检查且各自通过,合在一起就可能导致进入恶意目录后执行 git 命令,触发恶意钩子。

8.1.4 Shell 命令执行机制

BashTool 的命令执行涉及两个层次:上层的 runShellCommand 生成器函数(位于 BashTool.tsx)和底层的 exec 函数(位于 src/utils/Shell.ts)。

底层 exec 函数负责实际的进程创建。它首先通过 ShellProvidersrc/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 的捕获方式。在标准模式下,两个流都重定向到同一个文件描述符:

基于 VitePress 构建