工具系统:AI 如何安全地与外部世界交互

深入 Claude Code 的 40+ 工具系统——从类型定义到并发执行到六层权限评估,理解 AI 如何安全地操作外部世界

概览

在前两篇文章中,我们建立了 Claude Code 的全局架构认知,并深入了查询引擎的流式循环。现在我们来到了架构的第三个关键层——工具系统。

如果把查询引擎比作 Claude Code 的"大脑",那工具就是它的"手脚"。没有工具,AI 只能生成文本。有了工具,AI 可以读写文件、执行 Shell 命令、搜索代码、访问网页、甚至生成子 Agent。

但这种能力伴随着巨大的风险。一个可以执行 rm -rf / 的 AI,如果没有适当的权限控制,就是一颗定时炸弹。Claude Code 的工具系统不仅要提供强大的能力,还要在每次执行前确保安全。

本文将从三个维度剖析工具系统:

  1. 定义——工具是什么,如何声明?
  2. 执行——多个工具如何并发安全地执行?
  3. 权限——谁来决定一个工具是否可以执行?

工具的类型定义

每个工具都是一个符合 Tool 类型的对象。让我们逐字段理解这个类型:

src/Tool.ts:362-599
TypeScript
362export type Tool<
363 Input extends AnyObject = AnyObject,
364 Output = unknown,
365 P extends ToolProgressData = ToolProgressData,
366> = {
367 // 身份
368 aliases?: string[] // 向后兼容的别名
369 searchHint?: string // 延迟发现时的关键词匹配
370
371 // 核心方法
372 call(args, context, canUseTool, parentMessage, onProgress?)
373 : Promise<ToolResult<Output>>
374 description(input, options): Promise<string>
375
376 // Schema
377 readonly inputSchema: Input // Zod schema
378 readonly inputJSONSchema?: ToolInputJSONSchema // JSON Schema(MCP 工具用)
379
380 // 行为声明
381 isConcurrencySafe(input): boolean // 是否可以与其他工具并行
382 isEnabled(): boolean // 是否在当前环境中可用
383 isReadOnly(input): boolean // 是否是只读操作
384 isDestructive?(input): boolean // 是否执行不可逆操作
385 interruptBehavior?(): 'cancel' | 'block' // 用户中断时的行为
386
387 // 权限
388 checkPermissions(input, context): Promise<PermissionResult>
389 validateInput?(input, context): Promise<ValidationResult>
390 preparePermissionMatcher?(input): Promise<(pattern: string) => boolean>
391
392 // 输出控制
393 maxResultSizeChars: number // 结果最大字符数
394 readonly name: string
395}

这个类型定义跨越了 src/Tool.ts 的近 240 行(362-599),包含了约 30 个字段和方法。上面的代码是经过简化的核心部分——完整类型还包括 UI 渲染方法(renderToolResultMessageuserFacingName)、分析方法(toAutoClassifierInput)等。

returnschecked againstTool+name: string+aliases: string[]+searchHint: string+inputSchema: ZodType+inputJSONSchema?: JSONSchema+maxResultSizeChars: number+call(): Promise~ToolResult~+description(): Promise~string~+checkPermissions(): Promise~PermissionResult~+isConcurrencySafe(input): boolean+isEnabled(): boolean+isReadOnly(input): boolean+isDestructive?(input): boolean+interruptBehavior?(): cancel | block+validateInput?(): Promise~ValidationResult~ToolResult+data: T+newMessages?: Message[]+contextModifier?: Function+mcpMeta?: ObjectToolPermissionContext+mode: PermissionMode+alwaysAllowRules: RulesBySource+alwaysDenyRules: RulesBySource+alwaysAskRules: RulesBySource+additionalWorkingDirectories: Map+isBypassPermissionsModeAvailable: boolean+awaitAutomatedChecksBeforeDialog?: boolean+shouldAvoidPermissionPrompts?: boolean

让我们关注几个特别值得理解的设计。

两种 Schema:Zod vs JSON Schema

注意 inputSchemainputJSONSchema 的共存。这不是冗余设计,而是两种不同来源的工具需要不同的 schema 格式:

  • 内置工具(BashTool, FileReadTool 等)使用 Zod schema — TypeScript 原生,提供编译时类型检查和运行时验证
  • MCP 工具(来自外部 MCP server)使用 JSON Schema — 因为 MCP 协议基于 JSON Schema 定义工具接口
TypeScript
1// 内置工具的 Zod schema 示例
2const inputSchema = z.object({
3 command: z.string().describe("The shell command to execute"),
4 timeout: z.number().optional().describe("Timeout in milliseconds"),
5 run_in_background: z.boolean().optional(),
6})
7
8// MCP 工具的 JSON Schema(从 MCP server 动态获取)
9const inputJSONSchema: ToolInputJSONSchema = {
10 type: "object",
11 properties: {
12 query: { type: "string", description: "SQL query" }
13 },
14 required: ["query"]
15}

ToolInputJSONSchema 类型定义在 src/Tool.ts:15-21,要求根类型必须是 object

TypeScript
1export type ToolInputJSONSchema = {
2 [x: string]: unknown
3 type: 'object'
4 properties?: {
5 [x: string]: unknown
6 }
7}

