本章总览
useCanUseTool 是 src/hooks/ 中架构权重最高的 Hook:它把引擎层 hasPermissionsToUseTool 的决策结果,转换成 REPL 可消费的 Promise,并在需要用户确认时驱动 PermissionRequest 弹窗队列。本章带源码走读,要求你能从 query 循环一直追到弹窗 UI。
学完本章你应该能
- 解释 CanUseToolFn 类型与各参数在 tool_use 时的含义
- 画出 allow / deny / ask 三分支及 ask 时的入队逻辑
- 说明 toolPermission/handlers 三种模式的适用场景
- 能在源码树中定位 PermissionContext 与 interactiveHandler
核心概念(先读懂这些)
为什么是 Promise 而不是同步返回
query.ts 在每次 tool_use 前 await canUseTool()。交互式会话中用户点击 Allow 可能数秒后才发生,Ink 仍需刷新 spinner。Promise 把「决策未完成」与「UI 线程」解耦:Hook 内部入队 confirm 项,用户操作后 resolve。Headless/SDK 模式若策略已 allow,则快速 resolve,不弹窗。
PermissionContext 解耦 React
createPermissionContext 把 tool、input、toolUseID 等封装为 ctx,并提供 buildAllow/buildDeny、resolveIfAborted。PermissionQueueOps 抽象 push/remove/update,REPL 用 React state 实现,测试可 mock。这是「引擎逻辑不依赖 React,UI 适配层依赖 React」的典型分层。
建议学习步骤
- 阅读下方源码块 A(CanUseToolFn 类型与 Hook 入口)
- 阅读源码块 B(allow 分支与 classifier 联动)
- 打开 ask 分支,对照 PermissionContext 源码块 C
- 在源码树中点击 toolPermission/ 子目录核对文件清单
常见误区
注意
React Compiler 生成的 _c 缓存语法可忽略,聚焦 t0 回调内的业务分支
注意
不要把 executePermissionRequestHooks(utils/hooks.ts)与本 Hook 混淆
在架构中的调用位置
当 Claude 返回 tool_use block,query 循环在调用 StreamingToolExecutor 之前执行:
query.ts
→ canUseTool(tool, input, toolUseContext, assistantMessage, toolUseID)
↓ REPL 注册:useCanUseTool(...) 的返回值
→ PermissionDecision { behavior: allow | deny | ask }
→ 若 allow:进入工具执行
→ 若 deny:生成 tool_result 错误
→ 若 ask:挂起直至用户确认
REPL.tsx 在初始化 query 时传入 canUseTool 函数引用。改权限 UI 行为应优先改 useCanUseTool 或其 handlers,而非直接改 query.ts。
CanUseToolFn 类型与 Hook 入口
下列源码来自反编译 v2.1.88。类型定义明确 forceDecision 可选参数,用于测试注入或 replay:
阅读要点:
- 导入链横跨 Tool、permissions、analytics、toolPermission 子目录,说明这是「横切接缝」
- useCallback 包裹的 t0 即实际 canUseTool 实现
- 第一行即 createPermissionContext,后续分支都通过 ctx 操作
源码引用: src/hooks/useCanUseTool.tsx · 第 27–38 行(共 355 行)
27| setYoloClassifierApproval,
28| } from '../utils/classifierApprovals.js'
29| import { logForDebugging } from '../utils/debug.js'
30| import { AbortError } from '../utils/errors.js'
31| import { logError } from '../utils/log.js'
32| import type { PermissionDecision } from '../utils/permissions/PermissionResult.js'
33| import { hasPermissionsToUseTool } from '../utils/permissions/permissions.js'
34| import { jsonStringify } from '../utils/slowOperations.js'
35| import { handleCoordinatorPermission } from './toolPermission/handlers/coordinatorHandler.js'
36| import { handleInteractivePermission } from './toolPermission/handlers/interactiveHandler.js'
37| import { handleSwarmWorkerPermission } from './toolPermission/handlers/swarmWorkerHandler.js'
38| import {
allow 分支:配置放行与 classifier
当 hasPermissionsToUseTool 返回 behavior === "allow" 时,路径最短:
- 检查 abort 信号,避免 turn 已取消仍 resolve
- TRANSCRIPT_CLASSIFIER feature 开启时,auto-mode classifier 放行会写入 setYoloClassifierApproval
- ctx.logDecision 记录 analytics,source 为 "config"
- resolve(ctx.buildAllow(...)) 把可能更新的 input 一并返回
工程含义: 大部分只读工具、已在 allowlist 中的 Bash 命令走此分支,无 UI 打扰。
源码引用: src/hooks/useCanUseTool.tsx · 第 39–54 行(共 355 行)
39| createPermissionContext,
40| createPermissionQueueOps,
41| } from './toolPermission/PermissionContext.js'
42| import { logPermissionDecision } from './toolPermission/permissionLogging.js'
43|
44| export type CanUseToolFn<
45| Input extends Record<string, unknown> = Record<string, unknown>,
46| > = (
47| tool: ToolType,
48| input: Input,
49| toolUseContext: ToolUseContext,
50| assistantMessage: AssistantMessage,
51| toolUseID: string,
52| forceDecision?: PermissionDecision<Input>,
53| ) => Promise<PermissionDecision<Input>>
54|
deny 分支:自动模式拒绝与通知
deny 时除 resolve(result) 外,auto-mode classifier 拒绝会:
- recordAutoModeDenial 持久化拒绝记录
- addNotification 在 REPL 顶部展示 error 色横幅,并提示 /permissions
这体现「拒绝不仅是静默失败,还要给用户可操作的反馈」。
源码引用: src/hooks/useCanUseTool.tsx · 第 64–92 行(共 355 行)
64| input,
65| toolUseContext,
66| assistantMessage,
67| toolUseID,
68| forceDecision,
69| ) => {
70| return new Promise(resolve => {
71| const ctx = createPermissionContext(
72| tool,
73| input,
74| toolUseContext,
75| assistantMessage,
76| toolUseID,
77| setToolPermissionContext,
78| createPermissionQueueOps(setToolUseConfirmQueue),
79| )
80|
81| if (ctx.resolveIfAborted(resolve)) return
82|
83| const decisionPromise =
84| forceDecision !== undefined
85| ? Promise.resolve(forceDecision)
86| : hasPermissionsToUseTool(
87| tool,
88| input,
89| toolUseContext,
90| assistantMessage,
91| toolUseID,
92| )
ask 分支与 Coordinator
behavior === "ask" 进入交互式权限流程。若 toolPermissionContext.awaitAutomatedChecksBeforeDialog 为真,先 await handleCoordinatorPermission(多 Agent 协调场景,可能等待 Bash classifier)。
随后通常进入 handleInteractivePermission(普通 REPL)或 handleSwarmWorkerPermission(Swarm 工人)。阅读建议: 在源码树展开 toolPermission/handlers/,三个 handler 文件对照本段文字。
PermissionContext 负责统一:队列操作、abort 检测、buildAllow/buildDeny、执行 permission request hooks(此处 hooks 指 utils/hooks.ts 的 PreToolUse 类钩子,与目录名 hooks/ 不同)。
源码引用: src/hooks/useCanUseTool.tsx · 第 93–110 行(共 355 行)
93|
94| return decisionPromise
95| .then(async result => {
96| // [ANT-ONLY] Log all tool permission decisions with tool name and args
97| if ("external" === 'ant') {
98| logEvent('tengu_internal_tool_permission_decision', {
99| toolName: sanitizeToolNameForAnalytics(tool.name),
100| behavior:
101| result.behavior as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
102| // Note: input contains code/filepaths, only log for ants
103| input: jsonStringify(
104| input,
105| ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
106| messageID:
107| ctx.messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
108| isMcp: tool.isMcp ?? false,
109| })
110| }
源码引用: src/hooks/toolPermission/PermissionContext.ts · 第 45–75 行(共 389 行)
45| type PermissionApprovalSource =
46| | { type: 'hook'; permanent?: boolean }
47| | { type: 'user'; permanent: boolean }
48| | { type: 'classifier' }
49|
50| type PermissionRejectionSource =
51| | { type: 'hook' }
52| | { type: 'user_abort' }
53| | { type: 'user_reject'; hasFeedback: boolean }
54|
55| // Generic interface for permission queue operations, decoupled from React.
56| // In the REPL, these are backed by React state.
57| type PermissionQueueOps = {
58| push(item: ToolUseConfirm): void
59| remove(toolUseID: string): void
60| update(toolUseID: string, patch: Partial<ToolUseConfirm>): void
61| }
62|
63| type ResolveOnce<T> = {
64| resolve(value: T): void
65| isResolved(): boolean
66| /**
67| * Atomically check-and-mark as resolved. Returns true if this caller
68| * won the race (nobody else has resolved yet), false otherwise.
69| * Use this in async callbacks BEFORE awaiting, to close the window
70| * between the `isResolved()` check and the actual `resolve()` call.
71| */
72| claim(): boolean
73| }
74|
75| function createResolveOnce<T>(resolve: (value: T) => void): ResolveOnce<T> {
PermissionQueueOps:与 React 状态的边界
PermissionContext.ts 开头定义 PermissionQueueOps 接口——纯数据结构,不 import React:
push(item) → 新的 PermissionRequest 入队
remove(id) → 用户决策后移除
update(id) → 更新 confirm 状态(如加载中)
REPL 把 setToolUseConfirmQueue 包一层传给 createPermissionQueueOps。这样 query 引擎只看见 Promise,不看见 React setState。
源码引用: src/hooks/toolPermission/PermissionContext.ts · 第 55–90 行(共 389 行)
55| // Generic interface for permission queue operations, decoupled from React.
56| // In the REPL, these are backed by React state.
57| type PermissionQueueOps = {
58| push(item: ToolUseConfirm): void
59| remove(toolUseID: string): void
60| update(toolUseID: string, patch: Partial<ToolUseConfirm>): void
61| }
62|
63| type ResolveOnce<T> = {
64| resolve(value: T): void
65| isResolved(): boolean
66| /**
67| * Atomically check-and-mark as resolved. Returns true if this caller
68| * won the race (nobody else has resolved yet), false otherwise.
69| * Use this in async callbacks BEFORE awaiting, to close the window
70| * between the `isResolved()` check and the actual `resolve()` call.
71| */
72| claim(): boolean
73| }
74|
75| function 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() {
86| return claimed
87| },
88| claim() {
89| if (claimed) return false
90| claimed = true
ask 分支深度:Coordinator、Swarm 与 Bash 投机分类
behavior === "ask" 时,useCanUseTool 并非直接弹窗,而是按 环境标志 依次尝试短路:
1. Coordinator 路径 若 awaitAutomatedChecksBeforeDialog 为真,先 await handleCoordinatorPermission。多 Agent 协调场景可能等待 Bash classifier 或远程决策;若返回 decision 则直接 resolve,不再进入 REPL 弹窗。
2. Swarm Worker 路径handleSwarmWorkerPermission 处理工人 Agent 权限——主 REPL 不弹窗时由 worker 侧规则消化。
3. Bash 投机 classifier(BASH_CLASSIFIER feature) 对 Bash 工具且存在 pendingClassifierCheck 时,peekSpeculativeClassifierCheck 与 2s timeout race。高置信度 allow 则 consume 并 resolve allow,跳过弹窗——这是「用户还在看描述时后台已跑完 classifier」的优化。
4. 默认 interactive 以上皆未 resolve 时,handleInteractivePermission({ ctx, description, result, bridgeCallbacks, channelCallbacks }, resolve) 入队 ToolUseConfirm。
阅读 ask 分支时应对照 feature gate:TRANSCRIPT_CLASSIFIER、BASH_CLASSIFIER、BRIDGE_MODE、KAIROS_CHANNELS 会显著改变路径。
源码引用: src/hooks/useCanUseTool.tsx · 第 93–168 行(共 355 行)
93|
94| return decisionPromise
95| .then(async result => {
96| // [ANT-ONLY] Log all tool permission decisions with tool name and args
97| if ("external" === 'ant') {
98| logEvent('tengu_internal_tool_permission_decision', {
99| toolName: sanitizeToolNameForAnalytics(tool.name),
100| behavior:
101| result.behavior as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
102| // Note: input contains code/filepaths, only log for ants
103| input: jsonStringify(
104| input,
105| ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
106| messageID:
107| ctx.messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
108| isMcp: tool.isMcp ?? false,
109| })
110| }
111|
112| // Has permissions to use tool, granted in config
113| if (result.behavior === 'allow') {
114| if (ctx.resolveIfAborted(resolve)) return
115| // Track auto mode classifier approvals for UI display
116| if (
117| feature('TRANSCRIPT_CLASSIFIER') &&
118| result.decisionReason?.type === 'classifier' &&
119| result.decisionReason.classifier === 'auto-mode'
120| ) {
121| setYoloClassifierApproval(
122| toolUseID,
123| result.decisionReason.reason,
124| )
125| }
126|
127| ctx.logDecision({ decision: 'accept', source: 'config' })
128|
129| resolve(
130| ctx.buildAllow(result.updatedInput ?? input, {
131| decisionReason: result.decisionReason,
132| }),
133| )
134| return
135| }
136|
137| const appState = toolUseContext.getAppState()
138| const description = await tool.description(input as never, {
139| isNonInteractiveSession:
140| toolUseContext.options.isNonInteractiveSession,
141| toolPermissionContext: appState.toolPermissionContext,
142| tools: toolUseContext.options.tools,
143| })
144|
145| if (ctx.resolveIfAborted(resolve)) return
146|
147| // Does not have permissions to use tool, check the behavior
148| switch (result.behavior) {
149| case 'deny': {
150| logPermissionDecision(
151| {
152| tool,
153| input,
154| toolUseContext,
155| messageId: ctx.messageId,
156| toolUseID,
157| },
158| { decision: 'reject', source: 'config' },
159| )
160| if (
161| feature('TRANSCRIPT_CLASSIFIER') &&
162| result.decisionReason?.type === 'classifier' &&
163| result.decisionReason.classifier === 'auto-mode'
164| ) {
165| recordAutoModeDenial({
166| toolName: tool.name,
167| display: description,
168| reason: result.decisionReason.reason ?? '',
handleInteractivePermission:队列、race 与 resolve-once
interactiveHandler.ts 是 ask 分支的「重逻辑」所在。函数 不返回 Promise,而是通过 push ToolUseConfirm 并在 onAllow/onReject 回调里 resolve 外层 Promise。
核心机制:
createResolveOnce(resolve)— 保证只 resolve 一次,避免用户点击 Allow 与 classifier 同时返回双 resolveuserInteracted标志 — classifier/hook 后台完成时,若用户已操作则忽略自动结果- 异步并行:permission request hooks(utils/hooks.ts PreToolUse)、Bash classifier、bridge/channel 远程确认与 UI 交互 race
- onUserInteraction — 用户首次聚焦弹窗时标记,影响 classifier 是否仍可自动放行
Bridge / Channel 扩展:
- BRIDGE_MODE 传入 replBridgePermissionCallbacks,向远端 UI 转发权限请求
- KAIROS_CHANNELS 传入 channelPermissionCallbacks,支持手机端 yes/no 回复
工程调试: 弹窗不出现但 turn 挂起 → 查 confirm queue 是否 push;弹窗闪退 → 查 resolveOnce 是否被 classifier 抢先;Bash 无弹窗直接 allow → 查 speculative classifier race 日志。
源码引用: src/hooks/toolPermission/handlers/interactiveHandler.ts · 第 43–80 行(共 537 行)
43| /**
44| * Handles the interactive (main-agent) permission flow.
45| *
46| * Pushes a ToolUseConfirm entry to the confirm queue with callbacks:
47| * onAbort, onAllow, onReject, recheckPermission, onUserInteraction.
48| *
49| * Runs permission hooks and bash classifier checks asynchronously in the
50| * background, racing them against user interaction. Uses a resolve-once
51| * guard and `userInteracted` flag to prevent multiple resolutions.
52| *
53| * This function does NOT return a Promise -- it sets up callbacks that
54| * eventually call `resolve()` to resolve the outer promise owned by
55| * the caller.
56| */
57| function handleInteractivePermission(
58| params: InteractivePermissionParams,
59| resolve: (decision: PermissionDecision) => void,
60| ): void {
61| const {
62| ctx,
63| description,
64| result,
65| awaitAutomatedChecksBeforeDialog,
66| bridgeCallbacks,
67| channelCallbacks,
68| } = params
69|
70| const { resolve: resolveOnce, isResolved, claim } = createResolveOnce(resolve)
71| let userInteracted = false
72| let checkmarkTransitionTimer: ReturnType<typeof setTimeout> | undefined
73| // Hoisted so onDismissCheckmark (Esc during checkmark window) can also
74| // remove the abort listener — not just the timer callback.
75| let checkmarkAbortHandler: (() => void) | undefined
76| const bridgeRequestId = bridgeCallbacks ? randomUUID() : undefined
77| // Hoisted so local/hook/classifier wins can remove the pending channel
78| // entry. No "tell remote to dismiss" equivalent — the text sits in your
79| // phone, and a stale "yes abc123" after local-resolve falls through
80| // tryConsumeReply (entry gone) and gets enqueued as normal chat.
forceDecision、abort 与 finally 清理
CanUseToolFn 第六参数 forceDecision 用于测试或 replay:传入时跳过 hasPermissionsToUseTool,直接走 allow/deny/ask 分支逻辑。SDK 集成测试可注入固定决策而不改 permissions 文件。
Abort 处理:
- 每个分支入口调用
ctx.resolveIfAborted(resolve)— turn 已取消则 early resolve - .catch 捕获 AbortError / APIUserAbortError → logForDebugging + ctx.cancelAndAbort
- 其他错误 → logError + cancelAndAbort
.finally 始终 clearClassifierChecking(toolUseID),避免 spinner 状态泄漏到下一 tool_use。
deny 分支的 addNotification(auto-mode-denied key,immediate priority)与 notifs 章 immediate 语义一致——权限拒绝需要立刻打断当前横幅。
源码引用: src/hooks/useCanUseTool.tsx · 第 27–37 行(共 355 行)
27| setYoloClassifierApproval,
28| } from '../utils/classifierApprovals.js'
29| import { logForDebugging } from '../utils/debug.js'
30| import { AbortError } from '../utils/errors.js'
31| import { logError } from '../utils/log.js'
32| import type { PermissionDecision } from '../utils/permissions/PermissionResult.js'
33| import { hasPermissionsToUseTool } from '../utils/permissions/permissions.js'
34| import { jsonStringify } from '../utils/slowOperations.js'
35| import { handleCoordinatorPermission } from './toolPermission/handlers/coordinatorHandler.js'
36| import { handleInteractivePermission } from './toolPermission/handlers/interactiveHandler.js'
37| import { handleSwarmWorkerPermission } from './toolPermission/handlers/swarmWorkerHandler.js'
源码引用: src/hooks/useCanUseTool.tsx · 第 171–182 行(共 355 行)
171| toolUseContext.addNotification?.({
172| key: 'auto-mode-denied',
173| priority: 'immediate',
174| jsx: (
175| <>
176| <Text color="error">
177| {tool.userFacingName(input).toLowerCase()} denied by
178| auto mode
179| </Text>
180| <Text dimColor> · /permissions</Text>
181| </>
182| ),
与 utils/permissions 的协作
hasPermissionsToUseTool 在 utils/permissions/permissions.ts 中实现,读取:
- toolPermissionContext(会话级 allow/deny 规则)
- 工具自身 permissionType
- plan 模式 / acceptEdits 等标志
useCanUseTool 不做 规则求值,只做「把结果翻译成 UI 或 Promise」。调试权限问题时:
- 先在 permissions.ts 看为何返回 ask
- 再在 useCanUseTool 看 ask 是否正确入队
- 最后在 components/permissions/PermissionRequest 看渲染
这条三步链是 hooks 模块最重要的实战技能。
源码目录(本主题相关文件)
点击 toolPermission/ 可展开查看 handlers、permissionLogging 等关联文件。
动手练习
- 在终端触发一次需确认的 FileEdit,观察 PermissionRequest 出现时机与 toolUseID
- 对照源码块 B/C,在纸上画出 allow/deny/ask 三分支
- 修改 settings 中 permissions 为 deny 某工具,确认走 deny 分支且出现通知(若适用)
- 在源码树点击 PermissionContext.ts,跳转到对应源码块 C
本章小结与延伸
useCanUseTool = 权限决策的 UI 适配器。下一章建议读「权限与安全」专题或 mod-components 中的 PermissionRequest 组件。 继续学习: