Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
52 kB · 1486 lines
TypeScript
at main
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487import { 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')) : nullconst 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<Tool, 'name' | 'mcpInfo'>, 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<Tool, 'name' | 'mcpInfo'>,): 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<Tool, 'name' | 'mcpInfo'>,): 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<Tool, 'name' | 'mcpInfo'>,): 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<T extends { agentType: string }>( 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<string>() 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<string, PermissionRule> { return getRuleByContentsForToolName( context, getToolNameForPermissionCheck(tool), behavior, )}
// Used to break circular dependency where a Tool calls this functionexport function getRuleByContentsForToolName( context: ToolPermissionContext, toolName: string, behavior: PermissionBehavior,): Map<string, PermissionRule> { const ruleByContents = new Map<string, PermissionRule>() 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<PermissionDecision | null> { try { for await (const hookResult of executePermissionRequestHooks( tool.name, toolUseID, input, context, permissionMode, suggestions, 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) context.setAppState(prev => ({ ...prev, toolPermissionContext: applyPermissionUpdates( prev.toolPermissionContext, decision.updatedPermissions!, ), })) } 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<PermissionDecision> => { 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<PermissionAskDecision | PermissionDenyDecision | null> { 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<PermissionDecision> { 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<void> { 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<string, PermissionRuleValue[]>()
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<string, unknown>,): Record<string, unknown> { return ( ('updatedInput' in permissionResult ? permissionResult.updatedInput : undefined) ?? fallback )}