深入 Claude Code 源码(三):工具系统——53 个工具背后的统一框架

一个 AI Agent 能做什么,本质上由它拥有哪些工具决定。工具越丰富,Agent 的能力边界越宽;工具越可靠,Agent 的行为越可预期。Claude Code 拥有 53 个工具------从读写文件、执行命令,到搜索代码、管理子代理,几乎覆盖了一个软件开发者日常工作的所有操作。

把这 53 个工具统一管理起来,并不是简单地堆放在一起。它们需要一套共同的接口规范、一套权限控制机制、一套进度反馈协议,还要能以完全相同的方式嵌入 Claude 的 system prompt 里,让模型可以一视同仁地调用任何一个。本文就带大家解析这套统一框架。


一、Tool.ts:所有工具的契约

src/Tool.ts 是整个工具系统的「宪法」,它定义了一个工具必须遵守的所有接口。

一个工具的核心结构包括以下几个字段:

typescript 复制代码
type Tool = {
  // 基本身份
  name: string                      // 工具名,e.g. "Bash", "Read"
  description: string               // 给 Claude 看的说明,嵌入 system prompt
  inputSchema: ToolInputJSONSchema  // 参数的 JSON Schema,Claude 按此构造调用

  // 执行
  execute(
    input: unknown,
    context: ToolUseContext,
    setToolJSX: SetToolJSXFn,
  ): AsyncGenerator<ToolCallProgress, ToolResultBlockParam>

  // 权限检查(可选,不实现则默认允许)
  canUseTool?(
    input, context, assistantMessage, toolUseID, forceDecision,
  ): PermissionResult | Promise<PermissionResult>

  // 输入验证(可选)
  validateInput?(input): ValidationResult

  // 工具结果大小上限(超出则存磁盘,可选)
  maxResultSizeChars?: number
}

这里最值得关注的是 execute() 的返回类型------同样是一个 AsyncGenerator。工具执行过程中可以随时 yieldToolCallProgress 类型的进度消息,让终端 UI 实时显示「正在执行...」「已读取 3 个文件...」这类动态状态,而不是黑屏等待。最终执行完成后,return 一个 ToolResultBlockParam,这才是真正返回给 Claude 的工具结果。

这个「进度消息 + 最终结果」的双层输出设计,是 Claude Code 体验流畅的重要原因之一。


二、工具是怎么创建的------buildTool() 工厂函数

工具不直接实现 Tool 接口,而是通过 buildTool() 工厂函数创建。这个设计的好处是:所有工具都经过同样的「质检流程」,工厂函数可以在里面注入统一的错误处理、日志记录、类型校验等横切逻辑,而不用每个工具自己实现一遍。

以 BashTool 为例,它的定义文件是 src/tools/BashTool/BashTool.tsx,导出一个 ToolDef 传给 buildTool()

typescript 复制代码
buildTool({
  name: BASH_TOOL_NAME,            // "Bash"
  description: getSimplePrompt(),  // 工具说明,动态生成,注入 system prompt
  inputSchema: lazySchema(z.object({
    command: z.string(),
    timeout: z.number().optional(),
    description: z.string().optional(),
  })),
  execute: async function*(input, context, setToolJSX) {
    // ... 执行逻辑,可以多次 yield 进度
    yield { type: 'progress', message: '正在执行命令...' }
    const result = await exec(input.command)
    return buildToolResult(result)
  },
  canUseTool: bashToolHasPermission,
})

lazySchema() 包装了一下 Zod schema,做了懒加载处理------只有真正需要校验输入时才初始化 schema 对象,减少启动时的内存开销。


三、三层权限防护

使用 Claude Code 时,大家会发现有些操作自动放行,有些会弹出确认框,有些在某些模式下完全禁止。这背后是一套三层叠加的权限防护体系。

如图所示,三层权限从内到外依次拦截:
#mermaid-svg-CDOHRbsFBvYKWRnD{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-CDOHRbsFBvYKWRnD .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-CDOHRbsFBvYKWRnD .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-CDOHRbsFBvYKWRnD .error-icon{fill:#552222;}#mermaid-svg-CDOHRbsFBvYKWRnD .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-CDOHRbsFBvYKWRnD .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-CDOHRbsFBvYKWRnD .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-CDOHRbsFBvYKWRnD .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-CDOHRbsFBvYKWRnD .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-CDOHRbsFBvYKWRnD .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-CDOHRbsFBvYKWRnD .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-CDOHRbsFBvYKWRnD .marker{fill:#333333;stroke:#333333;}#mermaid-svg-CDOHRbsFBvYKWRnD .marker.cross{stroke:#333333;}#mermaid-svg-CDOHRbsFBvYKWRnD svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-CDOHRbsFBvYKWRnD p{margin:0;}#mermaid-svg-CDOHRbsFBvYKWRnD .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-CDOHRbsFBvYKWRnD .cluster-label text{fill:#333;}#mermaid-svg-CDOHRbsFBvYKWRnD .cluster-label span{color:#333;}#mermaid-svg-CDOHRbsFBvYKWRnD .cluster-label span p{background-color:transparent;}#mermaid-svg-CDOHRbsFBvYKWRnD .label text,#mermaid-svg-CDOHRbsFBvYKWRnD span{fill:#333;color:#333;}#mermaid-svg-CDOHRbsFBvYKWRnD .node rect,#mermaid-svg-CDOHRbsFBvYKWRnD .node circle,#mermaid-svg-CDOHRbsFBvYKWRnD .node ellipse,#mermaid-svg-CDOHRbsFBvYKWRnD .node polygon,#mermaid-svg-CDOHRbsFBvYKWRnD .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-CDOHRbsFBvYKWRnD .rough-node .label text,#mermaid-svg-CDOHRbsFBvYKWRnD .node .label text,#mermaid-svg-CDOHRbsFBvYKWRnD .image-shape .label,#mermaid-svg-CDOHRbsFBvYKWRnD .icon-shape .label{text-anchor:middle;}#mermaid-svg-CDOHRbsFBvYKWRnD .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-CDOHRbsFBvYKWRnD .rough-node .label,#mermaid-svg-CDOHRbsFBvYKWRnD .node .label,#mermaid-svg-CDOHRbsFBvYKWRnD .image-shape .label,#mermaid-svg-CDOHRbsFBvYKWRnD .icon-shape .label{text-align:center;}#mermaid-svg-CDOHRbsFBvYKWRnD .node.clickable{cursor:pointer;}#mermaid-svg-CDOHRbsFBvYKWRnD .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-CDOHRbsFBvYKWRnD .arrowheadPath{fill:#333333;}#mermaid-svg-CDOHRbsFBvYKWRnD .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-CDOHRbsFBvYKWRnD .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-CDOHRbsFBvYKWRnD .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-CDOHRbsFBvYKWRnD .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-CDOHRbsFBvYKWRnD .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-CDOHRbsFBvYKWRnD .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-CDOHRbsFBvYKWRnD .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-CDOHRbsFBvYKWRnD .cluster text{fill:#333;}#mermaid-svg-CDOHRbsFBvYKWRnD .cluster span{color:#333;}#mermaid-svg-CDOHRbsFBvYKWRnD div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-CDOHRbsFBvYKWRnD .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-CDOHRbsFBvYKWRnD rect.text{fill:none;stroke-width:0;}#mermaid-svg-CDOHRbsFBvYKWRnD .icon-shape,#mermaid-svg-CDOHRbsFBvYKWRnD .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-CDOHRbsFBvYKWRnD .icon-shape p,#mermaid-svg-CDOHRbsFBvYKWRnD .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-CDOHRbsFBvYKWRnD .icon-shape .label rect,#mermaid-svg-CDOHRbsFBvYKWRnD .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-CDOHRbsFBvYKWRnD .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-CDOHRbsFBvYKWRnD .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-CDOHRbsFBvYKWRnD :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 第三层:全局 PermissionMode
第二层:QueryEngine 包装层
第一层:工具级 canUseTool()
default
acceptEdits
bypassPermissions
Claude 决定调用某个工具
工具自带的权限检查

