工具系统:Claude Code 的手与眼

0 阅读23分钟

工具系统:Claude Code 的手与眼

导航


目录


极限场景:五类工具协作完成一个任务

想象这样一个时刻:

用户说"帮我分析这个项目,找出所有 API 接口,生成一份文档"。

模型的执行路径:

① GlobTool + GrepTool     → 并行扫描项目文件(并发安全,同批次执行)
        ↓
② FileReadTool            → 读取关键文件(并发安全)
        ↓
③ AgentTool               → 派子 Agent 做深度分析(启动独立 queryLoop)
        ↓
④ SkillTool               → 加载 /commit 风格的写作 skill(注入上下文)
        ↓
⑤ FileWriteTool           → 写出文档(非并发,独占执行)

这条路径里出现了五种不同类型的工具——它们的 call() 实现完全不同,但 toolOrchestration.ts 对它们一视同仁。这正是统一接口的价值:调度层不需要知道工具内部做了什么

每一步背后,系统都在回答同样的问题:

  1. 这个工具存在吗? — 工具注册中心查找
  2. 输入合法吗? — Zod schema 校验(Zod:TypeScript 优先的运行时校验库,同时生成 JSON Schema)
  3. 这条操作安全吗? — 规则引擎 + LLM 分类器并行判断
  4. 用户允许吗? — 四路权限竞争,第一个 claim 胜出
  5. 可以和其他工具并行跑吗? — 并发安全分组

后面每一节,都是这个场景的一个答案。


什么是工具系统

没有工具,LLM 只能说话;有了工具,LLM 能改变世界。

Claude Code 有 60+ 个工具,覆盖文件读写、Shell 执行、网络请求、子 Agent 调度……它们是怎么组织和管理的?

四个核心组件的空间关系:

tools.png

整个工具系统由五个核心文件驱动:

文件职责
src/Tool.ts统一协议:定义每个工具必须实现的接口
src/tools.ts注册中心:组装可用工具列表
src/services/tools/toolOrchestration.ts并发调度:读写分离执行
src/services/tools/toolExecution.ts执行中间件:schema 校验、hook 触发、遥测埋点
src/utils/permissions/权限层:四路竞争裁决

一切皆工具:Agent 架构的设计哲学

在 Claude Code 里,所有对外的能力都统一成工具——不只是文件读写和 Shell 命令,连"进入 Plan 模式"、"切换 worktree"、"派子 Agent"、"加载 skill",也都是工具。

EnterPlanModeTool    → 模型主动决定进入规划模式
EnterWorktreeTool    → 模型主动决定切换工作目录
AgentTool            → 模型主动决定派子 Agent
SkillTool            → 模型主动决定加载工作流
AskUserQuestionTool  → 模型主动决定向用户提问

这不是偶然的实现细节,而是一个架构决策:让模型通过工具调用来自我决策,而不是被外部逻辑干预

传统的 Agent 框架往往在外部写大量 if/else——"如果上下文超长就压缩"、"如果任务复杂就拆分"。Claude Code 的做法相反:把这些能力暴露成工具,让模型自己判断什么时候该用。EnterPlanModeTool 的存在意味着"是否进入规划模式"是模型的决策,不是框架的决策。

这个设计的代价是:模型必须足够聪明,知道什么时候该调用哪个工具。收益是:框架本身保持简单——queryLoop 只需要执行工具、喂回结果、等待下一步,不需要理解任务语义。

复杂性从框架转移到了模型。


Tool 统一协议:Tool.ts

所有工具都实现同一个接口。这是工具系统能统一调度的基础。

