工具系统:Claude Code 的手与眼
导航
- 🔧 协议派? → Tool 统一协议 — 每个工具必须实现什么
- 🗂️ 注册派? → 工具注册与发现 — 工具列表怎么组装
- ⚡ 调度派? → 并发调度 — 读写分离怎么实现
- 🔐 安全派? → 权限层 — 四路竞争怎么裁决
目录
- 极限场景:一个工具调用背后的完整链路
- 什么是工具系统
- Tool 统一协议:Tool.ts
- 工具注册与发现:tools.ts
- 工具调度:toolOrchestration.ts
- 权限层:四路竞争
- PermissionMode 的五种模式
- 规则引擎:allow/deny/ask + glob 匹配
- 本系列后续文章
- 常见问题 FAQ
极限场景:五类工具协作完成一个任务
想象这样一个时刻:
用户说"帮我分析这个项目,找出所有 API 接口,生成一份文档"。
模型的执行路径:
① GlobTool + GrepTool → 并行扫描项目文件(并发安全,同批次执行)
↓
② FileReadTool → 读取关键文件(并发安全)
↓
③ AgentTool → 派子 Agent 做深度分析(启动独立 queryLoop)
↓
④ SkillTool → 加载 /commit 风格的写作 skill(注入上下文)
↓
⑤ FileWriteTool → 写出文档(非并发,独占执行)
这条路径里出现了五种不同类型的工具——它们的 call() 实现完全不同,但 toolOrchestration.ts 对它们一视同仁。这正是统一接口的价值:调度层不需要知道工具内部做了什么。
每一步背后,系统都在回答同样的问题:
- 这个工具存在吗? — 工具注册中心查找
- 输入合法吗? — Zod schema 校验(Zod:TypeScript 优先的运行时校验库,同时生成 JSON Schema)
- 这条操作安全吗? — 规则引擎 + LLM 分类器并行判断
- 用户允许吗? — 四路权限竞争,第一个 claim 胜出
- 可以和其他工具并行跑吗? — 并发安全分组
后面每一节,都是这个场景的一个答案。
什么是工具系统
没有工具,LLM 只能说话;有了工具,LLM 能改变世界。
Claude Code 有 60+ 个工具,覆盖文件读写、Shell 执行、网络请求、子 Agent 调度……它们是怎么组织和管理的?
四个核心组件的空间关系:
整个工具系统由五个核心文件驱动:
| 文件 | 职责 |
|---|---|
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.ts 用 isConcurrencySafe 来决定是否并行执行——只有两个都为 true 的工具才能同时跑。
内置工具的实际值(来自源码):
| 工具 | isConcurrencySafe | isReadOnly | 说明 |
|---|---|---|---|
| GlobTool | true | true | 纯文件系统查询 |
| GrepTool | true | true | 纯文件系统查询 |
| FileReadTool | true | true | 只读文件 |
| BashTool | false(默认) | 动态判断 | 命令可能有副作用 |
| FileEditTool | false(默认) | false | 写操作 |
| FileWriteTool | false(默认) | false | 写操作 |
| WebFetchTool | true | true | 网络只读 |
| AgentTool | false(默认) | — | 子 Agent 行为不可预测 |
false(默认) 表示工具没有显式设置,走 buildTool 的 fail-closed 默认值。
反例:假设有一个 CacheWriteTool,它只读取文件内容然后写入内存缓存,不修改磁盘——所以 isReadOnly=true。但它内部维护一个全局单例缓存,并发写入会产生竞态条件,所以 isConcurrencySafe=false。toolOrchestration.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 |
| MCPTool | RPC 调用外部服务器 | 否 | 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 文件内容包装成一条 UserMessage(isMeta: true)注入消息流。这是上下文注入,不是控制流转移。
MCPTool:运行时注入
MCPTool.ts 里的 call() 是个空壳:
// src/tools/MCPTool/MCPTool.ts
async call() {
return { data: '' } // 💡 空壳,真正实现在 mcpClient.ts 里动态覆盖
}
每个 MCP 工具实例化时,mcpClient.ts 把 call() 替换成对应 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_result 的 tool_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
权限层的处理:
- 规则引擎:检查是否有
Bash的 allow/deny 规则。假设用户配置了Bash(prefix:npm run)的 allow 规则,但rm -rf不匹配这个前缀 → 规则引擎返回ask - Hook:用户没有配置 pre-tool-use hook → 跳过
- Classifier:
toAutoClassifierInput返回rm -rf ./dist,LLM 分类器判断这是危险命令 → 返回deny(或ask) - 竞争结果: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 协作编排 |
系列导航:
- 上一篇: 001 - queryLoop:Agent 运作引擎
- 当前: 002 - 工具系统设计总览 + 权限机制
- 下一篇: 003 - 文件工具:精确编辑的实现
常见问题 FAQ
Q:isConcurrencySafe 和 isReadOnly 有什么区别?
isReadOnly 是语义标记:这个工具不修改文件系统。isConcurrencySafe 是调度标记:这个工具可以和其他工具同时执行。
两者通常一致,但不总是。例如,一个只读工具如果内部有全局状态(如单例缓存),并发执行可能出问题,此时 isReadOnly=true 但 isConcurrencySafe=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 服务器的某个工具。