Zod schema 在 Anthropic API 调用前会被自动转换为 JSON Schema 格式——这个转换对工具开发者是透明的。双轨制让内置工具享受 TypeScript 类型系统的好处,同时让外部 MCP 工具无需引入 Zod 依赖。

行为声明:工具告诉系统自己的特性

Claude Code 的工具系统采用了一种"声明式"设计——工具不是被动地等待调度器查询它的属性,而是主动声明自己的行为特性。这让调度器可以在不了解工具具体实现的情况下,做出正确的调度决策。

isConcurrencySafe(input) — 这个工具是否可以与其他工具并行执行?

TypeScript
1// GrepTool 是只读的,可以安全并行
2isConcurrencySafe() { return true }
3
4// FileWriteTool 修改文件系统,不能并行
5isConcurrencySafe() { return false }
6
7// 注意:方法接收 input 参数,允许基于具体输入做决策
8// 某些工具可能根据不同的输入返回不同的并发安全性

interruptBehavior() — 用户在工具执行期间提交新消息时,这个工具应该立即取消还是等待完成?

  • 'cancel' — 立即中止(大多数工具的默认行为)
  • 'block' — 继续运行,新消息等待(如正在执行 git commit 的 BashTool)

注意这个方法不接受 input 参数——中断行为是工具级别的,不依赖于具体输入。

isReadOnly(input) — 是否是只读操作?只读工具在权限评估中通常获得更宽松的待遇。

isDestructive?(input) — 是否执行不可逆操作(删除、覆盖、发送)?这个方法是可选的,默认为 false。它为权限系统提供了额外的风险信号。

isEnabled() — 工具是否在当前环境中可用?src/tools.tsgetToolsForDefaultPreset() 函数会过滤掉 isEnabled() 返回 false 的工具。

ToolResult:工具的返回类型

工具执行后返回的不是原始数据,而是一个结构化的 ToolResult<T>

src/Tool.ts:321-336
TypeScript
321export type ToolResult<T> = {
322 data: T // 工具的实际输出
323 newMessages?: ( // 需要注入到对话历史的消息
324 | UserMessage
325 | AssistantMessage
326 | AttachmentMessage
327 | SystemMessage
328 )[]
329 contextModifier?: (context: ToolUseContext) => ToolUseContext // 修改后续上下文
330 mcpMeta?: { // MCP 协议元数据透传
331 _meta?: Record<string, unknown>
332 structuredContent?: Record<string, unknown>
333 }
334}

contextModifier 字段特别有趣——它允许工具修改后续工具的执行上下文。但这个功能有一个重要限制:只有非并发安全的工具才会应用 context modifier。这是因为并发工具的执行顺序不确定,如果它们都试图修改上下文,结果将不可预测。


工具注册与发现

静态注册

所有内置工具在 src/tools.ts 中注册。getAllBaseTools() 函数(第 193 行)是工具的 source of truth:

src/tools.ts:193-251
TypeScript
193export function getAllBaseTools(): Tools {
194 return [
195 AgentTool,
196 TaskOutputTool,
197 BashTool,
198 // 嵌入式搜索工具时跳过 Glob/Grep
199 ...(hasEmbeddedSearchTools() ? [] : [GlobTool, GrepTool]),
200 ExitPlanModeV2Tool,
201 FileReadTool,
202 FileEditTool,
203 FileWriteTool,
204 NotebookEditTool,
205 WebFetchTool,
206 TodoWriteTool,
207 WebSearchTool,
208 // ... 更多工具
209 ...(isToolSearchEnabledOptimistic() ? [ToolSearchTool] : []),
210 ]
211}

这个函数展示了几种工具注册模式:

1. 编译期 Feature Flag

src/tools.ts:29-35
TypeScript
29const cronTools = feature('AGENT_TRIGGERS')
30 ? [
31 require('./tools/ScheduleCronTool/CronCreateTool.js').CronCreateTool,
32 require('./tools/ScheduleCronTool/CronDeleteTool.js').CronDeleteTool,
33 require('./tools/ScheduleCronTool/CronListTool.js').CronListTool,
34 ]
35 : []

feature() 是 Bun 的编译期宏——当 AGENT_TRIGGERS 在构建时为 false,整个 require() 分支及其传递依赖会从最终产物中删除。

2. 惰性 require 打破循环依赖

src/tools.ts:63-72
TypeScript
63const getTeamCreateTool = () =>
64 require('./tools/TeamCreateTool/TeamCreateTool.js')
65 .TeamCreateTool as typeof import('./tools/TeamCreateTool/TeamCreateTool.js').TeamCreateTool
66const getTeamDeleteTool = () =>
67 require('./tools/TeamDeleteTool/TeamDeleteTool.js')
68 .TeamDeleteTool

注意 getTeamCreateTool() 使用了惰性 require() 而不是顶层 import。这是为了打破循环依赖——TeamCreateTool 的实现可能依赖了 tools.ts 导出的某些类型或常量。

3. 环境变量条件注册

src/tools.ts:16-19
TypeScript
16const REPLTool =
17 process.env.USER_TYPE === 'ant'
18 ? require('./tools/REPLTool/REPLTool.js').REPLTool
19 : null

某些工具只对特定用户类型可用(如 Anthropic 内部用户)。

工具池组装:assembleToolPool

getAllBaseTools() 只是第一步。实际暴露给 AI 的工具列表还要经过过滤和合并。assembleToolPool()(第 345 行)是组装最终工具池的统一入口:

src/tools.ts:345-367
TypeScript
345export function assembleToolPool(
346 permissionContext: ToolPermissionContext,
347 mcpTools: Tools,
348): Tools {
349 const builtInTools = getTools(permissionContext)
350 const allowedMcpTools = filterToolsByDenyRules(mcpTools, permissionContext)
351 // 排序以保证 prompt cache 稳定性
352 const byName = (a: Tool, b: Tool) => a.name.localeCompare(b.name)
353 return uniqBy(
354 [...builtInTools].sort(byName).concat(allowedMcpTools.sort(byName)),
355 'name',
356 )
357}

这个函数做了三件事:

  1. 获取内置工具——通过 getTools() 过滤已禁用和被 deny 规则阻止的工具
  2. 过滤 MCP 工具——同样应用 deny 规则
  3. 去重合并——内置工具优先,按名称排序以保证 prompt cache 稳定性

排序的目的不仅仅是整洁——注释中解释道,Anthropic API 服务端会在内置工具的最后一个位置放置 cache breakpoint。如果 MCP 工具被排序到内置工具中间,会导致所有下游 cache key 失效。

延迟发现:ToolSearchTool

当工具太多(40+),将所有工具定义放入 System Prompt 会消耗大量 token。Claude Code 的解决方案是延迟加载

...

每个工具可以通过两个字段控制延迟加载行为:

  • shouldDefer — 设为 true 的工具在工具数量超过阈值时会被延迟加载
  • alwaysLoad — 设为 true 的工具永远不会被延迟,始终出现在初始 prompt 中

ToolSearchTool 支持三种查询模式:

  1. 精确选择: "select:Read,Edit,Grep" — 按名称获取特定工具
  2. 关键词搜索: "notebook jupyter" — 模糊匹配,使用 searchHint 字段辅助
  3. 前缀+关键词: "+slack send" — 要求名称包含 "slack",再按其余词排序

每个工具的 searchHint 字段提供了关键词匹配支持:

TypeScript
1// 工具的 searchHint 示例
2// AgentTool
3searchHint: "subagent parallel background isolation worktree"
4
5// NotebookEditTool
6searchHint: "jupyter ipynb cell"
7
8// 当 AI 搜索 "parallel" 时,AgentTool 会被匹配到
9// searchHint 描述说明:"3-10 words, no trailing period.
10// Prefer terms not already in the tool name"

MCP 动态工具

除了内置工具和延迟发现,工具还可以来自外部 MCP(Model Context Protocol)server。当一个 MCP server 连接后,它暴露的所有 tool 会被动态注册到工具池。MCP 工具有一个特殊的 mcpInfo 字段:

src/Tool.ts:451-455
TypeScript
451mcpInfo?: { serverName: string; toolName: string }

MCP 工具的名称通常被前缀化为 mcp__serverName__toolName 格式,而 mcpInfo 保存了原始的、未前缀化的服务器名和工具名。这让权限系统可以按 MCP 服务器级别配置规则(如 mcp__database 匹配该服务器的所有工具)。

MCP 工具也可以通过 _meta['anthropic/alwaysLoad'] 标记自己为 alwaysLoad,确保在延迟加载模式下仍然始终可见。


StreamingToolExecutor:并发安全的工具编排

当 API 响应包含多个 tool_use 调用时,StreamingToolExecutorsrc/services/tools/StreamingToolExecutor.ts)负责决定如何执行它们——并行还是串行。

TrackedTool:工具的运行时状态

src/services/tools/StreamingToolExecutor.ts:19-33
TypeScript
19type ToolStatus = 'queued' | 'executing' | 'completed' | 'yielded'
20
21type TrackedTool = {
22 id: string
23 block: ToolUseBlock
24 assistantMessage: AssistantMessage
25 status: ToolStatus
26 isConcurrencySafe: boolean
27 promise?: Promise<void>
28 results?: Message[]
29 pendingProgress: Message[] // 进度消息缓冲区
30 contextModifiers?: Array<(context: ToolUseContext) => ToolUseContext>
31}

每个工具在执行器中经历四个状态:queued(排队等待)→ executing(执行中)→ completed(执行完成,结果已收集)→ yielded(结果已发射到上层)。

并发调度模型

sequenceDiagram
    participant API as API 响应
    participant STE as StreamingToolExecutor
    participant T1 as GrepTool (并发安全)
    participant T2 as FileReadTool (并发安全)
    participant T3 as BashTool (非并发安全)
    participant T4 as FileWriteTool (非并发安全)

    API->>STE: tool_use[1]: grep "login"
    API->>STE: tool_use[2]: read "auth.ts"
    API->>STE: tool_use[3]: bash "npm test"
    API->>STE: tool_use[4]: write "fix.ts"

    Note over STE: 分类:[1,2] 并发安全,[3,4] 非并发安全

    par 并行执行并发安全的工具
        STE->>T1: 执行 grep
        STE->>T2: 执行 read
    end
    T1-->>STE: 结果 1
    T2-->>STE: 结果 2

    Note over STE: 等待并行工具完成后,串行执行非并发安全的工具
    STE->>T3: 执行 bash
    T3-->>STE: 结果 3
    STE->>T4: 执行 write
    T4-->>STE: 结果 4

    STE-->>API: [结果 1, 2, 3, 4](保持原始顺序)

