import { feature } from 'bun:bundle' import { APIUserAbortError } from '@anthropic-ai/sdk' import * as React from 'react' import { useCallback } from 'react' import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, } from 'src/services/analytics/index.js' import { sanitizeToolNameForAnalytics } from 'src/services/analytics/metadata.js' import type { ToolUseConfirm } from '../components/permissions/PermissionRequest.js' import { Text } from '@anthropic/ink' import type { ToolPermissionContext, Tool as ToolType, ToolUseContext, } from '../Tool.js' import { consumeSpeculativeClassifierCheck, peekSpeculativeClassifierCheck, } from '@claude-code-best/builtin-tools/tools/BashTool/bashPermissions.js' import { BASH_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/BashTool/toolName.js' import type { AssistantMessage } from '../types/message.js' import { recordAutoModeDenial } from '../utils/autoModeDenials.js' import { clearClassifierChecking, setClassifierApproval, setYoloClassifierApproval, } from '../utils/classifierApprovals.js' import { logForDebugging } from '../utils/debug.js' import { AbortError } from '../utils/errors.js' import { logError } from '../utils/log.js' import type { PermissionDecision } from '../utils/permissions/PermissionResult.js' import { hasPermissionsToUseTool } from '../utils/permissions/permissions.js' import { jsonStringify } from '../utils/slowOperations.js' import { handleCoordinatorPermission } from './toolPermission/handlers/coordinatorHandler.js' import { handleInteractivePermission } from './toolPermission/handlers/interactiveHandler.js' import { handleSwarmWorkerPermission } from './toolPermission/handlers/swarmWorkerHandler.js' import { createPermissionContext, createPermissionQueueOps, } from './toolPermission/PermissionContext.js' import { logPermissionDecision } from './toolPermission/permissionLogging.js' export type CanUseToolFn< Input extends Record = Record, > = ( tool: ToolType, input: Input, toolUseContext: ToolUseContext, assistantMessage: AssistantMessage, toolUseID: string, forceDecision?: PermissionDecision, ) => Promise> function useCanUseTool( setToolUseConfirmQueue: React.Dispatch< React.SetStateAction >, setToolPermissionContext: (context: ToolPermissionContext) => void, ): CanUseToolFn { return useCallback( async ( tool, input, toolUseContext, assistantMessage, toolUseID, forceDecision, ) => { return new Promise(resolve => { const ctx = createPermissionContext( tool, input, toolUseContext, assistantMessage, toolUseID, setToolPermissionContext, createPermissionQueueOps(setToolUseConfirmQueue), ) if (ctx.resolveIfAborted(resolve)) return const decisionPromise = forceDecision !== undefined ? Promise.resolve(forceDecision) : hasPermissionsToUseTool( tool, input, toolUseContext, assistantMessage, toolUseID, ) return decisionPromise .then(async result => { // [ANT-ONLY] Log all tool permission decisions with tool name and args if (process.env.USER_TYPE === 'ant') { logEvent('tengu_internal_tool_permission_decision', { toolName: sanitizeToolNameForAnalytics(tool.name), behavior: result.behavior as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, // Note: input contains code/filepaths, only log for ants input: jsonStringify( input, ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, messageID: ctx.messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, isMcp: tool.isMcp ?? false, }) } // Has permissions to use tool, granted in config if (result.behavior === 'allow') { if (ctx.resolveIfAborted(resolve)) return // Track auto mode classifier approvals for UI display if ( feature('TRANSCRIPT_CLASSIFIER') && result.decisionReason?.type === 'classifier' && result.decisionReason.classifier === 'auto-mode' ) { setYoloClassifierApproval( toolUseID, result.decisionReason.reason, ) } ctx.logDecision({ decision: 'accept', source: 'config' }) resolve( ctx.buildAllow(result.updatedInput ?? input, { decisionReason: result.decisionReason, }), ) return } const appState = toolUseContext.getAppState() const description = await tool.description(input as never, { isNonInteractiveSession: toolUseContext.options.isNonInteractiveSession, toolPermissionContext: appState.toolPermissionContext, tools: toolUseContext.options.tools, }) if (ctx.resolveIfAborted(resolve)) return // Does not have permissions to use tool, check the behavior switch (result.behavior) { case 'deny': { logPermissionDecision( { tool, input, toolUseContext, messageId: ctx.messageId!, toolUseID, }, { decision: 'reject', source: 'config' }, ) if ( feature('TRANSCRIPT_CLASSIFIER') && result.decisionReason?.type === 'classifier' && result.decisionReason.classifier === 'auto-mode' ) { recordAutoModeDenial({ toolName: tool.name, display: description, reason: result.decisionReason.reason ?? '', timestamp: Date.now(), }) toolUseContext.addNotification?.({ key: 'auto-mode-denied', priority: 'immediate', jsx: ( <> {tool.userFacingName(input).toLowerCase()} denied by auto mode · /permissions ), }) } resolve(result) return } case 'ask': { // For coordinator workers, await automated checks before showing dialog. // Background workers should only interrupt the user when automated checks can't decide. if ( appState.toolPermissionContext .awaitAutomatedChecksBeforeDialog ) { const coordinatorDecision = await handleCoordinatorPermission( { ctx, ...(feature('BASH_CLASSIFIER') ? { pendingClassifierCheck: result.pendingClassifierCheck, } : {}), updatedInput: result.updatedInput, suggestions: result.suggestions, permissionMode: appState.toolPermissionContext.mode, }, ) if (coordinatorDecision) { resolve(coordinatorDecision) return } // null means neither automated check resolved -- fall through to dialog below. // Hooks already ran, classifier already consumed. } // After awaiting automated checks, verify the request wasn't aborted // while we were waiting. Without this check, a stale dialog could appear. if (ctx.resolveIfAborted(resolve)) return // For swarm workers, try classifier auto-approval then // forward permission requests to the leader via mailbox. const swarmDecision = await handleSwarmWorkerPermission({ ctx, description, ...(feature('BASH_CLASSIFIER') ? { pendingClassifierCheck: result.pendingClassifierCheck, } : {}), updatedInput: result.updatedInput, suggestions: result.suggestions, }) if (swarmDecision) { resolve(swarmDecision) return } // Grace period: wait up to 2s for speculative classifier // to resolve before showing the dialog (main agent only) if ( feature('BASH_CLASSIFIER') && result.pendingClassifierCheck && tool.name === BASH_TOOL_NAME && !appState.toolPermissionContext .awaitAutomatedChecksBeforeDialog ) { const speculativePromise = peekSpeculativeClassifierCheck( (input as { command: string }).command, ) if (speculativePromise) { const raceResult = await Promise.race([ speculativePromise.then(r => ({ type: 'result' as const, result: r, })), new Promise<{ type: 'timeout' }>(res => // eslint-disable-next-line no-restricted-syntax -- resolves with a value, not void setTimeout(res, 2000, { type: 'timeout' as const }), ), ]) if (ctx.resolveIfAborted(resolve)) return if ( raceResult.type === 'result' && raceResult.result.matches && raceResult.result.confidence === 'high' && feature('BASH_CLASSIFIER') ) { // Classifier approved within grace period — skip dialog void consumeSpeculativeClassifierCheck( (input as { command: string }).command, ) const matchedRule = raceResult.result.matchedDescription ?? undefined if (matchedRule) { setClassifierApproval(toolUseID, matchedRule) } ctx.logDecision({ decision: 'accept', source: { type: 'classifier' }, }) resolve( ctx.buildAllow( result.updatedInput ?? (input as Record), { decisionReason: { type: 'classifier' as const, classifier: 'bash_allow' as const, reason: `Allowed by prompt rule: "${raceResult.result.matchedDescription}"`, }, }, ), ) return } // Timeout or no match — fall through to show dialog } } // Show dialog and start hooks/classifier in background handleInteractivePermission( { ctx, description, result, awaitAutomatedChecksBeforeDialog: appState.toolPermissionContext .awaitAutomatedChecksBeforeDialog, bridgeCallbacks: feature('BRIDGE_MODE') ? appState.replBridgePermissionCallbacks : undefined, channelCallbacks: feature('KAIROS') || feature('KAIROS_CHANNELS') ? appState.channelPermissionCallbacks : undefined, }, resolve, ) return } } }) .catch(error => { if ( error instanceof AbortError || error instanceof APIUserAbortError ) { logForDebugging( `Permission check threw ${error.constructor.name} for tool=${tool.name}: ${error.message}`, ) ctx.logCancelled() resolve(ctx.cancelAndAbort(undefined, true)) } else { logError(error) resolve(ctx.cancelAndAbort(undefined, true)) } }) .finally(() => { clearClassifierChecking(toolUseID) }) }) }, [setToolUseConfirmQueue, setToolPermissionContext], ) } export default useCanUseTool