import { feature } from 'bun:bundle' import { APIUserAbortError } from '@anthropic-ai/sdk' import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' import { getToolNameForPermissionCheck, mcpInfoFromString, } from '../../services/mcp/mcpStringUtils.js' import type { Tool, ToolPermissionContext, ToolUseContext } from '../../Tool.js' import { AGENT_TOOL_NAME } from '../../tools/AgentTool/constants.js' import { shouldUseSandbox } from '../../tools/BashTool/shouldUseSandbox.js' import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js' import { POWERSHELL_TOOL_NAME } from '../../tools/PowerShellTool/toolName.js' import { REPL_TOOL_NAME } from '../../tools/REPLTool/constants.js' import type { AssistantMessage } from '../../types/message.js' import { extractOutputRedirections } from '../bash/commands.js' import { logForDebugging } from '../debug.js' import { AbortError, toError } from '../errors.js' import { logError } from '../log.js' import { SandboxManager } from '../sandbox/sandbox-adapter.js' import { getSettingSourceDisplayNameLowercase, SETTING_SOURCES, } from '../settings/constants.js' import { plural } from '../stringUtils.js' import { permissionModeTitle } from './PermissionMode.js' import type { PermissionAskDecision, PermissionDecision, PermissionDecisionReason, PermissionDenyDecision, PermissionResult, } from './PermissionResult.js' import type { PermissionBehavior, PermissionRule, PermissionRuleSource, PermissionRuleValue, } from './PermissionRule.js' import { applyPermissionUpdate, applyPermissionUpdates, persistPermissionUpdates, } from './PermissionUpdate.js' import type { PermissionUpdate, PermissionUpdateDestination, } from './PermissionUpdateSchema.js' import { permissionRuleValueFromString, permissionRuleValueToString, } from './permissionRuleParser.js' import { deletePermissionRuleFromSettings, type PermissionRuleFromEditableSettings, shouldAllowManagedPermissionRulesOnly, } from './permissionsLoader.js' /* eslint-disable @typescript-eslint/no-require-imports */ const classifierDecisionModule = feature('TRANSCRIPT_CLASSIFIER') ? (require('./classifierDecision.js') as typeof import('./classifierDecision.js')) : null const autoModeStateModule = feature('TRANSCRIPT_CLASSIFIER') ? (require('./autoModeState.js') as typeof import('./autoModeState.js')) : null import { addToTurnClassifierDuration, getTotalCacheCreationInputTokens, getTotalCacheReadInputTokens, getTotalInputTokens, getTotalOutputTokens, } from '../../bootstrap/state.js' import { getFeatureValue_CACHED_WITH_REFRESH } from '../../services/analytics/growthbook.js' import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, } from '../../services/analytics/index.js' import { sanitizeToolNameForAnalytics } from '../../services/analytics/metadata.js' import { clearClassifierChecking, setClassifierChecking, } from '../classifierApprovals.js' import { isInProtectedNamespace } from '../envUtils.js' import { executePermissionRequestHooks } from '../hooks.js' import { AUTO_REJECT_MESSAGE, buildClassifierUnavailableMessage, buildYoloRejectionMessage, DONT_ASK_REJECT_MESSAGE, } from '../messages.js' import { calculateCostFromTokens } from '../modelCost.js' /* eslint-enable @typescript-eslint/no-require-imports */ import { jsonStringify } from '../slowOperations.js' import { createDenialTrackingState, DENIAL_LIMITS, type DenialTrackingState, recordDenial, recordSuccess, shouldFallbackToPrompting, } from './denialTracking.js' import { classifyYoloAction, formatActionForClassifier, } from './yoloClassifier.js' const CLASSIFIER_FAIL_CLOSED_REFRESH_MS = 30 * 60 * 1000 // 30 minutes const PERMISSION_RULE_SOURCES = [ ...SETTING_SOURCES, 'cliArg', 'command', 'session', ] as const satisfies readonly PermissionRuleSource[] export function permissionRuleSourceDisplayString( source: PermissionRuleSource, ): string { return getSettingSourceDisplayNameLowercase(source) } export function getAllowRules( context: ToolPermissionContext, ): PermissionRule[] { return PERMISSION_RULE_SOURCES.flatMap(source => (context.alwaysAllowRules[source] || []).map(ruleString => ({ source, ruleBehavior: 'allow', ruleValue: permissionRuleValueFromString(ruleString), })), ) } /** * Creates a permission request message that explain the permission request */ export function createPermissionRequestMessage( toolName: string, decisionReason?: PermissionDecisionReason, ): string { // Handle different decision reason types if (decisionReason) { if ( (feature('BASH_CLASSIFIER') || feature('TRANSCRIPT_CLASSIFIER')) && decisionReason.type === 'classifier' ) { return `Classifier '${decisionReason.classifier}' requires approval for this ${toolName} command: ${decisionReason.reason}` } switch (decisionReason.type) { case 'hook': { const hookMessage = decisionReason.reason ? `Hook '${decisionReason.hookName}' blocked this action: ${decisionReason.reason}` : `Hook '${decisionReason.hookName}' requires approval for this ${toolName} command` return hookMessage } case 'rule': { const ruleString = permissionRuleValueToString( decisionReason.rule.ruleValue, ) const sourceString = permissionRuleSourceDisplayString( decisionReason.rule.source, ) return `Permission rule '${ruleString}' from ${sourceString} requires approval for this ${toolName} command` } case 'subcommandResults': { const needsApproval: string[] = [] for (const [cmd, result] of decisionReason.reasons) { if (result.behavior === 'ask' || result.behavior === 'passthrough') { // Strip output redirections for display to avoid showing filenames as commands // Only do this for Bash tool to avoid affecting other tools if (toolName === 'Bash') { const { commandWithoutRedirections, redirections } = extractOutputRedirections(cmd) // Only use stripped version if there were actual redirections const displayCmd = redirections.length > 0 ? commandWithoutRedirections : cmd needsApproval.push(displayCmd) } else { needsApproval.push(cmd) } } } if (needsApproval.length > 0) { const n = needsApproval.length return `This ${toolName} command contains multiple operations. The following ${plural(n, 'part')} ${plural(n, 'requires', 'require')} approval: ${needsApproval.join(', ')}` } return `This ${toolName} command contains multiple operations that require approval` } case 'permissionPromptTool': return `Tool '${decisionReason.permissionPromptToolName}' requires approval for this ${toolName} command` case 'sandboxOverride': return 'Run outside of the sandbox' case 'workingDir': return decisionReason.reason case 'safetyCheck': case 'other': return decisionReason.reason case 'mode': { const modeTitle = permissionModeTitle(decisionReason.mode) return `Current permission mode (${modeTitle}) requires approval for this ${toolName} command` } case 'asyncAgent': return decisionReason.reason } } // Default message without listing allowed commands const message = `Claude requested permissions to use ${toolName}, but you haven't granted it yet.` return message } export function getDenyRules(context: ToolPermissionContext): PermissionRule[] { return PERMISSION_RULE_SOURCES.flatMap(source => (context.alwaysDenyRules[source] || []).map(ruleString => ({ source, ruleBehavior: 'deny', ruleValue: permissionRuleValueFromString(ruleString), })), ) } export function getAskRules(context: ToolPermissionContext): PermissionRule[] { return PERMISSION_RULE_SOURCES.flatMap(source => (context.alwaysAskRules[source] || []).map(ruleString => ({ source, ruleBehavior: 'ask', ruleValue: permissionRuleValueFromString(ruleString), })), ) } /** * Check if the entire tool matches a rule * For example, this matches "Bash" but not "Bash(prefix:*)" for BashTool * This also matches MCP tools with a server name, e.g. the rule "mcp__server1" */ function toolMatchesRule( tool: Pick, rule: PermissionRule, ): boolean { // Rule must not have content to match the entire tool if (rule.ruleValue.ruleContent !== undefined) { return false } // MCP tools are matched by their fully qualified mcp__server__tool name. In // skip-prefix mode (CLAUDE_AGENT_SDK_MCP_NO_PREFIX), MCP tools have unprefixed // display names (e.g., "Write") that collide with builtin names; rules targeting // builtins should not match their MCP replacements. const nameForRuleMatch = getToolNameForPermissionCheck(tool) // Direct tool name match if (rule.ruleValue.toolName === nameForRuleMatch) { return true } // MCP server-level permission: rule "mcp__server1" matches tool "mcp__server1__tool1" // Also supports wildcard: rule "mcp__server1__*" matches all tools from server1 const ruleInfo = mcpInfoFromString(rule.ruleValue.toolName) const toolInfo = mcpInfoFromString(nameForRuleMatch) return ( ruleInfo !== null && toolInfo !== null && (ruleInfo.toolName === undefined || ruleInfo.toolName === '*') && ruleInfo.serverName === toolInfo.serverName ) } /** * Check if the entire tool is listed in the always allow rules * For example, this finds "Bash" but not "Bash(prefix:*)" for BashTool */ export function toolAlwaysAllowedRule( context: ToolPermissionContext, tool: Pick, ): PermissionRule | null { return ( getAllowRules(context).find(rule => toolMatchesRule(tool, rule)) || null ) } /** * Check if the tool is listed in the always deny rules */ export function getDenyRuleForTool( context: ToolPermissionContext, tool: Pick, ): PermissionRule | null { return getDenyRules(context).find(rule => toolMatchesRule(tool, rule)) || null } /** * Check if the tool is listed in the always ask rules */ export function getAskRuleForTool( context: ToolPermissionContext, tool: Pick, ): PermissionRule | null { return getAskRules(context).find(rule => toolMatchesRule(tool, rule)) || null } /** * Check if a specific agent is denied via Agent(agentType) syntax. * For example, Agent(Explore) would deny the Explore agent. */ export function getDenyRuleForAgent( context: ToolPermissionContext, agentToolName: string, agentType: string, ): PermissionRule | null { return ( getDenyRules(context).find( rule => rule.ruleValue.toolName === agentToolName && rule.ruleValue.ruleContent === agentType, ) || null ) } /** * Filter agents to exclude those that are denied via Agent(agentType) syntax. */ export function filterDeniedAgents( agents: T[], context: ToolPermissionContext, agentToolName: string, ): T[] { // Parse deny rules once and collect Agent(x) contents into a Set. // Previously this called getDenyRuleForAgent per agent, which re-parsed // every deny rule for every agent (O(agents×rules) parse calls). const deniedAgentTypes = new Set() for (const rule of getDenyRules(context)) { if ( rule.ruleValue.toolName === agentToolName && rule.ruleValue.ruleContent !== undefined ) { deniedAgentTypes.add(rule.ruleValue.ruleContent) } } return agents.filter(agent => !deniedAgentTypes.has(agent.agentType)) } /** * Map of rule contents to the associated rule for a given tool. * e.g. the string key is "prefix:*" from "Bash(prefix:*)" for BashTool */ export function getRuleByContentsForTool( context: ToolPermissionContext, tool: Tool, behavior: PermissionBehavior, ): Map { return getRuleByContentsForToolName( context, getToolNameForPermissionCheck(tool), behavior, ) } // Used to break circular dependency where a Tool calls this function export function getRuleByContentsForToolName( context: ToolPermissionContext, toolName: string, behavior: PermissionBehavior, ): Map { const ruleByContents = new Map() let rules: PermissionRule[] = [] switch (behavior) { case 'allow': rules = getAllowRules(context) break case 'deny': rules = getDenyRules(context) break case 'ask': rules = getAskRules(context) break } for (const rule of rules) { if ( rule.ruleValue.toolName === toolName && rule.ruleValue.ruleContent !== undefined && rule.ruleBehavior === behavior ) { ruleByContents.set(rule.ruleValue.ruleContent, rule) } } return ruleByContents } /** * Runs PermissionRequest hooks for headless/async agents that cannot show * permission prompts. This gives hooks an opportunity to allow or deny * tool use before the fallback auto-deny kicks in. * * Returns a PermissionDecision if a hook made a decision, or null if no * hook provided a decision (caller should proceed to auto-deny). */ async function runPermissionRequestHooksForHeadlessAgent( tool: Tool, input: { [key: string]: unknown }, toolUseID: string, context: ToolUseContext, permissionMode: string | undefined, suggestions: PermissionUpdate[] | undefined, ): Promise { try { for await (const hookResult of executePermissionRequestHooks( tool.name, toolUseID, input, context, permissionMode, suggestions as any, context.abortController.signal, )) { if (!hookResult.permissionRequestResult) { continue } const decision = hookResult.permissionRequestResult if (decision.behavior === 'allow') { const finalInput = decision.updatedInput ?? input // Persist permission updates if provided if (decision.updatedPermissions?.length) { persistPermissionUpdates(decision.updatedPermissions as any) context.setAppState(prev => ({ ...prev, toolPermissionContext: applyPermissionUpdates( prev.toolPermissionContext, decision.updatedPermissions as any, ), })) } return { behavior: 'allow', updatedInput: finalInput, decisionReason: { type: 'hook', hookName: 'PermissionRequest', }, } } if (decision.behavior === 'deny') { if (decision.interrupt) { logForDebugging( `Hook interrupt: tool=${tool.name} hookMessage=${decision.message}`, ) context.abortController.abort() } return { behavior: 'deny', message: decision.message || 'Permission denied by hook', decisionReason: { type: 'hook', hookName: 'PermissionRequest', reason: decision.message, }, } } } } catch (error) { // If hooks fail, fall through to auto-deny rather than crashing logError( new Error('PermissionRequest hook failed for headless agent', { cause: toError(error), }), ) } return null } export const hasPermissionsToUseTool: CanUseToolFn = async ( tool, input, context, assistantMessage, toolUseID, ): Promise => { const result = await hasPermissionsToUseToolInner(tool, input, context) // Reset consecutive denials on any allowed tool use in auto mode. // This ensures that a successful tool use (even one auto-allowed by rules) // breaks the consecutive denial streak. if (result.behavior === 'allow') { const appState = context.getAppState() if (feature('TRANSCRIPT_CLASSIFIER')) { const currentDenialState = context.localDenialTracking ?? appState.denialTracking if ( appState.toolPermissionContext.mode === 'auto' && currentDenialState && currentDenialState.consecutiveDenials > 0 ) { const newDenialState = recordSuccess(currentDenialState) persistDenialState(context, newDenialState) } } return result } // Apply dontAsk mode transformation: convert 'ask' to 'deny' // This is done at the end so it can't be bypassed by early returns if (result.behavior === 'ask') { const appState = context.getAppState() if (appState.toolPermissionContext.mode === 'dontAsk') { return { behavior: 'deny', decisionReason: { type: 'mode', mode: 'dontAsk', }, message: DONT_ASK_REJECT_MESSAGE(tool.name), } } // Apply auto mode: use AI classifier instead of prompting user // Check this BEFORE shouldAvoidPermissionPrompts so classifiers work in headless mode if ( feature('TRANSCRIPT_CLASSIFIER') && (appState.toolPermissionContext.mode === 'auto' || (appState.toolPermissionContext.mode === 'plan' && (autoModeStateModule?.isAutoModeActive() ?? false))) ) { // Non-classifier-approvable safetyCheck decisions stay immune to ALL // auto-approve paths: the acceptEdits fast-path, the safe-tool allowlist, // and the classifier. Step 1g only guards bypassPermissions; this guards // auto. classifierApprovable safetyChecks (sensitive-file paths) fall // through to the classifier — the fast-paths below naturally don't fire // because the tool's own checkPermissions still returns 'ask'. if ( result.decisionReason?.type === 'safetyCheck' && !result.decisionReason.classifierApprovable ) { if (appState.toolPermissionContext.shouldAvoidPermissionPrompts) { return { behavior: 'deny', message: result.message, decisionReason: { type: 'asyncAgent', reason: 'Safety check requires interactive approval and permission prompts are not available in this context', }, } } return result } if (tool.requiresUserInteraction?.() && result.behavior === 'ask') { return result } // Use local denial tracking for async subagents (whose setAppState // is a no-op), otherwise read from appState as before. const denialState = context.localDenialTracking ?? appState.denialTracking ?? createDenialTrackingState() // PowerShell requires explicit user permission in auto mode unless // POWERSHELL_AUTO_MODE (ant-only build flag) is on. When disabled, this // guard keeps PS out of the classifier and skips the acceptEdits // fast-path below. When enabled, PS flows through to the classifier like // Bash — the classifier prompt gets POWERSHELL_DENY_GUIDANCE appended so // it recognizes `iex (iwr ...)` as download-and-execute, etc. // Note: this runs inside the behavior === 'ask' branch, so allow rules // that fire earlier (step 2b toolAlwaysAllowedRule, PS prefix allow) // return before reaching here. Allow-rule protection is handled by // permissionSetup.ts: isOverlyBroadPowerShellAllowRule strips PowerShell(*) // and isDangerousPowerShellPermission strips iex/pwsh/Start-Process // prefix rules for ant users and auto mode entry. if ( tool.name === POWERSHELL_TOOL_NAME && !feature('POWERSHELL_AUTO_MODE') ) { if (appState.toolPermissionContext.shouldAvoidPermissionPrompts) { return { behavior: 'deny', message: 'PowerShell tool requires interactive approval', decisionReason: { type: 'asyncAgent', reason: 'PowerShell tool requires interactive approval and permission prompts are not available in this context', }, } } logForDebugging( `Skipping auto mode classifier for ${tool.name}: tool requires explicit user permission`, ) return result } // Before running the auto mode classifier, check if acceptEdits mode would // allow this action. This avoids expensive classifier API calls for safe // operations like file edits in the working directory. // Skip for Agent and REPL — their checkPermissions returns 'allow' for // acceptEdits mode, which would silently bypass the classifier. REPL // code can contain VM escapes between inner tool calls; the classifier // must see the glue JavaScript, not just the inner tool calls. if ( result.behavior === 'ask' && tool.name !== AGENT_TOOL_NAME && tool.name !== REPL_TOOL_NAME ) { try { const parsedInput = tool.inputSchema.parse(input) const acceptEditsResult = await tool.checkPermissions(parsedInput, { ...context, getAppState: () => { const state = context.getAppState() return { ...state, toolPermissionContext: { ...state.toolPermissionContext, mode: 'acceptEdits' as const, }, } }, }) if (acceptEditsResult.behavior === 'allow') { const newDenialState = recordSuccess(denialState) persistDenialState(context, newDenialState) logForDebugging( `Skipping auto mode classifier for ${tool.name}: would be allowed in acceptEdits mode`, ) logEvent('tengu_auto_mode_decision', { decision: 'allowed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, toolName: sanitizeToolNameForAnalytics(tool.name), inProtectedNamespace: isInProtectedNamespace(), // msg_id of the agent completion that produced this tool_use — // the action at the bottom of the classifier transcript. Joins // the decision back to the main agent's API response. agentMsgId: assistantMessage.message .id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, confidence: 'high' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, fastPath: 'acceptEdits' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) return { behavior: 'allow', updatedInput: acceptEditsResult.updatedInput ?? input, decisionReason: { type: 'mode', mode: 'auto', }, } } } catch (e) { if (e instanceof AbortError || e instanceof APIUserAbortError) { throw e } // If the acceptEdits check fails, fall through to the classifier } } // Allowlisted tools are safe and don't need YOLO classification. // This uses the safe-tool allowlist to skip unnecessary classifier API calls. if (classifierDecisionModule!.isAutoModeAllowlistedTool(tool.name)) { const newDenialState = recordSuccess(denialState) persistDenialState(context, newDenialState) logForDebugging( `Skipping auto mode classifier for ${tool.name}: tool is on the safe allowlist`, ) logEvent('tengu_auto_mode_decision', { decision: 'allowed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, toolName: sanitizeToolNameForAnalytics(tool.name), inProtectedNamespace: isInProtectedNamespace(), agentMsgId: assistantMessage.message .id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, confidence: 'high' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, fastPath: 'allowlist' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) return { behavior: 'allow', updatedInput: input, decisionReason: { type: 'mode', mode: 'auto', }, } } // Run the auto mode classifier const action = formatActionForClassifier(tool.name, input) setClassifierChecking(toolUseID) let classifierResult try { classifierResult = await classifyYoloAction( context.messages, action, context.options.tools, appState.toolPermissionContext, context.abortController.signal, ) } finally { clearClassifierChecking(toolUseID) } // Notify ants when classifier error dumped prompts (will be in /share) if ( process.env.USER_TYPE === 'ant' && classifierResult.errorDumpPath && context.addNotification ) { context.addNotification({ key: 'auto-mode-error-dump', text: `Auto mode classifier error — prompts dumped to ${classifierResult.errorDumpPath} (included in /share)`, priority: 'immediate', color: 'error', }) } // Log classifier decision for metrics (including overhead telemetry) const yoloDecision = classifierResult.unavailable ? 'unavailable' : classifierResult.shouldBlock ? 'blocked' : 'allowed' // Compute classifier cost in USD for overhead analysis const classifierCostUSD = classifierResult.usage && classifierResult.model ? calculateCostFromTokens( classifierResult.model, classifierResult.usage, ) : undefined logEvent('tengu_auto_mode_decision', { decision: yoloDecision as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, toolName: sanitizeToolNameForAnalytics(tool.name), inProtectedNamespace: isInProtectedNamespace(), // msg_id of the agent completion that produced this tool_use — // the action at the bottom of the classifier transcript. agentMsgId: assistantMessage.message .id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, classifierModel: classifierResult.model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, consecutiveDenials: classifierResult.shouldBlock ? denialState.consecutiveDenials + 1 : 0, totalDenials: classifierResult.shouldBlock ? denialState.totalDenials + 1 : denialState.totalDenials, // Overhead telemetry: token usage and latency for the classifier API call classifierInputTokens: classifierResult.usage?.inputTokens, classifierOutputTokens: classifierResult.usage?.outputTokens, classifierCacheReadInputTokens: classifierResult.usage?.cacheReadInputTokens, classifierCacheCreationInputTokens: classifierResult.usage?.cacheCreationInputTokens, classifierDurationMs: classifierResult.durationMs, // Character lengths of the prompt components sent to the classifier classifierSystemPromptLength: classifierResult.promptLengths?.systemPrompt, classifierToolCallsLength: classifierResult.promptLengths?.toolCalls, classifierUserPromptsLength: classifierResult.promptLengths?.userPrompts, // Session totals at time of classifier call (for computing overhead %). // These are main-transcript-only — sideQuery (used by the classifier) // does NOT call addToTotalSessionCost, so classifier tokens are excluded. sessionInputTokens: getTotalInputTokens(), sessionOutputTokens: getTotalOutputTokens(), sessionCacheReadInputTokens: getTotalCacheReadInputTokens(), sessionCacheCreationInputTokens: getTotalCacheCreationInputTokens(), classifierCostUSD, classifierStage: classifierResult.stage as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, classifierStage1InputTokens: classifierResult.stage1Usage?.inputTokens, classifierStage1OutputTokens: classifierResult.stage1Usage?.outputTokens, classifierStage1CacheReadInputTokens: classifierResult.stage1Usage?.cacheReadInputTokens, classifierStage1CacheCreationInputTokens: classifierResult.stage1Usage?.cacheCreationInputTokens, classifierStage1DurationMs: classifierResult.stage1DurationMs, classifierStage1RequestId: classifierResult.stage1RequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, classifierStage1MsgId: classifierResult.stage1MsgId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, classifierStage1CostUSD: classifierResult.stage1Usage && classifierResult.model ? calculateCostFromTokens( classifierResult.model, classifierResult.stage1Usage, ) : undefined, classifierStage2InputTokens: classifierResult.stage2Usage?.inputTokens, classifierStage2OutputTokens: classifierResult.stage2Usage?.outputTokens, classifierStage2CacheReadInputTokens: classifierResult.stage2Usage?.cacheReadInputTokens, classifierStage2CacheCreationInputTokens: classifierResult.stage2Usage?.cacheCreationInputTokens, classifierStage2DurationMs: classifierResult.stage2DurationMs, classifierStage2RequestId: classifierResult.stage2RequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, classifierStage2MsgId: classifierResult.stage2MsgId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, classifierStage2CostUSD: classifierResult.stage2Usage && classifierResult.model ? calculateCostFromTokens( classifierResult.model, classifierResult.stage2Usage, ) : undefined, }) if (classifierResult.durationMs !== undefined) { addToTurnClassifierDuration(classifierResult.durationMs) } if (classifierResult.shouldBlock) { // Transcript exceeded the classifier's context window — deterministic // error, won't recover on retry. Skip iron_gate and fall back to // normal prompting so the user can approve/deny manually. if (classifierResult.transcriptTooLong) { if (appState.toolPermissionContext.shouldAvoidPermissionPrompts) { // Permanent condition (transcript only grows) — deny-retry-deny // wastes tokens without ever hitting the denial-limit abort. throw new AbortError( 'Agent aborted: auto mode classifier transcript exceeded context window in headless mode', ) } logForDebugging( 'Auto mode classifier transcript too long, falling back to normal permission handling', { level: 'warn' }, ) return { ...result, decisionReason: { type: 'other', reason: 'Auto mode classifier transcript exceeded context window — falling back to manual approval', }, } } // When classifier is unavailable (API error), behavior depends on // the tengu_iron_gate_closed gate. if (classifierResult.unavailable) { if ( getFeatureValue_CACHED_WITH_REFRESH( 'tengu_iron_gate_closed', true, CLASSIFIER_FAIL_CLOSED_REFRESH_MS, ) ) { logForDebugging( 'Auto mode classifier unavailable, denying with retry guidance (fail closed)', { level: 'warn' }, ) return { behavior: 'deny', decisionReason: { type: 'classifier', classifier: 'auto-mode', reason: 'Classifier unavailable', }, message: buildClassifierUnavailableMessage( tool.name, classifierResult.model, ), } } // Fail open: fall back to normal permission handling logForDebugging( 'Auto mode classifier unavailable, falling back to normal permission handling (fail open)', { level: 'warn' }, ) return result } // Update denial tracking and check limits const newDenialState = recordDenial(denialState) persistDenialState(context, newDenialState) logForDebugging( `Auto mode classifier blocked action: ${classifierResult.reason}`, { level: 'warn' }, ) // If denial limit hit, fall back to prompting so the user // can review. We check after the classifier so we can include // its reason in the prompt. const denialLimitResult = handleDenialLimitExceeded( newDenialState, appState, classifierResult.reason, assistantMessage, tool, result, context, ) if (denialLimitResult) { return denialLimitResult } return { behavior: 'deny', decisionReason: { type: 'classifier', classifier: 'auto-mode', reason: classifierResult.reason, }, message: buildYoloRejectionMessage(classifierResult.reason), } } // Reset consecutive denials on success const newDenialState = recordSuccess(denialState) persistDenialState(context, newDenialState) return { behavior: 'allow', updatedInput: input, decisionReason: { type: 'classifier', classifier: 'auto-mode', reason: classifierResult.reason, }, } } // When permission prompts should be avoided (e.g., background/headless agents), // run PermissionRequest hooks first to give them a chance to allow/deny. // Only auto-deny if no hook provides a decision. if (appState.toolPermissionContext.shouldAvoidPermissionPrompts) { const hookDecision = await runPermissionRequestHooksForHeadlessAgent( tool, input, toolUseID, context, appState.toolPermissionContext.mode, result.suggestions, ) if (hookDecision) { return hookDecision } return { behavior: 'deny', decisionReason: { type: 'asyncAgent', reason: 'Permission prompts are not available in this context', }, message: AUTO_REJECT_MESSAGE(tool.name), } } } return result } /** * Persist denial tracking state. For async subagents with localDenialTracking, * mutate the local state in place (since setAppState is a no-op). Otherwise, * write to appState as usual. */ function persistDenialState( context: ToolUseContext, newState: DenialTrackingState, ): void { if (context.localDenialTracking) { Object.assign(context.localDenialTracking, newState) } else { context.setAppState(prev => { // recordSuccess returns the same reference when state is // unchanged. Returning prev here lets store.setState's Object.is check // skip the listener loop entirely. if (prev.denialTracking === newState) return prev return { ...prev, denialTracking: newState } }) } } /** * Check if a denial limit was exceeded and return an 'ask' result * so the user can review. Returns null if no limit was hit. */ function handleDenialLimitExceeded( denialState: DenialTrackingState, appState: { toolPermissionContext: { shouldAvoidPermissionPrompts?: boolean } }, classifierReason: string, assistantMessage: AssistantMessage, tool: Tool, result: PermissionDecision, context: ToolUseContext, ): PermissionDecision | null { if (!shouldFallbackToPrompting(denialState)) { return null } const hitTotalLimit = denialState.totalDenials >= DENIAL_LIMITS.maxTotal const isHeadless = appState.toolPermissionContext.shouldAvoidPermissionPrompts // Capture counts before persistDenialState, which may mutate denialState // in-place via Object.assign for subagents with localDenialTracking. const totalCount = denialState.totalDenials const consecutiveCount = denialState.consecutiveDenials const warning = hitTotalLimit ? `${totalCount} actions were blocked this session. Please review the transcript before continuing.` : `${consecutiveCount} consecutive actions were blocked. Please review the transcript before continuing.` logEvent('tengu_auto_mode_denial_limit_exceeded', { limit: (hitTotalLimit ? 'total' : 'consecutive') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, mode: (isHeadless ? 'headless' : 'cli') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, messageID: assistantMessage.message .id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, consecutiveDenials: consecutiveCount, totalDenials: totalCount, toolName: sanitizeToolNameForAnalytics(tool.name), }) if (isHeadless) { throw new AbortError( 'Agent aborted: too many classifier denials in headless mode', ) } logForDebugging( `Classifier denial limit exceeded, falling back to prompting: ${warning}`, { level: 'warn' }, ) if (hitTotalLimit) { persistDenialState(context, { ...denialState, totalDenials: 0, consecutiveDenials: 0, }) } // Preserve the original classifier value (e.g. 'dangerous-agent-action') // so downstream analytics in interactiveHandler can log the correct // user override event. const originalClassifier = result.decisionReason?.type === 'classifier' ? result.decisionReason.classifier : 'auto-mode' return { ...result, decisionReason: { type: 'classifier', classifier: originalClassifier, reason: `${warning}\n\nLatest blocked action: ${classifierReason}`, }, } } /** * Check only the rule-based steps of the permission pipeline — the subset * that bypassPermissions mode respects (everything that fires before step 2a). * * Returns a deny/ask decision if a rule blocks the tool, or null if no rule * objects. Unlike hasPermissionsToUseTool, this does NOT run the auto mode classifier, * mode-based transformations (dontAsk/auto/asyncAgent), PermissionRequest hooks, * or bypassPermissions / always-allowed checks. * * Caller must pre-check tool.requiresUserInteraction() — step 1e is not replicated. */ export async function checkRuleBasedPermissions( tool: Tool, input: { [key: string]: unknown }, context: ToolUseContext, ): Promise { const appState = context.getAppState() // 1a. Entire tool is denied by rule const denyRule = getDenyRuleForTool(appState.toolPermissionContext, tool) if (denyRule) { return { behavior: 'deny', decisionReason: { type: 'rule', rule: denyRule, }, message: `Permission to use ${tool.name} has been denied.`, } } // 1b. Entire tool has an ask rule const askRule = getAskRuleForTool(appState.toolPermissionContext, tool) if (askRule) { const canSandboxAutoAllow = tool.name === BASH_TOOL_NAME && SandboxManager.isSandboxingEnabled() && SandboxManager.isAutoAllowBashIfSandboxedEnabled() && shouldUseSandbox(input) if (!canSandboxAutoAllow) { return { behavior: 'ask', decisionReason: { type: 'rule', rule: askRule, }, message: createPermissionRequestMessage(tool.name), } } // Fall through to let tool.checkPermissions handle command-specific rules } // 1c. Tool-specific permission check (e.g. bash subcommand rules) let toolPermissionResult: PermissionResult = { behavior: 'passthrough', message: createPermissionRequestMessage(tool.name), } try { const parsedInput = tool.inputSchema.parse(input) toolPermissionResult = await tool.checkPermissions(parsedInput, context) } catch (e) { if (e instanceof AbortError || e instanceof APIUserAbortError) { throw e } logError(e) } // 1d. Tool implementation denied (catches bash subcommand denies wrapped // in subcommandResults — no need to inspect decisionReason.type) if (toolPermissionResult?.behavior === 'deny') { return toolPermissionResult } // 1f. Content-specific ask rules from tool.checkPermissions // (e.g. Bash(npm publish:*) → {ask, type:'rule', ruleBehavior:'ask'}) if ( toolPermissionResult?.behavior === 'ask' && toolPermissionResult.decisionReason?.type === 'rule' && toolPermissionResult.decisionReason.rule.ruleBehavior === 'ask' ) { return toolPermissionResult } // 1g. Safety checks (e.g. .git/, .claude/, .vscode/, shell configs) are // bypass-immune — they must prompt even when a PreToolUse hook returned // allow. checkPathSafetyForAutoEdit returns {type:'safetyCheck'} for these. if ( toolPermissionResult?.behavior === 'ask' && toolPermissionResult.decisionReason?.type === 'safetyCheck' ) { return toolPermissionResult } // No rule-based objection return null } async function hasPermissionsToUseToolInner( tool: Tool, input: { [key: string]: unknown }, context: ToolUseContext, ): Promise { if (context.abortController.signal.aborted) { throw new AbortError() } let appState = context.getAppState() // 1. Check if the tool is denied // 1a. Entire tool is denied const denyRule = getDenyRuleForTool(appState.toolPermissionContext, tool) if (denyRule) { return { behavior: 'deny', decisionReason: { type: 'rule', rule: denyRule, }, message: `Permission to use ${tool.name} has been denied.`, } } // 1b. Check if the entire tool should always ask for permission const askRule = getAskRuleForTool(appState.toolPermissionContext, tool) if (askRule) { // When autoAllowBashIfSandboxed is on, sandboxed commands skip the ask rule and // auto-allow via Bash's checkPermissions. Commands that won't be sandboxed (excluded // commands, dangerouslyDisableSandbox) still need to respect the ask rule. const canSandboxAutoAllow = tool.name === BASH_TOOL_NAME && SandboxManager.isSandboxingEnabled() && SandboxManager.isAutoAllowBashIfSandboxedEnabled() && shouldUseSandbox(input) if (!canSandboxAutoAllow) { return { behavior: 'ask', decisionReason: { type: 'rule', rule: askRule, }, message: createPermissionRequestMessage(tool.name), } } // Fall through to let Bash's checkPermissions handle command-specific rules } // 1c. Ask the tool implementation for a permission result // Overridden unless tool input schema is not valid let toolPermissionResult: PermissionResult = { behavior: 'passthrough', message: createPermissionRequestMessage(tool.name), } try { const parsedInput = tool.inputSchema.parse(input) toolPermissionResult = await tool.checkPermissions(parsedInput, context) } catch (e) { // Rethrow abort errors so they propagate properly if (e instanceof AbortError || e instanceof APIUserAbortError) { throw e } logError(e) } // 1d. Tool implementation denied permission if (toolPermissionResult?.behavior === 'deny') { return toolPermissionResult } // 1e. Tool requires user interaction even in bypass mode if ( tool.requiresUserInteraction?.() && toolPermissionResult?.behavior === 'ask' ) { return toolPermissionResult } // 1f. Content-specific ask rules from tool.checkPermissions take precedence // over bypassPermissions mode. When a user explicitly configures a // content-specific ask rule (e.g. Bash(npm publish:*)), the tool's // checkPermissions returns {behavior:'ask', decisionReason:{type:'rule', // rule:{ruleBehavior:'ask'}}}. This must be respected even in bypass mode, // just as deny rules are respected at step 1d. if ( toolPermissionResult?.behavior === 'ask' && toolPermissionResult.decisionReason?.type === 'rule' && toolPermissionResult.decisionReason.rule.ruleBehavior === 'ask' ) { return toolPermissionResult } // 1g. Safety checks (e.g. .git/, .claude/, .vscode/, shell configs) are // bypass-immune — they must prompt even in bypassPermissions mode. // checkPathSafetyForAutoEdit returns {type:'safetyCheck'} for these paths. if ( toolPermissionResult?.behavior === 'ask' && toolPermissionResult.decisionReason?.type === 'safetyCheck' ) { return toolPermissionResult } // 2a. Check if mode allows the tool to run // IMPORTANT: Call getAppState() to get the latest value appState = context.getAppState() // Check if permissions should be bypassed: // - Direct bypassPermissions mode // - Plan mode when the user originally started with bypass mode (isBypassPermissionsModeAvailable) const shouldBypassPermissions = appState.toolPermissionContext.mode === 'bypassPermissions' || (appState.toolPermissionContext.mode === 'plan' && appState.toolPermissionContext.isBypassPermissionsModeAvailable) if (shouldBypassPermissions) { return { behavior: 'allow', updatedInput: getUpdatedInputOrFallback(toolPermissionResult, input), decisionReason: { type: 'mode', mode: appState.toolPermissionContext.mode, }, } } // 2b. Entire tool is allowed const alwaysAllowedRule = toolAlwaysAllowedRule( appState.toolPermissionContext, tool, ) if (alwaysAllowedRule) { return { behavior: 'allow', updatedInput: getUpdatedInputOrFallback(toolPermissionResult, input), decisionReason: { type: 'rule', rule: alwaysAllowedRule, }, } } // 3. Convert "passthrough" to "ask" const result: PermissionDecision = toolPermissionResult.behavior === 'passthrough' ? { ...toolPermissionResult, behavior: 'ask' as const, message: createPermissionRequestMessage( tool.name, toolPermissionResult.decisionReason, ), } : toolPermissionResult if (result.behavior === 'ask' && result.suggestions) { logForDebugging( `Permission suggestions for ${tool.name}: ${jsonStringify(result.suggestions, null, 2)}`, ) } return result } type EditPermissionRuleArgs = { initialContext: ToolPermissionContext setToolPermissionContext: (updatedContext: ToolPermissionContext) => void } /** * Delete a permission rule from the appropriate destination */ export async function deletePermissionRule({ rule, initialContext, setToolPermissionContext, }: EditPermissionRuleArgs & { rule: PermissionRule }): Promise { if ( rule.source === 'policySettings' || rule.source === 'flagSettings' || rule.source === 'command' ) { throw new Error('Cannot delete permission rules from read-only settings') } const updatedContext = applyPermissionUpdate(initialContext, { type: 'removeRules', rules: [rule.ruleValue], behavior: rule.ruleBehavior, destination: rule.source as PermissionUpdateDestination, }) // Per-destination logic to delete the rule from settings const destination = rule.source switch (destination) { case 'localSettings': case 'userSettings': case 'projectSettings': { // Note: Typescript doesn't know that rule conforms to `PermissionRuleFromEditableSettings` even when we switch on `rule.source` deletePermissionRuleFromSettings( rule as PermissionRuleFromEditableSettings, ) break } case 'cliArg': case 'session': { // No action needed for in-memory sources - not persisted to disk break } } // Update React state with updated context setToolPermissionContext(updatedContext) } /** * Helper to convert PermissionRule array to PermissionUpdate array */ function convertRulesToUpdates( rules: PermissionRule[], updateType: 'addRules' | 'replaceRules', ): PermissionUpdate[] { // Group rules by source and behavior const grouped = new Map() for (const rule of rules) { const key = `${rule.source}:${rule.ruleBehavior}` if (!grouped.has(key)) { grouped.set(key, []) } grouped.get(key)!.push(rule.ruleValue) } // Convert to PermissionUpdate array const updates: PermissionUpdate[] = [] for (const [key, ruleValues] of grouped) { const [source, behavior] = key.split(':') updates.push({ type: updateType, rules: ruleValues, behavior: behavior as PermissionBehavior, destination: source as PermissionUpdateDestination, }) } return updates } /** * Apply permission rules to context (additive - for initial setup) */ export function applyPermissionRulesToPermissionContext( toolPermissionContext: ToolPermissionContext, rules: PermissionRule[], ): ToolPermissionContext { const updates = convertRulesToUpdates(rules, 'addRules') return applyPermissionUpdates(toolPermissionContext, updates) } /** * Sync permission rules from disk (replacement - for settings changes) */ export function syncPermissionRulesFromDisk( toolPermissionContext: ToolPermissionContext, rules: PermissionRule[], ): ToolPermissionContext { let context = toolPermissionContext // When allowManagedPermissionRulesOnly is enabled, clear all non-policy sources if (shouldAllowManagedPermissionRulesOnly()) { const sourcesToClear: PermissionUpdateDestination[] = [ 'userSettings', 'projectSettings', 'localSettings', 'cliArg', 'session', ] const behaviors: PermissionBehavior[] = ['allow', 'deny', 'ask'] for (const source of sourcesToClear) { for (const behavior of behaviors) { context = applyPermissionUpdate(context, { type: 'replaceRules', rules: [], behavior, destination: source, }) } } } // Clear all disk-based source:behavior combos before applying new rules. // Without this, removing a rule from settings (e.g. deleting a deny entry) // would leave the old rule in the context because convertRulesToUpdates // only generates replaceRules for source:behavior pairs that have rules — // an empty group produces no update, so stale rules persist. const diskSources: PermissionUpdateDestination[] = [ 'userSettings', 'projectSettings', 'localSettings', ] for (const diskSource of diskSources) { for (const behavior of ['allow', 'deny', 'ask'] as PermissionBehavior[]) { context = applyPermissionUpdate(context, { type: 'replaceRules', rules: [], behavior, destination: diskSource, }) } } const updates = convertRulesToUpdates(rules, 'replaceRules') return applyPermissionUpdates(context, updates) } /** * Extract updatedInput from a permission result, falling back to the original input. * Handles the case where some PermissionResult variants don't have updatedInput. */ function getUpdatedInputOrFallback( permissionResult: PermissionResult, fallback: Record, ): Record { return ( ('updatedInput' in permissionResult ? permissionResult.updatedInput : undefined) ?? fallback ) }