调度逻辑在 canExecuteTool() 方法(第 129 行)中实现:

src/services/tools/StreamingToolExecutor.ts:129-135
TypeScript
129private canExecuteTool(isConcurrencySafe: boolean): boolean {
130 const executingTools = this.tools.filter(t => t.status === 'executing')
131 return (
132 executingTools.length === 0 ||
133 (isConcurrencySafe && executingTools.every(t => t.isConcurrencySafe))
134 )
135}

规则非常精炼:

  • 如果没有工具在执行 → 可以执行任何工具
  • 如果有工具在执行,且当前工具和所有正在执行的工具都是并发安全的 → 可以执行
  • 否则 → 排队等待

processQueue() 方法(第 140 行)在工具入队或工具完成时被调用,它遍历队列并启动所有可以执行的工具。对于非并发安全的工具,一旦遇到无法执行的就停止遍历——保证顺序性。

工具入队:addTool

当流式 API 响应中出现一个 tool_use block 时,addTool() 方法(第 76 行)被调用:

src/services/tools/StreamingToolExecutor.ts:76-124
TypeScript
76addTool(block: ToolUseBlock, assistantMessage: AssistantMessage): void {
77 const toolDefinition = findToolByName(this.toolDefinitions, block.name)
78 if (!toolDefinition) {
79 // 未知工具:立即标记为 completed,结果是错误消息
80 this.tools.push({
81 id: block.id, block, assistantMessage,
82 status: 'completed',
83 isConcurrencySafe: true,
84 pendingProgress: [],
85 results: [/* error: "No such tool available" */],
86 })
87 return
88 }
89
90 // 解析输入并判断并发安全性
91 const parsedInput = toolDefinition.inputSchema.safeParse(block.input)
92 const isConcurrencySafe = parsedInput?.success
93 ? (() => {
94 try { return Boolean(toolDefinition.isConcurrencySafe(parsedInput.data)) }
95 catch { return false }
96 })()
97 : false
98
99 this.tools.push({
100 id: block.id, block, assistantMessage,
101 status: 'queued', isConcurrencySafe,
102 pendingProgress: [],
103 })
104
105 void this.processQueue()
106}

注意并发安全性的判断:如果输入解析失败(safeParse 返回 success: false),工具被视为非并发安全的。这是一个安全的默认值——如果连输入都无法解析,串行执行更安全。

Sibling Abort:级联取消

当一个 Bash 工具执行失败时,正在并行执行的其他工具会被取消。但这里有一个重要的细节——只有 BashTool 的错误会触发级联取消

src/services/tools/StreamingToolExecutor.ts:354-363
TypeScript
354if (isErrorResult) {
355 thisToolErrored = true
356 // Only Bash errors cancel siblings. Bash commands often have implicit
357 // dependency chains (e.g. mkdir fails → subsequent commands pointless).
358 // Read/WebFetch/etc are independent — one failure shouldn't nuke the rest.
359 if (tool.block.name === BASH_TOOL_NAME) {
360 this.hasErrored = true
361 this.erroredToolDescription = this.getToolDescription(tool)
362 this.siblingAbortController.abort('sibling_error')
363 }
364}

为什么只有 Bash?因为 Bash 命令通常有隐含的依赖链——如果 mkdir 失败了,后续的 cd && make 也没有意义。而 FileReadTool 或 WebFetchTool 的失败通常是独立的——一个文件读取失败不应该取消其他文件的读取。

siblingAbortControllertoolUseContext.abortController子控制器(第 59 行):

src/services/tools/StreamingToolExecutor.ts:53-61
TypeScript
53constructor(
54 private readonly toolDefinitions: Tools,
55 private readonly canUseTool: CanUseToolFn,
56 toolUseContext: ToolUseContext,
57) {
58 this.toolUseContext = toolUseContext
59 this.siblingAbortController = createChildAbortController(
60 toolUseContext.abortController,
61 )
62}

取消 siblingAbortController 不会取消父级的 toolUseContext.abortController——query 循环不会因为一个 Bash 错误而结束整个 turn。被取消的兄弟工具会收到一个合成的错误消息:

src/services/tools/StreamingToolExecutor.ts:153-205
TypeScript
153private createSyntheticErrorMessage(
154 toolUseId: string,
155 reason: 'sibling_error' | 'user_interrupted' | 'streaming_fallback',
156 assistantMessage: AssistantMessage,
157): Message {
158 if (reason === 'user_interrupted') {
159 // 用户中断:返回 REJECT_MESSAGE
160 }
161 if (reason === 'streaming_fallback') {
162 // 流式降级:返回降级错误
163 }
164 // sibling_error:返回 "Cancelled: parallel tool call X errored"
165 const desc = this.erroredToolDescription
166 const msg = desc
167 ? `Cancelled: parallel tool call ${desc} errored`
168 : 'Cancelled: parallel tool call errored'
169 // ...
170}

进度缓冲与有序发射

工具执行期间会发出进度消息(如搜索进度、文件读取进度、子进程输出)。这些消息的处理策略是:

  • Progress messages → 立即 yield,实时显示给用户
  • Result messages → 按原始 tool_use 顺序 yield,保证对话历史一致

这个分离在 getCompletedResults() 方法(第 412 行)中实现:

src/services/tools/StreamingToolExecutor.ts:412-440
TypeScript
412*getCompletedResults(): Generator<MessageUpdate, void> {
413 for (const tool of this.tools) {
414 // 始终立即 yield 进度消息,不管工具状态
415 while (tool.pendingProgress.length > 0) {
416 const progressMessage = tool.pendingProgress.shift()!
417 yield { message: progressMessage, newContext: this.toolUseContext }
418 }
419
420 if (tool.status === 'yielded') continue
421
422 if (tool.status === 'completed' && tool.results) {
423 tool.status = 'yielded'
424 for (const message of tool.results) {
425 yield { message, newContext: this.toolUseContext }
426 }
427 } else if (tool.status === 'executing' && !tool.isConcurrencySafe) {
428 break // 非并发工具必须保持顺序
429 }
430 }
431}

注意最后的 break——当遇到一个正在执行的非并发安全工具时,停止发射后续工具的结果。这保证了非并发工具的结果严格按顺序出现在对话历史中。

getRemainingResults()(第 453 行)中,执行器使用 Promise.race 同时等待工具完成和进度消息:

src/services/tools/StreamingToolExecutor.ts:466-484
TypeScript
466// 等待任一工具完成或进度消息可用
467const progressPromise = new Promise<void>(resolve => {
468 this.progressAvailableResolve = resolve
469})
470await Promise.race([...executingPromises, progressPromise])

当工具的 executeTool() 方法接收到进度消息时,它会将消息推入 pendingProgress 数组并触发 progressAvailableResolve,唤醒等待中的 getRemainingResults()

Discard:流式降级时的清理

StreamingToolExecutor 还有一个 discard() 方法(第 69 行),用于流式降级场景:

TypeScript
1discard(): void {
2 this.discarded = true
3}

当 API 流式传输失败需要降级到非流式模式时,之前已经开始执行的工具结果需要被丢弃。设置 discarded = true 后,getCompletedResults()getRemainingResults() 会立即返回,不再发射任何结果。排队中的工具也不会启动。


权限系统:六层安全防线

工具系统最关键的设计不是能力,而是约束。Claude Code 实现了一个六层权限评估流水线,每个工具调用都必须通过这个流水线才能执行。

ToolPermissionContext:不可变的权限上下文

src/Tool.ts:123-138
TypeScript
123export type ToolPermissionContext = DeepImmutable<{
124 mode: PermissionMode // 'default' | 'plan' | 'auto' | 'bypassPermissions'
125
126 // 三种规则集,每种按来源分组
127 alwaysAllowRules: ToolPermissionRulesBySource // 自动允许
128 alwaysDenyRules: ToolPermissionRulesBySource // 自动拒绝
129 alwaysAskRules: ToolPermissionRulesBySource // 总是询问用户
130
131 // 文件系统作用域
132 additionalWorkingDirectories: Map<string, AdditionalWorkingDirectory>
133
134 // 高级选项
135 isBypassPermissionsModeAvailable: boolean
136 isAutoModeAvailable?: boolean
137 shouldAvoidPermissionPrompts?: boolean // 后台 agent 不弹对话框
138 awaitAutomatedChecksBeforeDialog?: boolean // Coordinator worker
139 prePlanMode?: PermissionMode // plan 模式退出后恢复
140}>

DeepImmutable<> 包装是这个设计的核心。DeepImmutable 类型(来自 src/types/utils.ts)递归地将所有属性标记为 readonly,包括嵌套对象和 Map。这意味着权限上下文一旦创建就不能被修改。

为什么不可变性如此重要?因为权限决策必须基于一致的状态。考虑一个竞态条件:

  1. 线程 A 读取 alwaysDenyRules,发现没有匹配
  2. 线程 B 添加了一条新的 deny 规则
  3. 线程 A 继续执行,基于过时的规则允许了一个应该被拒绝的操作

DeepImmutable 在编译时阻止了步骤 2 的发生——任何尝试修改权限上下文的代码都会产生 TypeScript 编译错误。需要更新权限时,必须创建一个全新的 ToolPermissionContext 对象。

getEmptyToolPermissionContext()(第 140 行)提供了默认的空权限上下文:

src/Tool.ts:140-148
TypeScript
140export const getEmptyToolPermissionContext: () => ToolPermissionContext =
141 () => ({
142 mode: 'default',
143 additionalWorkingDirectories: new Map(),
144 alwaysAllowRules: {},
145 alwaysDenyRules: {},
146 alwaysAskRules: {},
147 isBypassPermissionsModeAvailable: false,
148 })

规则来源追踪

规则不只有"允许"和"拒绝",它们还有来源ToolPermissionRulesBySource 记录了每条规则来自哪里:

  • 用户规则 — 用户在 ~/.claude/settings.json 中配置的
  • 项目规则 — 项目 .claude/settings.json 中的
  • 策略规则 — 组织管理员通过 MDM/远程配置下发的

来源追踪的目的不只是审计,更是优先级判断:策略规则优先于项目规则,项目规则优先于用户规则。当规则冲突时,高优先级的来源"赢"。

filterToolsByDenyRules() 函数(第 262 行)在工具池组装阶段就过滤掉被全局 deny 的工具:

src/tools.ts:262-269
TypeScript
262export function filterToolsByDenyRules<
263 T extends { name: string; mcpInfo?: { serverName: string; toolName: string } },
264>(tools: readonly T[], permissionContext: ToolPermissionContext): T[] {
265 return tools.filter(tool => !getDenyRuleForTool(permissionContext, tool))
266}