e.g. BashTool 检查命令是否在白名单里
记录所有被拒绝的调用

统计到 permission_denials 字段
当前 PermissionMode?
危险操作弹确认框
文件编辑自动允许

Bash 仍需确认
完全绕过所有检查

(仅限受信任的自动化)

第一层:工具级权限

每个工具可以自带 canUseTool() 函数。以 BashTool 为例,src/tools/BashTool/bashPermissions.ts 实现了相当细致的规则:

  • 命令是否在「永久允许」规则里?(用户之前选择了「总是允许」)
  • 命令是否匹配通配符规则?(git* 匹配所有 git 命令)
  • 命令是否包含 cd 跳出项目目录?(禁止)
  • read_only 模式下,是否只有 catlsgrep 这类只读命令?

这不是简单的字符串包含检查,而是基于命令语义的分析,甚至会解析 Bash 管道操作符来判断整条复合命令的性质。

第二层:QueryEngine 包装层

QueryEngine.submitMessage() 里,用户传入的 canUseTool 被包了一层,专门用来追踪所有被拒绝的调用,汇总到最终的 permission_denials 字段返回给调用方,方便审计。

第三层:全局 PermissionMode

整个会话的权限模式,在启动时通过命令行参数或配置文件设定。bypassPermissions 模式是为完全受信任的自动化场景设计的------比如 CI/CD 流水线里运行的 Claude Code,不需要交互确认。


四、BashTool:最复杂工具的解剖

BashTool 光自己的目录就有 16 个文件,是 53 个工具里最复杂的,值得单独拆开来看。

安全检查(bashSecurity.ts + parseForSecurity()

在执行任何命令之前,BashTool 对命令字符串做语法树(AST)级别的解析,识别高危操作:

  • rm -rf 加根路径 /
  • chmod 777 宽泛权限修改
  • curl | bash / wget | sh 这类下载执行管道
  • 通过 subshell 绕过审查的技巧($(dangerous_cmd)

这里需要特别说明的是------这不是简单的字符串匹配,而是真正理解命令的语法结构。比如 echo "rm -rf /" 不会被拦截,因为它只是打印字符串,并不执行。

Sandbox 沙盒支持(shouldUseSandbox.ts

Claude Code 在 macOS 上支持以沙盒方式执行命令。沙盒模式下,命令无法访问 Claude Code 工作目录之外的文件系统。就算 Claude「想」修改系统文件,macOS 沙盒机制也会拦住------这是系统级别的安全保证,不是应用层的软限制。

命令语义分类

BashTool 会为每条命令打上语义标签,供 UI 层使用:

typescript 复制代码
// 搜索类 → UI 显示「Searched X files」
const BASH_SEARCH_COMMANDS = new Set(['find', 'grep', 'rg', 'ag', 'ack', ...])

// 读取类 → UI 显示「Read X files」
const BASH_READ_COMMANDS = new Set(['cat', 'head', 'tail', 'jq', 'awk', ...])

// 目录列表类 → UI 显示「Listed N directories」
const BASH_LIST_COMMANDS = new Set(['ls', 'tree', 'du'])

把「列目录」和「读文件」分开,是因为「Listed N directories」和「Read N files」在用户心理上是完全不同的操作,混淆会让大家困惑。这种对用户体验的细粒度关注,在 Claude Code 的代码里随处可见。

进度显示阈值

执行命令超过 2000ms ,才开始显示进度提示。这个 PROGRESS_THRESHOLD_MS = 2000 的设计,避免了对大量短时间命令(比如 lsecho)显示多余的进度 UI,只在真正需要等待的场景才弹出进度条。

后台任务(KAIROS 助手模式)

在 KAIROS 助手模式下,阻塞性的 Bash 命令在主代理运行 15,000ms 后会自动切换到后台任务(LocalShellTask),让主代理不被长时间运行的命令卡住。这个常量叫 ASSISTANT_BLOCKING_BUDGET_MS = 15_000,显示出工程师对「可接受阻塞时长」是有明确定量的,不是凭感觉设的。


五、工具结果的大小管理

大家可能想过:如果工具返回了一个 10MB 的文件内容,或者 grep 返回了几千行,Claude 的 context window 会直接被撑爆。

Claude Code 通过工具结果存储与替换机制 来解决这个问题(src/utils/toolResultStorage.ts):

  1. 工具执行完成后,如果结果超出 maxResultSizeChars 限制,完整内容写入磁盘临时文件
  2. 发给 Claude 的消息里,用一条「结果已截断,完整内容在 {path}」的提示替换原始内容
  3. 下次 query 循环的 applyToolResultBudget() 会统计所有工具结果的累计大小,超出阈值的做同样处理

这个机制的关键在于------是内容替换,不是信息删除 。Claude 知道「这里有个工具结果,因为太大被存到了文件里」,它可以选择用 Read 工具去读那个文件,也可以基于已有的摘要继续工作。大家不需要担心「Claude 忘掉了执行过什么」。


六、MCP 工具的特殊地位

53 个工具里有一类特殊的------MCPToolsrc/tools/MCPTool/MCPTool.tsx)。

MCPTool 不是一个具体工具,而是一个工具模板 。当 Claude Code 连接到 MCP 服务时,会枚举服务暴露的工具列表,然后为每一个 MCP 工具动态创建一个 MCPTool 实例。

从 Claude 的视角来看,MCP 工具和本地工具完全没有区别------它们都以相同的格式出现在 system prompt 里,也都以相同的格式接受调用。这种透明性就类似于苹果手机上的 App Store------不管是苹果自带的 App 还是第三方开发的 App,在 iOS 系统里都以统一的方式运行,用户不需要知道「这个 App 是谁写的」。

这种透明性是 MCP 协议设计的核心目标之一,也是为什么 Claude Code 可以通过 MCP 扩展出几乎无限的能力------只要实现了 MCP 接口,任何外部服务都可以成为 Claude 的工具。


七、工具列表是如何注入 System Prompt 的

最后介绍一个很重要的细节:这 53 个工具(加上动态创建的 MCP 工具)是如何让 Claude 「知道」它们存在的?

答案是通过 fetchSystemPromptParts() 在每次 submitMessage() 时动态构建。工具的 namedescriptioninputSchema 会被序列化成 Anthropic API 标准的 tools 数组,随每次请求一并发送给模型。

模型在响应里,如果决定调用工具,会返回一个 tool_use 内容块,包含工具名和参数。Claude Code 接到这个块后,在 runTools() 里根据工具名找到对应的工具对象,用 toolMatchesName() 匹配,然后调用其 execute() 方法。整个过程就像一个查字典的过程:Claude 说「我要用第 N 号工具」,代码去工具列表里查 N 号工具是什么,然后执行。


学习完本篇内容,大家应该清楚了:

  1. Tool.ts 定义了统一契约,buildTool() 工厂函数保证所有工具经过同样的质检
  2. execute() 返回 AsyncGenerator,支持在执行过程中流式输出进度消息
  3. 三层权限防护(工具级 → 记录层 → 全局模式)在安全性和便利性之间取得平衡
  4. BashTool 是最复杂的工具:AST 安全检查、沙盒、命令语义分类、进度阈值,每一处都有具体的工程权衡
  5. 工具结果大小管理是「替换内容,保留信息」,不让大文件撑爆 context
  6. MCPTool 是动态适配器,让外部服务对 Claude 完全透明

接下来,进入第四篇------上下文管理,当 context window 快满的时候,Claude Code 是怎么应对的。