Claude Code 源码解析
返回目录
工具系统08

内置工具全景

Bash·文件·搜索·LSP

核心洞察

Bash 工具通过 AST 解析实现语义级命令分类,区分只读/破坏性/搜索操作

学习

概念讲解与核心设计分析

内置工具全景概述

Claude Code 内置了 42+ 种工具,涵盖文件操作、代码搜索、命令执行、Web 访问、代码智能等多个领域。每种工具都针对特定场景做了深度优化。本章深入剖析最核心的几个工具的内部实现。

BashTool:最复杂的工具(20+ 文件)

BashTool 是整个工具系统中最复杂的工具,由 20+ 个文件组成。它不仅要执行 shell 命令,还要理解命令的语义、评估安全风险、决定是否沙箱化。

命令分类系统

BashTool 将 shell 命令分为多个类别,每个类别有不同的权限和行为策略:

  • BASH_SEARCH_COMMANDS — 搜索类命令(grep、find、rg 等):只读,可并发
  • BASH_READ_COMMANDS — 读取类命令(cat、head、tail 等):只读,可并发
  • BASH_LIST_COMMANDS — 列表类命令(ls、tree、du 等):只读,可并发
  • BASH_SILENT_COMMANDS — 静默类命令(cd、pwd 等):无副作用,自动允许

不属于以上类别的命令被视为"可能有副作用",需要更严格的权限检查。

AST 分析(treeSitterAnalysis)

BashTool 使用 Tree-sitter 将 shell 命令解析为 AST(抽象语法树),而不是简单的字符串匹配:

  • 能正确识别管道:cat file | grep pattern 中 cat 是只读的
  • 能识别命令替换:$(rm -rf /) 被嵌入到看似无害的命令中
  • 能识别重定向:echo "data" > file 虽然用了 echo 但有写操作
  • 能识别 && 和 || 链:test -f file && rm file 包含破坏性操作

沙箱决策(shouldUseSandbox)

对于不信任的命令,BashTool 可以在沙箱中执行:

  • 判断逻辑综合考虑:命令类别、用户权限模式、沙箱可用性
  • 沙箱通过 sandbox-adapter.ts 对接不同的沙箱实现
  • 沙箱内的命令无法访问主机文件系统(除显式挂载的目录)

sed 编辑检测

BashTool 能检测用户是否通过 sed 命令编辑文件,并提示用户使用更安全的 FileEditTool:

  • 检测 sed -i(in-place 编辑)模式
  • 提供"建议使用 Edit 工具"的提示,因为 Edit 工具有冲突检测

FileEditTool:精确文本替换

FileEditTool 实现了 old_string → new_string 的精确替换逻辑:

  • 唯一性检测 — old_string 必须在文件中唯一匹配。如果匹配到多处,工具报错并提示用户提供更多上下文以区分
  • Diff 计算 — 替换前后计算 diff,用于 UI 展示和版本历史
  • replace_all 模式 — 可选的全局替换模式,允许替换所有匹配项
  • 空白敏感 — 精确匹配缩进和空白字符,避免意外替换

FileReadTool:多格式智能读取

FileReadTool 不只是简单的文件读取,它支持多种文件格式:

  • 文本文件 — 带行号输出(cat -n 格式),支持 offset 和 limit 参数
  • 图片 — PNG/JPG 等图片经过处理管线:检测尺寸 → 按需 resize → 降采样 → 转为 base64 发送给多模态 Claude
  • PDF — 支持页面范围读取(pages: "1-5"),大 PDF 强制要求指定页面范围
  • Jupyter Notebook — 解析 .ipynb JSON 格式,展示代码单元、输出和可视化
  • 大小限制 — 默认读取前 2000 行,超大文件提示用户使用 offset 分段读取

FileWriteTool:智能写入

FileWriteTool 在写入文件时做了多项智能检测:

  • 编码检测 — 自动检测现有文件的编码(UTF-8、Latin-1 等),新文件使用同样编码写入
  • 行尾检测 — 检测现有文件使用 LF 还是 CRLF 行尾,保持一致性
  • 文件历史 — 写入前记录文件状态到历史中,支持后续的 diff 查看和撤销

GlobTool:高速文件搜索

GlobTool 使用 ripgrep 的文件匹配引擎(而非 Node.js 的 glob 库):

  • 支持标准 glob 模式(**/*.tssrc/**/*.{js,jsx}
  • 结果按文件修改时间排序(最近修改的文件排在前面)
  • 性能远超 Node.js 原生 glob,处理大型仓库(10万+ 文件)依然快速

GrepTool:多模式搜索

GrepTool 是 ripgrep 的包装器,提供三种输出模式:

  • content — 显示匹配行的完整内容,支持 -A/-B/-C 上下文行
  • files_with_matches — 只显示包含匹配的文件路径(默认模式)
  • count — 显示每个文件的匹配次数

LSPTool:代码智能

LSPTool 通过 Language Server Protocol 提供 IDE 级别的代码智能:

  • goToDefinition — 跳转到符号定义
  • findReferences — 查找所有引用
  • hover — 获取悬停信息(类型、文档)
  • documentSymbol — 获取文件中的所有符号
  • workspaceSymbol — 全工作区符号搜索
  • goToImplementation — 查找接口实现
  • prepareCallHierarchy / incomingCalls / outgoingCalls — 调用层次分析

其他工具

除上述核心工具外,Claude Code 还包含:

  • WebFetchTool — 获取网页内容并转换为 Markdown
  • WebSearchTool — Web 搜索并返回结构化结果
  • NotebookEditTool — 编辑 Jupyter Notebook 单元格
  • PowerShellTool — Windows PowerShell 执行(特性门控)
  • 总计 42+ 种工具,覆盖开发工作流的各个环节

架构

模块关系与设计决策

内置工具架构

工具分类矩阵

工具只读?并发安全?复杂度关键特性
BashTool视命令极高(20+ 文件)AST 分析、沙箱、命令分类
FileEditTool唯一性检测、diff 计算
FileReadTool多格式支持、图片处理
FileWriteTool编码检测、行尾检测
GlobToolripgrep 引擎、mod-time 排序
GrepTool三种输出模式、上下文行
LSPTool8 种 LSP 操作
WebFetchToolHTML→Markdown 转换
WebSearchTool结构化搜索结果

BashTool 内部架构

BashTool 的 20+ 文件按职责分层:

  1. 入口层:BashTool.ts — call() 方法,统筹整个执行流程
  2. 分析层:treeSitterAnalysis.ts — AST 解析和命令语义理解
  3. 分类层:commandClassification.ts — 命令类别判断
  4. 安全层:shouldUseSandbox.ts — 沙箱决策逻辑
  5. 执行层:processExecution.ts — 子进程管理和输出捕获
  6. 检测层:sedDetection.ts — sed 编辑检测和建议

FileReadTool 图片处理管线

  1. 格式检测 → 通过文件扩展名和 magic bytes 判断类型
  2. 尺寸检测 → 读取图片元数据获取宽×高
  3. Resize 决策 → 超过阈值(如 2048px)则缩小
  4. 降采样 → 减少颜色深度以减小体积
  5. Base64 编码 → 转换为 API 兼容的 base64 字符串
  6. 嵌入消息 → 作为 image content block 发送给 Claude

设计决策

为什么 BashTool 要用 Tree-sitter 而不是正则表达式?

Shell 命令的语法极其复杂:嵌套引号、命令替换、进程替换、here-doc 等。正则表达式无法可靠地解析 echo "$(cat file | grep 'pattern')" > output 这样的命令。Tree-sitter 提供了完整的 AST,可以准确识别每个子命令、每个重定向、每个替换。虽然增加了依赖和复杂度,但这是安全性的基础。

源码

3 个关键代码示例

01
BashTool 命令分类与 AST 分析
TypeScript
// tools/BashTool/commandClassification.ts — 简化版

// 命令分类常量
export const BASH_SEARCH_COMMANDS = new Set([
  'grep', 'rg', 'find', 'fd', 'ag', 'ack',
  'locate', 'which', 'whereis', 'type',
])

export const BASH_READ_COMMANDS = new Set([
  'cat', 'head', 'tail', 'less', 'more',
  'wc', 'file', 'stat', 'md5sum', 'sha256sum',
])

export const BASH_LIST_COMMANDS = new Set([
  'ls', 'tree', 'du', 'df', 'find', 'exa',
])

export const BASH_SILENT_COMMANDS = new Set([
  'cd', 'pwd', 'echo', 'printf', 'true', 'false',
  'test', '[', '[[',
])

// treeSitterAnalysis.ts — AST 分析
import Parser from 'tree-sitter'
import Bash from 'tree-sitter-bash'

export function analyzeCommand(command: string): CommandAnalysis {
  const parser = new Parser()
  parser.setLanguage(Bash)
  const tree = parser.parse(command)

  const analysis: CommandAnalysis = {
    commands: [],       // 所有命令名
    hasRedirection: false,
    hasPipe: false,
    hasCommandSubstitution: false,
    hasDestructiveOp: false,
    isReadOnly: true,
  }

  // 遍历 AST 节点
  function visit(node: Parser.SyntaxNode) {
    switch (node.type) {
      case 'command':
        const cmdName = node.firstChild?.text ?? ''
        analysis.commands.push(cmdName)
        if (!isReadOnlyCommand(cmdName)) {
          analysis.isReadOnly = false
        }
        break

      case 'redirected_statement':
      case 'file_redirect':
        analysis.hasRedirection = true
        analysis.isReadOnly = false // 重定向意味着写操作
        break

      case 'pipeline':
        analysis.hasPipe = true
        break

      case 'command_substitution':
        analysis.hasCommandSubstitution = true
        break
    }

    for (const child of node.children) {
      visit(child)
    }
  }

  visit(tree.rootNode)
  return analysis
}

function isReadOnlyCommand(cmd: string): boolean {
  return BASH_SEARCH_COMMANDS.has(cmd)
    || BASH_READ_COMMANDS.has(cmd)
    || BASH_LIST_COMMANDS.has(cmd)
    || BASH_SILENT_COMMANDS.has(cmd)
}
02
FileEditTool 精确替换逻辑
TypeScript
// tools/FileEditTool.ts — 简化版
import { z } from 'zod'
import { readFile, writeFile } from 'fs/promises'
import { createPatch } from 'diff'

export class FileEditTool implements Tool {
  name = 'Edit'

  inputSchema = z.object({
    file_path: z.string().describe('要编辑的文件绝对路径'),
    old_string: z.string().describe('要替换的文本'),
    new_string: z.string().describe('替换后的新文本'),
    replace_all: z.boolean().optional().default(false)
      .describe('是否替换所有匹配项'),
  })

  async call(
    input: z.infer<typeof this.inputSchema>,
    context: ToolUseContext
  ): Promise<ToolResult> {
    const { file_path, old_string, new_string, replace_all } = input

    // 读取文件
    const content = await readFile(file_path, 'utf-8')

    // 唯一性检测
    if (!replace_all) {
      const matchCount = countOccurrences(content, old_string)

      if (matchCount === 0) {
        return {
          data: '错误: old_string 在文件中未找到。\n' +
            '请确认要替换的文本与文件内容完全匹配(包括缩进和空白)。',
        }
      }

      if (matchCount > 1) {
        // 非唯一匹配:提示用户提供更多上下文
        const locations = findMatchLocations(content, old_string)
        return {
          data: '错误: old_string 在文件中匹配到 ' + matchCount + ' 处。\n' +
            '匹配位置: 行 ' + locations.join(', ') + '\n' +
            '请提供更多上下文使 old_string 唯一,或使用 replace_all=true。',
        }
      }
    }

    // 执行替换
    const newContent = replace_all
      ? content.replaceAll(old_string, new_string)
      : content.replace(old_string, new_string)

    // 计算 diff(用于 UI 展示)
    const diff = createPatch(file_path, content, newContent)

    // 记录文件历史
    context.readFileState.recordEdit(file_path, content)

    // 写入文件
    await writeFile(file_path, newContent, 'utf-8')

    return {
      data: '文件已更新: ' + file_path + '\n\n' + diff,
    }
  }

  isReadOnly() { return false }
  isConcurrencySafe() { return false }
  isDestructive() { return false }
}

function countOccurrences(text: string, search: string): number {
  let count = 0
  let pos = 0
  while ((pos = text.indexOf(search, pos)) !== -1) {
    count++
    pos += search.length
  }
  return count
}
03
FileReadTool 多格式支持
TypeScript
// tools/FileReadTool.ts — 简化版
import { readFile, stat } from 'fs/promises'
import { extname } from 'path'
import sharp from 'sharp'

const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp'])
const MAX_LINES = 2000
const MAX_IMAGE_DIMENSION = 2048

export class FileReadTool implements Tool {
  name = 'Read'

  async call(
    input: { file_path: string; offset?: number; limit?: number; pages?: string },
    context: ToolUseContext
  ): Promise<ToolResult> {
    const ext = extname(input.file_path).toLowerCase()

    // 图片处理管线
    if (IMAGE_EXTENSIONS.has(ext)) {
      return this.readImage(input.file_path)
    }

    // PDF 处理
    if (ext === '.pdf') {
      return this.readPDF(input.file_path, input.pages)
    }

    // Jupyter Notebook 处理
    if (ext === '.ipynb') {
      return this.readNotebook(input.file_path)
    }

    // 文本文件:带行号输出
    return this.readText(input.file_path, input.offset, input.limit)
  }

  private async readText(
    filePath: string,
    offset = 0,
    limit = MAX_LINES
  ): Promise<ToolResult> {
    const content = await readFile(filePath, 'utf-8')
    const lines = content.split('\n')

    const selectedLines = lines.slice(offset, offset + limit)
    const numbered = selectedLines.map(
      (line, i) => (offset + i + 1).toString().padStart(6) + '\t' + line
    )

    let result = numbered.join('\n')
    if (offset + limit < lines.length) {
      result += '\n\n... (文件共 ' + lines.length + ' 行,' +
        '已显示 ' + (offset + 1) + '-' + (offset + limit) + ' 行)'
    }

    return { data: result }
  }

  private async readImage(filePath: string): Promise<ToolResult> {
    // 1. 读取元数据
    const metadata = await sharp(filePath).metadata()
    const { width = 0, height = 0 } = metadata

    // 2. Resize 决策
    let image = sharp(filePath)
    if (width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION) {
      image = image.resize(MAX_IMAGE_DIMENSION, MAX_IMAGE_DIMENSION, {
        fit: 'inside',          // 保持比例
        withoutEnlargement: true, // 不放大
      })
    }

    // 3. 转为 base64
    const buffer = await image.toBuffer()
    const base64 = buffer.toString('base64')

    return {
      data: base64,
      // 作为 image content block 传给 Claude
    }
  }

  isReadOnly() { return true }
  isConcurrencySafe() { return true }
}

互动

步进式流程演示

互动演示

内置工具互动解析

第 1 步:BashTool 的安全层次

BashTool 面临一个核心矛盾:它必须足够强大以执行任意命令,又必须足够安全以防止危险操作。解决方案是多层防御:

  1. 第一层:命令分类 — 已知安全的命令(grep、ls、cat)自动放行
  2. 第二层:AST 分析 — 解析命令结构,识别重定向、管道、子命令中的风险
  3. 第三层:权限检查 — 未知命令交给权限系统(用户确认或分类器判断)
  4. 第四层:沙箱执行 — 高风险命令在隔离环境中执行

这种"层层筛选"的模式确保了:安全命令快速执行,危险命令被充分审查。

第 2 步:FileEditTool 为什么坚持"唯一匹配"?

想象以下场景:

// 文件中有 3 处 "const x = 1"
old_string: "const x = 1"
new_string: "const x = 2"

如果直接替换,Claude 可能意外修改了它不打算修改的那一处。唯一匹配要求 Claude 提供足够的上下文:

// 正确的做法:提供更多上下文使匹配唯一
old_string: "  // 在 processData 函数中\n  const x = 1"
new_string: "  // 在 processData 函数中\n  const x = 2"

这迫使 Claude "知道自己在改哪里",避免了盲目替换。

第 3 步:为什么 Glob 和 Grep 不用 Node.js 原生库?

ripgrep 的性能优势在大型仓库中尤为明显:

  • Node.js glob:10 万文件搜索耗时 ~5 秒(JS 实现,单线程)
  • ripgrep:10 万文件搜索耗时 ~0.2 秒(Rust 实现,多线程,SIMD 加速)
  • 在 monorepo(百万+文件)中,差距更加明显
  • Claude Code 需要频繁搜索文件,ripgrep 的速度直接影响用户体验

第 4 步:图片处理为什么要 resize 和降采样?

Claude 的多模态能力可以理解图片,但图片大小直接影响 token 成本:

  • 一张 4096×4096 的 PNG 截图可能有 10MB
  • Base64 编码后变为 ~13MB 的文本
  • 发送给 API 会消耗大量 input tokens
  • resize 到 2048×2048 后质量几乎无损,但体积减小 75%
  • 对于代码截图,这个尺寸完全足够 Claude 阅读

第 5 步:LSPTool 的价值

为什么 Claude Code 需要 LSP 而不是让 Claude 自己通过 Grep 查找定义?

  • 精确性:Grep 搜索 function foo 会匹配注释中的"// function foo"。LSP 通过语言服务器理解代码语义,只返回真正的定义
  • 跨文件findReferences 能找到所有导入和使用点,包括间接引用
  • 类型信息hover 能获取推断出的类型,Grep 做不到
  • 调用层次incomingCalls/outgoingCalls 能构建完整的调用图

关键设计洞察

  • 深度 > 广度:BashTool 用 20+ 文件深度解决一个问题,而不是用一个文件粗略处理
  • 安全第一:命令分类 + AST 分析 + 沙箱,三层防御保护用户文件系统
  • 格式适配:FileReadTool 不是"读文件",而是"让 Claude 理解文件内容"
  • 原生工具:选择 ripgrep 而非 JS 库,选择 Tree-sitter 而非正则,体现了"用正确的工具做正确的事"

相关源文件

tools/BashTool/tools/FileEditTool/tools/GrepTool/tools/LSPTool/