注意泛型约束——它同时支持内置工具(只有 name)和 MCP 工具(有 mcpInfo),让同一个过滤逻辑适用于两种工具来源。

六层评估流水线

...

各层详解:

Layer 1 — Hook 预审: 如果配置了 PreToolUse hooks(用户在 settings.json 中定义的 shell 命令),先执行 hook。Hook 可以直接通过(behavior: 'allow')或拒绝(behavior: 'deny')工具调用,还可以通过 updatedInput 修改工具输入。PermissionContext.ts 中的 runHooks() 方法(第 216 行)实现了这个逻辑。

Layer 2 — 自动化分类器(BASH_CLASSIFIER): 特定于 BashTool,使用分类器自动判断命令是否安全。tryClassifier() 方法(第 176 行)仅在 BASH_CLASSIFIER feature flag 启用时存在。分类器通过后会记录 approval 信息,用于 TRANSCRIPT_CLASSIFIER 功能。

Layer 3 — alwaysDeny 规则: 如果工具调用匹配了任何"总是拒绝"规则,直接拒绝。不可覆盖。

Layer 4 — alwaysAllow 规则: 如果工具调用匹配了"总是允许"规则,直接放行。

Layer 5 — 权限模式检查: 根据当前 PermissionMode 决定是否需要用户确认。

Layer 6 — 交互式对话框: 弹出终端对话框让用户决定。用户可以选择"允许"、"拒绝"或"总是允许此类操作"。选择"总是允许"时,系统会通过 persistPermissions() 方法(第 139 行)将规则持久化到配置文件中。

PermissionContext:权限处理的核心

src/hooks/toolPermission/PermissionContext.ts 中的 createPermissionContext() 函数(第 96 行)创建了权限处理的核心上下文。它提供了以下关键能力:

src/hooks/toolPermission/PermissionContext.ts:96-104
TypeScript
96function createPermissionContext(
97 tool: ToolType,
98 input: Record<string, unknown>,
99 toolUseContext: ToolUseContext,
100 assistantMessage: AssistantMessage,
101 toolUseID: string,
102 setToolPermissionContext: (context: ToolPermissionContext) => void,
103 queueOps?: PermissionQueueOps,
104)

这个上下文对象包含了多个辅助方法:

  • logDecision() — 记录权限决策到分析系统
  • logCancelled() — 记录工具取消事件
  • persistPermissions() — 将用户的权限选择持久化到配置文件
  • resolveIfAborted() — 检查是否已被取消(如用户按 Ctrl+C)
  • cancelAndAbort() — 拒绝工具并中止执行
  • runHooks() — 执行 PreToolUse hooks
  • tryClassifier() — 尝试自动化分类器(条件存在)
  • buildAllow() / buildDeny() — 构造允许/拒绝决策对象
  • handleUserAllow() — 处理用户批准(可能包含权限持久化)

注意 cancelAndAbort() 方法(第 154 行)的微妙逻辑——它会根据是否是 subagent 选择不同的拒绝消息:

TypeScript
1cancelAndAbort(feedback?, isAbort?, contentBlocks?): PermissionDecision {
2 const sub = !!toolUseContext.agentId
3 const baseMessage = feedback
4 ? `${sub ? SUBAGENT_REJECT_MESSAGE_WITH_REASON_PREFIX : REJECT_MESSAGE_WITH_REASON_PREFIX}${feedback}`
5 : sub ? SUBAGENT_REJECT_MESSAGE : REJECT_MESSAGE
6 // subagent 不中止父级
7 if (isAbort || (!feedback && !contentBlocks?.length && !sub)) {
8 toolUseContext.abortController.abort()
9 }
10 return { behavior: 'ask', message, contentBlocks }
11}

三种权限处理器

权限评估的实际执行由三种处理器之一完成,取决于当前的运行模式:

Text
1src/hooks/toolPermission/handlers/
2├── interactiveHandler.ts // 交互模式:弹出对话框让用户决定
3├── coordinatorHandler.ts // Coordinator 模式:自动化分类 + 可选用户确认
4└── swarmWorkerHandler.ts // Swarm Worker 模式:委托给调度者
  • interactiveHandler — 最常见的处理器。当工具需要用户批准时,渲染一个终端对话框,显示工具名称、参数、风险级别,让用户选择"允许"、"拒绝"或"总是允许此类操作"。
  • coordinatorHandler — 在多 Agent 模式下,Worker 的工具调用先经过自动化分类,只有分类器无法确定的调用才提升到用户。awaitAutomatedChecksBeforeDialog 标志控制是否等待分类结果再弹出用户对话框。
  • swarmWorkerHandler — Swarm 中的 Worker 将权限决策委托给 Coordinator,自己不做判断。

PermissionQueueOps 接口(第 57-61 行)解耦了权限 UI 和权限逻辑:

TypeScript
1type PermissionQueueOps = {
2 push(item: ToolUseConfirm): void
3 remove(toolUseID: string): void
4 update(toolUseID: string, patch: Partial<ToolUseConfirm>): void
5}

在 REPL 模式下,这些操作由 React 状态支撑;在 SDK 模式下可能有完全不同的实现。