// src/Tool.ts(核心字段,简化;完整接口还有 aliases、outputSchema、extractSearchText 等)
type Tool<Input, Output, P> = {
  name: string
  inputSchema: ZodSchema          // 💡 Zod schema:调用前自动校验,类型安全
                                  //    同时也是发给模型的 JSON Schema 来源——
                                  //    Anthropic API 要求工具以 JSON Schema 描述参数,
                                  //    Claude Code 直接从 Zod 定义生成,类型定义和 API 描述永远同步
  call(args, context, canUseTool, parentMessage, onProgress?): Promise<ToolResult>
  description(input, options): Promise<string>

  // 并发控制
  isConcurrencySafe(input): boolean   // 💡 true = 可与其他工具并行;false = 独占执行
  isReadOnly(input): boolean          // 只读工具不修改文件系统

  // 权限相关
  checkPermissions(input, ctx): Promise<PermissionResult>
  isDestructive?(input): boolean      // 不可逆操作(删除、覆盖)
  toAutoClassifierInput?(input): string  // 给 LLM 分类器看的文本;'' = 跳过分类器

  // UI 渲染
  renderToolUseMessage(input, options): React.ReactNode
  renderToolUseProgressMessage?(progressMessages, options): React.ReactNode
  renderGroupedToolUse?(toolUses, options): React.ReactNode | null

  // 工具搜索(延迟加载)
  shouldDefer?: boolean    // true = 工具 schema 不在初始 prompt 里,需要先 ToolSearch
  alwaysLoad?: boolean     // true = 永远出现在初始 prompt 里,不延迟
  searchHint?: string      // ToolSearch 关键词匹配用的一句话描述
  // ...
}

几个值得注意的设计:

isConcurrencySafe 而不是 isReadOnly

并发安全和只读是两个不同的概念。isReadOnly 表示工具不修改文件系统,但并发安全还要考虑工具内部是否有共享状态。toolOrchestration.tsisConcurrencySafe 来决定是否并行执行——只有两个都为 true 的工具才能同时跑。

内置工具的实际值(来自源码):

工具isConcurrencySafeisReadOnly说明
GlobTooltruetrue纯文件系统查询
GrepTooltruetrue纯文件系统查询
FileReadTooltruetrue只读文件
BashToolfalse(默认)动态判断命令可能有副作用
FileEditToolfalse(默认)false写操作
FileWriteToolfalse(默认)false写操作
WebFetchTooltruetrue网络只读
AgentToolfalse(默认)子 Agent 行为不可预测

false(默认) 表示工具没有显式设置,走 buildTool 的 fail-closed 默认值。

反例:假设有一个 CacheWriteTool,它只读取文件内容然后写入内存缓存,不修改磁盘——所以 isReadOnly=true。但它内部维护一个全局单例缓存,并发写入会产生竞态条件,所以 isConcurrencySafe=falsetoolOrchestration.ts 会把它单独成一个批次串行执行,即使旁边有其他只读工具在等待。

toAutoClassifierInput 的 fail-closed 默认值

默认值是 () => '',意味着"跳过 LLM 分类器"。这是 fail-closed 设计:新工具如果忘记实现这个方法,不会被分类器误判为安全,而是走人工审批路径。安全相关的工具(如 BashTool)必须显式 override,提供分类器能理解的文本。

buildTool 工厂函数

所有工具通过 buildTool(def) 创建,而不是直接实现接口:

// src/Tool.ts:783
export function buildTool<D extends AnyToolDef>(def: D): BuiltTool<D> {
  return {
    ...TOOL_DEFAULTS,
    userFacingName: () => def.name,
    ...def,
  } as BuiltTool<D>
}

// 默认值(fail-closed):
const TOOL_DEFAULTS = {
  isEnabled: () => true,
  isConcurrencySafe: () => false,   // 💡 默认不并发——保守策略
  isReadOnly: () => false,          // 💡 默认有写操作——保守策略
  isDestructive: () => false,
  checkPermissions: () => Promise.resolve({ behavior: 'allow', updatedInput }),
  toAutoClassifierInput: () => '',  // 💡 默认跳过分类器——fail-closed
}

工厂函数的好处:默认值集中在一处,新工具只需要声明"和默认不同的部分",不会因为漏实现某个方法而出现 undefined 错误。

interruptBehavior:用户打断时怎么办

interruptBehavior?(): 'cancel' | 'block'
// cancel:停止工具,丢弃结果
// block:继续运行,新消息等待
// 默认:'block'

用户在工具执行中途发送新消息时,cancel 工具会立即停止(适合搜索类工具),block 工具会继续跑完(适合文件写入,中途停止可能留下损坏的文件)。

四类工具的 call() 实现

统一接口背后,不同类型工具的 call() 实现差异极大:

工具类型call() 本质是否启动子 queryLoop结果如何返回
通用工具(Bash/File/Web)直接 I/O 操作直接返回 ToolResult
AgentTool启动子 Agent是(完整 queryLoop)子 Agent 最终输出作为 tool_result
SkillTool(inline)上下文注入skill 内容注入为 UserMessage
SkillTool(fork)启动子 Agent是(复用 runAgent)子 Agent 输出作为 tool_result
MCPToolRPC 调用外部服务器MCP 服务器响应作为 tool_result

AgentTool:递归 Agent 架构

AgentTool.call() 调用 runAgent()runAgent() 内部调用 query()——启动一个完整的新 queryLoop。父 Agent 的一次工具调用,等于启动了一个子 Agent 的完整生命周期。

// src/tools/AgentTool/runAgent.ts(简化)
export async function* runAgent({ agentDefinition, ... }) {
  // 子 Agent 有自己的系统提示、工具列表、权限模式
  const agentSystemPrompt = await getAgentSystemPrompt(agentDefinition, ...)

  // 同步 Agent:共享父级 abortController(父级 Ctrl+C 会中止子 Agent)
  // 异步 Agent:新建独立 AbortController(父级中止不影响它)
  const agentAbortController = isAsync
    ? new AbortController()
    : toolUseContext.abortController

  // 启动子 queryLoop
  for await (const message of query({ messages, systemPrompt, ... })) {
    yield message  // 子 Agent 的消息流向父 Agent
  }
}

子 Agent 的沙箱隔离:子 Agent 有独立的消息历史,看不到父 Agent 的对话上下文(除非父 Agent 显式通过 prompt 参数传递)。子 Agent 的工具列表由 agentDefinition 决定,可以是父 Agent 工具列表的子集。TodoWrite 的任务列表是全局共享的,子 Agent 可以读写——这是少数几个跨 Agent 共享的状态之一。


**SkillTool:两条路径**

SkillTool 的 `call()` 根据 skill 的 `context` 字段走两条完全不同的路:

```typescript
// src/tools/SkillTool/SkillTool.ts(简化)
async call({ skill, args }, context, canUseTool, parentMessage) {
  const command = findCommand(commandName, commands)

  if (command.context === 'fork') {
    // 路径 1:fork 模式 → 启动子 Agent,和 AgentTool 本质相同
    return executeForkedSkill(command, ...)
    // → runAgent() → 独立 queryLoop
  }

  // 路径 2:inline 模式(默认)→ 上下文注入,不启动子循环
  const skillContent = await processPromptSlashCommand(commandName, args, ...)
  return {
    data: { success: true, status: 'inline' },
    newMessages: [createUserMessage({ content: skillContent, isMeta: true })],
    // 💡 newMessages 会被注入当前对话的下一轮上下文
    // 模型看到 skill 内容后,自己决定怎么用
  }
}

inline 模式的关键:call() 不执行任何操作,只是把 skill 文件内容包装成一条 UserMessageisMeta: true)注入消息流。这是上下文注入,不是控制流转移

MCPTool:运行时注入

MCPTool.ts 里的 call() 是个空壳:

// src/tools/MCPTool/MCPTool.ts
async call() {
  return { data: '' }  // 💡 空壳,真正实现在 mcpClient.ts 里动态覆盖
}

每个 MCP 工具实例化时,mcpClient.tscall() 替换成对应 MCP 服务器的 RPC 调用。这是运行时注入——toolOrchestration.ts 调用 tool.call() 时,拿到的已经是覆盖后的版本,完全不知道背后是 RPC。


工具注册与发现:tools.ts

src/tools.ts 是工具的注册中心。它做三件事:组装工具列表、feature flag 控制、prompt cache 友好排序。

工具列表的组装

// src/tools.ts:193(简化)
export function getAllBaseTools(): Tools {
  return [
    AgentTool,
    TaskOutputTool,
    BashTool,
    // 💡 嵌入式搜索工具(ant 内部构建)存在时,跳过 Glob/Grep
    // 因为 ant 构建把 bfs/ugrep 嵌入了 bun 二进制,shell 里的 find/grep 已经是快速版本
    ...(hasEmbeddedSearchTools() ? [] : [GlobTool, GrepTool]),
    FileReadTool,
    FileEditTool,
    FileWriteTool,
    NotebookEditTool,
    WebFetchTool,
    TodoWriteTool,
    WebSearchTool,
    AskUserQuestionTool,
    SkillTool,
    EnterPlanModeTool,
    // ant 内部工具
    ...(process.env.USER_TYPE === 'ant' ? [ConfigTool, TungstenTool] : []),
    // feature flag 控制的工具
    ...(SleepTool ? [SleepTool] : []),
    ...(isWorktreeModeEnabled() ? [EnterWorktreeTool, ExitWorktreeTool] : []),
    ...(isAgentSwarmsEnabled() ? [TeamCreateTool, TeamDeleteTool] : []),
    // ...更多工具
  ]
}

工具列表的顺序是固定的,不是随机的。这是 prompt cache 友好设计:工具 schema 作为系统提示的一部分发给模型,如果每次请求的工具顺序不同,服务端的 prompt cache 就会 miss,每次都要重新计算。固定顺序 = 稳定的 cache prefix = 长对话的首字延迟大幅降低。

feature flag 控制

工具可用性由三层控制:

process.env.USER_TYPE === 'ant'   → ant 内部工具(外部用户看不到)
feature('FEATURE_FLAG')           → 实验性功能(灰度发布)
tool.isEnabled()                  → 运行时动态检查(如平台兼容性)

getTools()getAllBaseTools() 基础上再过一层 deny rules 过滤,最终返回当前会话可用的工具列表。

工具搜索(延迟加载)

当工具数量超过 60+,把所有工具 schema 塞进初始 prompt 会消耗大量 tokens。Claude Code 引入了 ToolSearchTool

shouldDefer: true   → 工具 schema 不在初始 prompt 里
                       模型需要先调用 ToolSearch 找到工具,再调用工具本身

alwaysLoad: true    → 永远出现在初始 prompt 里(如 BashTool、FileReadTool)

searchHint: string  → ToolSearch 关键词匹配用的描述
                       例如 NotebookEditTool 的 hint 是 'jupyter'

这是一个渐进式加载机制:常用工具直接可见,长尾工具按需加载。

换个角度看,ToolSearch 也是一个意图路由层:模型先用自然语言描述需要什么能力("我需要操作 Jupyter notebook"),ToolSearch 把这个意图映射到具体工具,再把完整 schema 注入后续请求。工具发现本身也是一次工具调用——这正是"一切皆工具"哲学的体现。


工具调度:toolOrchestration.ts

模型一次可以输出多个 tool_use block。toolOrchestration.ts 决定这些工具怎么执行——串行还是并行。

读写分离分组

// src/services/tools/toolOrchestration.ts:91
function partitionToolCalls(toolUseMessages, toolUseContext): Batch[] {
  return toolUseMessages.reduce((acc, toolUse) => {
    const tool = findToolByName(toolUseContext.options.tools, toolUse.name)
    // 💡 先用工具自己的 inputSchema 解析输入,isConcurrencySafe 需要完整的 input 才能判断
    // 例如 BashTool 对 "cat file.txt" 返回 true,对 "rm -rf ." 返回 false
    const parsedInput = tool?.inputSchema.safeParse(toolUse.input)
    const isConcurrencySafe = parsedInput?.success
      ? Boolean(tool?.isConcurrencySafe(parsedInput.data))
      : false  // 解析失败时保守处理,不并发

    // 连续的并发安全工具合并成一个批次,一起并行执行
    if (isConcurrencySafe && acc[acc.length - 1]?.isConcurrencySafe) {
      acc[acc.length - 1].blocks.push(toolUse)
    } else {
      // 非并发安全工具单独成一个批次,串行执行
      acc.push({ isConcurrencySafe, blocks: [toolUse] })
    }
    return acc
  }, [])
}

分组逻辑:把工具调用序列切成若干批次,每个批次要么全是并发安全工具(并行执行),要么是单个非并发安全工具(串行执行)。

这个分组本质上是一个读写屏障(Read-Write Barrier):每当遇到 isConcurrencySafe=false 的工具,前面所有并发批次必须全部完成,才能继续执行。这保证了写操作不会和任何其他操作交叉——不是靠锁,而是靠批次边界的顺序保证。

举个例子:

模型输出:[GlobTool, GrepTool, BashTool, FileReadTool, FileEditTool]

分组结果:
  批次 1:[GlobTool, GrepTool]isConcurrencySafe=true  → 并行执行
  批次 2:[BashTool]isConcurrencySafe=false → 串行执行
  批次 3:[FileReadTool]isConcurrencySafe=true  → 并行执行(只有一个)
  批次 4:[FileEditTool]isConcurrencySafe=false → 串行执行

执行顺序:批次1并行 → 等待 → 批次2 → 批次3 → 批次4

并发执行的结果顺序

并发执行时,工具完成顺序不确定(B 可能比 A 先完成)。但 tool_result 必须和 tool_use 的顺序一致——Anthropic API 要求每个 tool_resulttool_use_id 必须能在 assistant 消息里找到对应的 tool_use

批次内并行调用的时序:

sequenceDiagram
    participant O as toolOrchestration
    participant G as GlobTool
    participant R as GrepTool
    participant B as 结果缓冲区

    Note over O: 批次 1:[GlobTool, GrepTool](并发安全)

    O->>G: call()(不等待)
    O->>R: call()(不等待)

    Note over G,R: 并行执行中...

    R-->>B: 完成(GrepTool 先完成)
    G-->>B: 完成(GlobTool 后完成)

    Note over B: 缓冲区按原始顺序排列
    B-->>O: [GlobTool result, GrepTool result]

    Note over O: 批次 2:[BashTool](非并发安全,等待批次1完成后才开始)

toolOrchestration.ts 用缓冲区解决这个问题:并发执行,但按原始顺序输出结果。

实际上,runToolsConcurrently 调用的是 src/utils/generators.ts 里的 all() 函数,它用 Promise.race 实现流式并发——谁先完成谁先 yield,不等所有人完成:

// src/utils/generators.ts:32
export async function* all<A>(
  generators: AsyncGenerator<A, void>[],
  concurrencyCap = Infinity,
): AsyncGenerator<A, void> {
  const waiting = [...generators]
  const promises = new Set<Promise<...>>()

  // 启动初始批次(最多 concurrencyCap 个)
  while (promises.size < concurrencyCap && waiting.length > 0) {
    promises.add(next(waiting.shift()!))
  }

  while (promises.size > 0) {
    const { done, value, generator } = await Promise.race(promises)
    // 💡 谁先完成谁先 yield,不等其他人
    if (!done) {
      yield value
      promises.add(next(generator))  // 继续这个 generator 的下一个值
    } else if (waiting.length > 0) {
      promises.add(next(waiting.shift()!))  // 补充新 generator
    }
  }
}

这意味着并发批次内的结果是乱序 yield 的——GrepTool 先完成就先返回,不等 GlobTool。tool_result 的顺序由上层调用者(runTools)负责重新对齐,而不是 all() 本身。

最大并发数

// src/services/tools/toolOrchestration.ts:8
function getMaxToolUseConcurrency(): number {
  return parseInt(process.env.CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY || '', 10) || 10
}

默认最多 10 个工具并发执行,可通过环境变量调整。

Hook 执行超时

Hook 脚本有独立的超时机制(src/utils/hooks.ts):

pre-tool-use / post-tool-use hook:默认 10 分钟(TOOL_HOOK_EXECUTION_TIMEOUT_MS)
session-end hook:默认 1.5 秒(SESSION_END_HOOK_TIMEOUT_MS_DEFAULT)

用户可以在 hook 配置里用 timeout 字段覆盖单个 hook 的超时时间(单位:秒)。超时后 hook 进程会被中止,工具调用继续执行(hook 超时不等于工具调用失败)。


权限层:四路竞争

每次工具调用,权限层都要回答一个问题:这个操作允许执行吗?

答案来自四个渠道,它们同时竞争,第一个给出明确答案的渠道胜出:

sequenceDiagram
    participant T as 工具调用请求
    participant R as Rule(规则引擎)
    participant H as Hook(脚本)
    participant C as Classifier(LLM)
    participant U as User(人工)
    participant O as ResolveOnce

    T->>R: 同步检查 allow/deny/ask 规则
    T->>H: 异步执行 pre-tool-use hook
    T->>C: 异步调用 LLM 分类器
    T->>U: 弹出 REPL 确认框

    R-->>O: claim(allow) ← 最快,通常先到
    H-->>O: claim(deny/allow)
    C-->>O: claim(deny/ask)
    U-->>O: claim(allow/deny)

    Note over O: 第一个 claim 生效,其余忽略
    O-->>T: 最终权限决策

为什么是竞争而不是串行?

串行的问题:如果先等 Hook 脚本跑完(可能几秒),再等 Classifier(可能几秒),用户体验很差。竞争模型让最快的渠道直接决定——如果规则引擎已经有明确的 allow 规则,不需要等 Hook 和 Classifier。

ResolveOnce 原子锁

这个模式和数据库的**乐观并发控制(Optimistic Concurrency Control)**有相似之处:不提前加锁阻塞其他竞争者,而是让所有人同时跑,最后用 claim() 做一次原子性的"谁先到谁赢"判断。区别在于数据库的乐观锁失败时需要重试,而 ResolveOnce 的失败者直接丢弃结果——因为权限决策只需要一个答案。

真实实现在 src/hooks/toolPermission/PermissionContext.ts

// src/hooks/toolPermission/PermissionContext.ts:63
type ResolveOnce<T> = {
  resolve(value: T): void
  isResolved(): boolean
  /**
   * 原子性地检查并标记为已解决。
   * 返回 true 表示当前调用者赢得了竞争(没有其他人先解决)。
   * 在异步回调中,应在 await 之前调用,以关闭 isResolved() 检查和 resolve() 调用之间的竞态窗口。
   */
  claim(): boolean
}

function createResolveOnce<T>(resolve: (value: T) => void): ResolveOnce<T> {
  let claimed = false
  let delivered = false
  return {
    resolve(value: T) {
      if (delivered) return   // 已交付,忽略
      delivered = true
      claimed = true
      resolve(value)
    },
    isResolved() {
      return claimed
    },
    claim() {
      if (claimed) return false  // 已被抢占,返回 false
      claimed = true
      return true  // 抢占成功,可以继续 resolve
    },
  }
}

claim() 里的 if (claimed) return false; claimed = true 看起来不是原子操作,但在 Node.js 里是安全的:JavaScript 是单线程事件循环,同步代码执行期间不会被其他回调打断。claim() 没有 await,所以整个函数体是一个不可中断的临界区,不需要 Atomics 或互斥锁。

四路竞争的使用方式:

// src/hooks/toolPermission/handlers/interactiveHandler.ts(简化)
const { resolve: resolveOnce, isResolved, claim } = createResolveOnce(resolve)

// 规则引擎(同步,通常最快)
const ruleResult = checkRules(tool, input, context)
if (ruleResult.behavior !== 'ask') {
  resolveOnce(ruleResult)  // 直接 resolve,后续竞争者会被 delivered 守卫拦截
  return
}

// Hook + Classifier 异步并行
Promise.all([
  runHooks().then(r => { if (claim()) resolveOnce(r) }),
  runClassifier().then(r => { if (claim()) resolveOnce(r) }),
])

// 用户确认框(最慢,作为兜底)
showConfirmDialog({ onAllow: () => { if (claim()) resolveOnce(allow) } })

权限决策的完整流程

// src/services/tools/toolExecution.ts(简化)
async function checkPermissionsAndCallTool(tool, input, context, canUseTool) {
  // Step 1: schema 校验
  const parsedInput = tool.inputSchema.safeParse(input)
  if (!parsedInput.success) {
    return formatZodValidationError(parsedInput.error)
  }

  // Step 2: pre-tool-use hooks
  const hookResult = await runPreToolUseHooks(tool, input, context)
  if (hookResult.behavior === 'deny') return hookResult

  // Step 3: 权限检查(四路竞争)
  const permissionResult = await canUseTool(tool, input, context)

  if (permissionResult.behavior === 'deny') {
    await runPostToolUseFailureHooks(tool, input, context)
    return permissionResult
  }

  // Step 4: 执行工具
  startToolExecutionSpan()
  const result = await tool.call(parsedInput.data, context, canUseTool, ...)

  // Step 5: post-tool-use hooks + 遥测
  await runPostToolUseHooks(tool, input, result, context)
  logEvent('tengu_tool_use', { toolName, duration, ... })

  return result
}

每次工具调用都经过这五步,没有捷径。


PermissionMode 的五种模式

权限层的行为由 PermissionMode 控制:

模式行为适用场景
default正常询问,未知操作弹确认框日常使用
plan只读,拒绝所有写操作规划阶段,只看不改
acceptEdits自动批准文件编辑,其他操作仍询问信任文件操作,但谨慎对待 Shell
bypassPermissions跳过所有权限检查CI/CD、自动化脚本
dontAsk自动拒绝所有需要确认的操作无人值守场景,宁可失败不要阻塞

还有两个内部模式:

  • auto(ant 内部):LLM 分类器自动判断,不弹确认框
  • bubble:权限上浮给父 Agent,子 Agent 自己不决策

plan 模式的特殊逻辑

plan 模式不只是"拒绝写操作"。当模型主动调用 EnterPlanModeTool 时,系统会检查上下文是否超过 200k tokens(doesMostRecentAssistantMessageExceed200k)——如果是,会切换到更小的模型来处理 plan 阶段,避免在 plan 阶段消耗大量 tokens。


规则引擎:allow/deny/ask + glob 匹配

用户可以在配置文件里写权限规则,控制哪些工具调用自动允许、自动拒绝、或者每次询问。

规则格式

工具名                    → 匹配整个工具(等价于 工具名(*))
工具名(内容)              → 匹配特定输入,内容由工具自己的 checkPermissions 解释
Bash(prefix:npm run)     → 匹配以 "npm run" 开头的 Bash 命令
Read(~/projects/**)      → 匹配 ~/projects/ 下的所有文件读取
mcp__server1             → 匹配某个 MCP 服务器的所有工具
mcp__server1__*          → 同上(通配符写法)
mcp__server1__tool1      → 精确匹配某个 MCP 工具

规则内容的通配符语法(由各工具的 checkPermissions 自行解释):

写法含义
Bash(prefix:npm)命令以 npm 开头
Read(~/projects/**)路径 glob,** 匹配任意深度子目录
Read(~/projects/*)路径 glob,* 只匹配当前层级
Skill(commit)精确匹配 skill 名
Skill(review:*)匹配所有以 review: 开头的 skill
工具名(*)工具名()等价于只写工具名,匹配整个工具

括号内的 ( ) 需要转义:Bash(python -c "print\\(1\\)")

规则来源(优先级从高到低)

const PERMISSION_RULE_SOURCES = [
  'cliArg',        // --allowedTools 命令行参数
  'command',       // /allow 命令动态添加
  'session',       // 本次会话临时授权("这次允许")
  'localSettings', // .claude/settings.json(项目级)
  'userSettings',  // ~/.claude/settings.json(用户级)
  'policySettings',// 企业策略(最低优先级)
]

规则匹配逻辑

// src/utils/permissions/permissions.ts:238
function toolMatchesRule(tool, rule): boolean {
  // 规则没有 ruleContent → 匹配整个工具(如 "Bash")
  if (rule.ruleValue.ruleContent === undefined) {
    return rule.ruleValue.toolName === tool.name
  }
  // 有 ruleContent → 工具自己的 checkPermissions 决定是否匹配
  // 例如 BashTool 的 checkPermissions 会检查命令是否匹配 prefix 规则
}

deny rules 的特殊作用

deny rules 不只在调用时检查——filterToolsByDenyRules 在工具列表组装阶段就过滤掉被整体 deny 的工具。模型根本看不到这些工具的 schema,不会尝试调用它们。这比调用时拒绝更彻底:减少了模型的"无效尝试"。

规则冲突时的优先级

规则引擎按来源优先级裁决冲突,不是"最具体匹配优先"。来源越靠前,优先级越高:

场景:
  userSettings(用户配置):  Bash(prefix:npm run) → allow
  localSettings(项目配置):  Bash(*)             → deny

执行 npm run build 时:
  → 两条规则都匹配
  → localSettings 的 deny 优先级低于 userSettings 的 allow
  → 最终:allow ✅

场景反转:
  localSettings(项目配置):  Bash(prefix:npm run) → allow
  userSettings(用户配置):   Bash(*)              → deny

执行 npm run build 时:
  → 两条规则都匹配
  → userSettings 的 deny 优先级高于 localSettings 的 allow
  → 最终:deny ❌

这意味着"更具体的规则"不一定赢——来源层级才是决定因素。如果想让项目级规则覆盖用户级规则,需要在 localSettings 里写,而不是依赖规则的具体程度。

回到极限场景

模型决定执行 rm -rf ./dist

权限层的处理:

  1. 规则引擎:检查是否有 Bash 的 allow/deny 规则。假设用户配置了 Bash(prefix:npm run) 的 allow 规则,但 rm -rf 不匹配这个前缀 → 规则引擎返回 ask
  2. Hook:用户没有配置 pre-tool-use hook → 跳过
  3. ClassifiertoAutoClassifierInput 返回 rm -rf ./dist,LLM 分类器判断这是危险命令 → 返回 deny(或 ask
  4. 竞争结果:Classifier 先给出答案 → 弹出确认框,告知用户"分类器认为这个命令需要确认"

用户看到的是一个带有分类器解释的确认框,而不是一个空洞的"是否允许?"


本系列后续文章

002 是工具系统的入口地图。每类工具对应后续的深度文章:

工具类型对应文章
文件工具(Read/Edit/Write/Glob/Grep)003 文件工具:精确编辑的实现
Shell 工具(Bash)004 BashTool:沙箱、超时与安全边界
问答工具(AskUserQuestion)005 AskUserQuestion:人机协作的接口
Agent 工具(Agent/Skill)006 AgentTool:子 Agent 的生命周期
MCP 工具007 MCP 工具:外部能力的接入协议
Skill 工具008 SkillTool:可复用工作流的实现
Team 工具(TeamCreate/TeamDelete)009 TeamTool:多 Agent 协作编排

系列导航


常见问题 FAQ

Q:isConcurrencySafeisReadOnly 有什么区别?

isReadOnly 是语义标记:这个工具不修改文件系统。isConcurrencySafe 是调度标记:这个工具可以和其他工具同时执行。

两者通常一致,但不总是。例如,一个只读工具如果内部有全局状态(如单例缓存),并发执行可能出问题,此时 isReadOnly=trueisConcurrencySafe=false

toolOrchestration.ts 只看 isConcurrencySafe,不看 isReadOnly


Q:四路权限竞争,如果 Hook 和 Classifier 给出相反的答案怎么办?

ResolveOnce 保证只有第一个 claim 生效。竞争没有固定的优先级顺序——谁先完成谁赢。

但有一个例外:如果规则引擎(Rule)已经有明确的 allow 或 deny 规则,它会同步返回,几乎总是最快的。Hook 和 Classifier 是异步的,通常慢一些。所以实际上,规则引擎的优先级最高——不是因为代码里有优先级逻辑,而是因为它最快。


Q:bypassPermissions 模式安全吗?

不安全,这是设计意图。bypassPermissions 跳过所有权限检查,适合 CI/CD 等完全自动化场景,此时没有人工监督,也不需要确认框。

Claude Code 有一个 killswitch 机制(bypassPermissionsKillswitch.ts):即使在 bypassPermissions 模式下,某些极端危险的操作(如修改 .git/config)仍然会被拦截。这是最后一道防线。

这里有一个值得深思的设计权衡:Agentic Workflow 中,"人类意图的最后防线"应该由代码硬编码(Hard-coded)还是由模型分类(Model-based)?

纯硬编码的问题:规则是静态的,攻击者可以绕过(比如用 git config 的等价写法)。纯模型分类的问题:LLM 本身可能被 prompt injection 操控,让它认为危险操作是安全的。

Claude Code 的答案是两者结合——killswitch 是硬编码的最后防线,不经过任何 LLM 判断;日常的权限决策则交给规则引擎 + 分类器的组合。硬编码守住绝对边界,模型处理灰色地带。这个分层思路在构建任何 Agentic 系统时都值得借鉴。


Q:工具 schema 怎么发给模型?

工具 schema 作为系统提示的一部分,在每次 API 请求时发送。tools.ts 组装工具列表后,query.ts 把工具 schema 序列化成 Anthropic API 要求的格式(JSON Schema),附在请求里。

启用 shouldDefer 的工具不在初始请求里发送完整 schema,只发送一个占位符。模型需要先调用 ToolSearch 找到工具,系统再把完整 schema 注入后续请求。这减少了初始 prompt 的 token 消耗。


Q:MCP 工具和内置工具有什么区别?

内置工具在 src/tools/ 里实现,随 Claude Code 一起发布。MCP 工具由外部 MCP 服务器提供,通过 MCPTool 包装后加入工具列表。

两者都实现 Tool 接口,对 toolOrchestration.ts 和权限层完全透明——调度和权限逻辑不区分内置工具和 MCP 工具。区别在于:MCP 工具的 isMcp=true,权限规则可以用 mcp__server__tool 格式精确控制某个 MCP 服务器的某个工具。