ResolveOnce:竞态安全的决策解析

权限系统面临一个典型的竞态条件:用户按"允许"的同时,abort 信号也到达了。如果两个回调都尝试 resolve 同一个 Promise,会产生不可预期的行为。

createResolveOnce<T>()(第 75-93 行)提供了原子级的竞态保护:

src/hooks/toolPermission/PermissionContext.ts:75-93
TypeScript
75function createResolveOnce<T>(resolve: (value: T) => void): ResolveOnce<T> {
76 let claimed = false
77 let delivered = false
78 return {
79 resolve(value: T) {
80 if (delivered) return
81 delivered = true
82 claimed = true
83 resolve(value)
84 },
85 isResolved() { return claimed },
86 claim() {
87 if (claimed) return false
88 claimed = true
89 return true
90 },
91 }
92}

claim() 方法提供了"先声明,后执行"的模式——在 async 回调中,先调用 claim() 获取独占权,再执行可能有副作用的操作。这关闭了 isResolved() 检查和 resolve() 调用之间的竞态窗口。

权限模式对比

模式行为典型场景
default危险操作询问用户,安全操作自动允许正常交互使用
plan只允许只读操作,禁止一切修改规划阶段,不执行
auto大部分操作自动允许,高风险仍询问批量自动化任务
bypassPermissions所有操作自动允许受信任的自动化环境

prePlanMode 字段记录了进入 plan 模式前的模式,以便退出 plan 模式时恢复。


工具的完整执行流程

将上面所有层面整合,一个工具从被 AI 调用到执行完成的完整流程:

sequenceDiagram
    participant AI as LLM 响应
    participant Q as query() 循环
    participant STE as StreamingToolExecutor
    participant Perm as 权限系统
    participant Tool as 具体工具

    AI->>Q: tool_use: { name: "Bash", input: { command: "npm test" } }
    Q->>STE: addTool(toolUseBlock, assistantMessage)

    STE->>STE: findToolByName() 查找工具定义
    STE->>STE: inputSchema.safeParse() + isConcurrencySafe()

    Note over STE: canExecuteTool() 检查并发条件

    STE->>Perm: runToolUse() -> canUseTool()
    Perm->>Perm: Layer 1: runHooks()
    Perm->>Perm: Layer 2: tryClassifier()
    Perm->>Perm: Layer 3-4: deny/allow 规则匹配
    Perm->>Perm: Layer 5: 权限模式检查
    alt 需要用户确认
        Perm->>Perm: Layer 6: queueOps.push() 弹出对话框
    end

    alt 允许
        Perm-->>STE: PermissionAllowDecision
        STE->>Tool: call(args, context, canUseTool, parentMessage, onProgress)
        Tool->>Tool: 执行实际操作
        loop 进度更新
            Tool-->>STE: onProgress(progressMessage)
            STE-->>Q: 立即 yield 进度消息
        end
        Tool-->>STE: ToolResult { data, newMessages? }
        STE-->>Q: yield 结果消息(按顺序)
    else 拒绝
        Perm-->>STE: PermissionDenyDecision
        STE-->>Q: yield 拒绝消息
        Q->>AI: 告知 AI 工具被拒绝
    end

工具输出管理

工具执行后的输出不是直接发给 AI 的——它经过截断和格式化。

Token 预算与 maxResultSizeChars

每个工具声明 maxResultSizeChars,控制输出的最大字符数。这个值因工具而异:

TypeScript
1// BashTool: maxResultSizeChars = 100_000
2// GrepTool: 根据 head_limit 动态计算
3// FileReadTool: maxResultSizeChars = Infinity(特殊情况)

FileReadToolmaxResultSizeChars 设为 Infinity——源码注释解释了原因:

Set to Infinity for tools whose output must never be persisted (e.g. Read, where persisting creates a circular Read→file→Read loop and the tool already self-bounds via its own limits).

(译:对于输出不应被持久化的工具,设为 Infinity(例如 Read,持久化会创建 Read→文件→Read 的循环读取,且该工具已通过自身的限制参数控制输出大小)。)

如果 Read 的输出被持久化到磁盘文件,AI 可能再次用 Read 读取这个文件,形成无限循环。Read 已经通过自己的 offset/limit 参数控制了输出大小,不需要外部截断。

超出 maxResultSizeChars 限制的输出会被保存到临时文件,AI 收到的是一个预览加上文件路径——而不是完整内容。ContentReplacementStatesrc/utils/toolResultStorage.ts)管理这个持久化过程。

大文件读取策略

FileReadTool 对大文件提供了分页参数:

  • offset — 从第 N 行开始读取
  • limit — 读取 N 行
  • pages — PDF 文件的页码范围(如 "1-5")

这让 AI 可以按需读取文件的特定部分,而不是将整个大文件加载到上下文中。

输出映射:从工具结果到 API 格式

每个工具必须实现 mapToolResultToToolResultBlockParam() 方法,将自己的输出转换为 Anthropic API 的 ToolResultBlockParam 格式:

TypeScript
1mapToolResultToToolResultBlockParam(
2 content: Output,
3 toolUseID: string,
4): ToolResultBlockParam

这个方法负责将工具特定的数据格式(如文件内容、搜索结果、命令输出)序列化为 API 可以理解的文本或图片 block。


45 个工具的分类总览

文件操作
FileReadTool
多格式读取
FileWriteTool
全量写入
FileEditTool
精确替换
GlobTool
模式匹配
GrepTool
内容搜索
执行
BashTool
Shell 命令
PowerShellTool
Windows
NotebookEditTool
Jupyter
Agent
AgentTool
子 Agent
SendMessageTool
Agent 通信
TeamCreateTool
Team 模式
扩展
MCP 工具
MCP 协议
SkillTool
技能执行
ToolSearchTool
延迟发现
Web
WebFetchTool
URL 获取
WebSearchTool
搜索
状态与模式
TaskCreateTool
任务管理
EnterPlanModeTool
规划模式
EnterWorktreeTool
工作树隔离
自动化
CronCreateTool
定时任务
RemoteTriggerTool
远程触发
SleepTool
主动等待

每个工具的具体设计将在后续的专题文章中深入。第 21 篇将剖析文件操作三剑客,第 22 篇深入 BashTool,第 08 篇展开多 Agent 编排。


ToolUseContext:工具的运行时环境

每个工具执行时都会收到一个 ToolUseContext 对象(src/Tool.ts:158-300),它包含了工具需要的一切运行时信息。这个类型有 40+ 个字段,是 Claude Code 中最大的上下文类型之一。

核心字段分为几类:

配置与选项:

TypeScript
1options: {
2 commands: Command[] // 可用命令列表
3 tools: Tools // 可用工具列表
4 mainLoopModel: string // 当前使用的模型
5 mcpClients: MCPServerConnection[] // MCP 连接
6 thinkingConfig: ThinkingConfig // 思考模式配置
7 isNonInteractiveSession: boolean // 是否是非交互模式
8 maxBudgetUsd?: number // 预算限制
9 refreshTools?: () => Tools // 动态刷新工具列表
10}

状态管理:

TypeScript
1getAppState(): AppState // 读取全局状态
2setAppState(f: (prev) => AppState) // 更新全局状态
3messages: Message[] // 当前对话历史
4readFileState: FileStateCache // 文件缓存

中止与控制:

TypeScript
1abortController: AbortController // 中止信号
2setInProgressToolUseIDs: (f) => void // 追踪正在执行的工具
3setHasInterruptibleToolInProgress?: (v: boolean) => void

子 Agent 支持:

TypeScript
1agentId?: AgentId // 子 agent 标识
2agentType?: string // agent 类型名
3queryTracking?: QueryChainTracking // 查询链追踪

setAppStateForTasks 字段(第 186 行)值得特别注意——它是专门为后台任务设计的"总是生效"的状态更新器。普通的 setAppState 在 async subagent 中是 no-op(为了避免并发状态冲突),但基础设施操作(如注册/清理后台任务)需要一个始终能到达根 store 的通道。


可迁移的工程模式

1. 行为声明式工具设计

让工具通过 isConcurrencySafe()interruptBehavior()isReadOnly() 等方法自我声明行为特性,而不是由调度器硬编码每个工具的行为。这让新工具可以无缝接入调度系统,不需要修改调度器代码。StreamingToolExecutor 的 canExecuteTool() 方法只有 6 行,却能正确处理任意数量的工具组合——因为调度逻辑完全基于工具的自我声明。

2. Schema 双轨制

当系统需要同时支持内部定义和外部协议时,提供两种 schema 格式(Zod + JSON Schema)是一个务实的选择。关键是在运行时统一处理逻辑——safeParse() 用于内置工具的输入验证,而 MCP 工具的 JSON Schema 在 API 序列化时直接使用。调用方不需要关心 schema 的来源格式。

3. 分层权限与来源追踪

权限系统中记录规则来源(用户/项目/策略)的做法值得借鉴。它不仅让冲突解决有据可循,还让审计追踪成为可能——管理员可以看到哪些权限来自组织策略,哪些是用户自行配置的。filterToolsByDenyRules() 在工具池组装阶段就过滤掉不可用的工具,避免了 AI 浪费 token 去调用一个永远会被拒绝的工具。

4. 不可变权限上下文

DeepImmutable<ToolPermissionContext> 的设计确保了权限评估的一致性。在任何需要安全关键决策的系统中,决策上下文的不可变性是防止 TOCTOU(Time-of-check to time-of-use)漏洞的有效手段。更新权限时创建新对象而不是修改现有对象,这个模式在 React 的状态管理中也广泛使用。

5. 级联取消的选择性传播

StreamingToolExecutor 中只有 BashTool 的错误会触发 sibling abort,而不是所有工具的错误。这种"选择性级联"比"全部取消"或"全不取消"更精确——它基于对工具间依赖关系的领域知识做出判断。这个模式可以推广到任何需要并发取消的场景。

6. 竞态安全的决策解析

ResolveOnceclaim() 模式为异步竞态条件提供了一个优雅的解决方案。在权限对话框中,用户操作和系统取消可能同时到达——claim() 确保只有一个回调"赢得"决策权,避免了重复 resolve 和不可预期的副作用。


系列回顾与展望

至此,基础架构篇的三篇文章完成了它的使命:

  1. 第 01 篇 建立了 5 层架构的全局视角
  2. 第 02 篇 深入了引擎层的流式查询循环
  3. 第 03 篇(本篇)剖析了工具层的定义、执行与权限

从下一篇开始,我们进入独立的深度主题。你可以按兴趣跳读: