import { feature } from 'bun:bundle' import { relative } from 'path' import { getOriginalCwd, handleAutoModeTransition, handlePlanModeTransition, setHasExitedPlanMode, setNeedsAutoModeExitAttachment, } from '../../bootstrap/state.js' import type { ToolPermissionContext, ToolPermissionRulesBySource, } from '../../Tool.js' import { getCwd } from '../cwd.js' import { isEnvTruthy } from '../envUtils.js' import type { SettingSource } from '../settings/constants.js' import { SETTING_SOURCES } from '../settings/constants.js' import { getSettings_DEPRECATED, getSettingsFilePathForSource, getUseAutoModeDuringPlan, hasAutoModeOptIn, } from '../settings/settings.js' import { type PermissionMode, permissionModeFromString, } from './PermissionMode.js' import { applyPermissionRulesToPermissionContext } from './permissions.js' import { loadAllPermissionRulesFromDisk } from './permissionsLoader.js' /* eslint-disable @typescript-eslint/no-require-imports */ const autoModeStateModule = feature('TRANSCRIPT_CLASSIFIER') ? (require('./autoModeState.js') as typeof import('./autoModeState.js')) : null import { resolve } from 'path' import { checkSecurityRestrictionGate, checkStatsigFeatureGate_CACHED_MAY_BE_STALE, getDynamicConfig_BLOCKS_ON_INIT, getFeatureValue_CACHED_MAY_BE_STALE, } from 'src/services/analytics/growthbook.js' import { addDirHelpMessage, validateDirectoryForWorkspace, } from '../../commands/add-dir/validation.js' import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, } from '../../services/analytics/index.js' import { AGENT_TOOL_NAME } from '../../tools/AgentTool/constants.js' import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js' /* eslint-enable @typescript-eslint/no-require-imports */ import { POWERSHELL_TOOL_NAME } from '../../tools/PowerShellTool/toolName.js' import { getToolsForDefaultPreset, parseToolPreset } from '../../tools.js' import { getFsImplementation, safeResolvePath, } from '../../utils/fsOperations.js' import { modelSupportsAutoMode } from '../betas.js' import { logForDebugging } from '../debug.js' import { gracefulShutdown } from '../gracefulShutdown.js' import { getMainLoopModel } from '../model/model.js' import { CROSS_PLATFORM_CODE_EXEC, DANGEROUS_BASH_PATTERNS, } from './dangerousPatterns.js' import type { PermissionRule, PermissionRuleSource, PermissionRuleValue, } from './PermissionRule.js' import { type AdditionalWorkingDirectory, applyPermissionUpdate, } from './PermissionUpdate.js' import type { PermissionUpdateDestination } from './PermissionUpdateSchema.js' import { normalizeLegacyToolName, permissionRuleValueFromString, permissionRuleValueToString, } from './permissionRuleParser.js' /** * Checks if a Bash permission rule is dangerous for auto mode. * A rule is dangerous if it would auto-allow commands that execute arbitrary code, * bypassing the classifier's safety evaluation. * * Dangerous patterns: * 1. Tool-level allow (Bash with no ruleContent) - allows ALL commands * 2. Prefix rules for script interpreters (python:*, node:*, etc.) * 3. Wildcard rules matching interpreters (python*, node*, etc.) */ export function isDangerousBashPermission( toolName: string, ruleContent: string | undefined, ): boolean { // Only check Bash rules if (toolName !== BASH_TOOL_NAME) { return false } // Tool-level allow (Bash with no content, or Bash(*)) - allows ALL commands if (ruleContent === undefined || ruleContent === '') { return true } const content = ruleContent.trim().toLowerCase() // Standalone wildcard (*) matches everything if (content === '*') { return true } // Check for dangerous patterns with prefix syntax (e.g., "python:*") // or wildcard syntax (e.g., "python*") for (const pattern of DANGEROUS_BASH_PATTERNS) { const lowerPattern = pattern.toLowerCase() // Exact match to the pattern itself (e.g., "python" as a rule) if (content === lowerPattern) { return true } // Prefix syntax: "python:*" allows any python command if (content === `${lowerPattern}:*`) { return true } // Wildcard at end: "python*" matches python, python3, etc. if (content === `${lowerPattern}*`) { return true } // Wildcard with space: "python *" would match "python script.py" if (content === `${lowerPattern} *`) { return true } // Check for patterns like "python -*" which would match "python -c 'code'" if (content.startsWith(`${lowerPattern} -`) && content.endsWith('*')) { return true } } return false } /** * Checks if a PowerShell permission rule is dangerous for auto mode. * A rule is dangerous if it would auto-allow commands that execute arbitrary * code (nested shells, Invoke-Expression, Start-Process, etc.), bypassing the * classifier's safety evaluation. * * PowerShell is case-insensitive, so rule content is lowercased before matching. */ export function isDangerousPowerShellPermission( toolName: string, ruleContent: string | undefined, ): boolean { if (toolName !== POWERSHELL_TOOL_NAME) { return false } // Tool-level allow (PowerShell with no content, or PowerShell(*)) - allows ALL commands if (ruleContent === undefined || ruleContent === '') { return true } const content = ruleContent.trim().toLowerCase() // Standalone wildcard (*) matches everything if (content === '*') { return true } // PS-specific cmdlet names. CROSS_PLATFORM_CODE_EXEC is shared with bash. const patterns: readonly string[] = [ ...CROSS_PLATFORM_CODE_EXEC, // Nested PS + shells launchable from PS 'pwsh', 'powershell', 'cmd', 'wsl', // String/scriptblock evaluators 'iex', 'invoke-expression', 'icm', 'invoke-command', // Process spawners 'start-process', 'saps', 'start', 'start-job', 'sajb', 'start-threadjob', // bundled PS 6.1+; takes -ScriptBlock like Start-Job // Event/session code exec 'register-objectevent', 'register-engineevent', 'register-wmievent', 'register-scheduledjob', 'new-pssession', 'nsn', // alias 'enter-pssession', 'etsn', // alias // .NET escape hatches 'add-type', // Add-Type -TypeDefinition '' → P/Invoke 'new-object', // New-Object -ComObject WScript.Shell → .Run() ] for (const pattern of patterns) { // patterns stored lowercase; content lowercased above if (content === pattern) return true if (content === `${pattern}:*`) return true if (content === `${pattern}*`) return true if (content === `${pattern} *`) return true if (content.startsWith(`${pattern} -`) && content.endsWith('*')) return true // .exe — goes on the FIRST word. `python` → `python.exe`. // `npm run` → `npm.exe run` (npm.exe is the real Windows binary name). // A rule like `PowerShell(npm.exe run:*)` needs to match `npm run`. const sp = pattern.indexOf(' ') const exe = sp === -1 ? `${pattern}.exe` : `${pattern.slice(0, sp)}.exe${pattern.slice(sp)}` if (content === exe) return true if (content === `${exe}:*`) return true if (content === `${exe}*`) return true if (content === `${exe} *`) return true if (content.startsWith(`${exe} -`) && content.endsWith('*')) return true } return false } /** * Checks if an Agent (sub-agent) permission rule is dangerous for auto mode. * Any Agent allow rule would auto-approve sub-agent spawns before the auto mode classifier * can evaluate the sub-agent's prompt, defeating delegation attack prevention. */ export function isDangerousTaskPermission( toolName: string, _ruleContent: string | undefined, ): boolean { return normalizeLegacyToolName(toolName) === AGENT_TOOL_NAME } function formatPermissionSource(source: PermissionRuleSource): string { if ((SETTING_SOURCES as readonly string[]).includes(source)) { const filePath = getSettingsFilePathForSource(source as SettingSource) if (filePath) { const relativePath = relative(getCwd(), filePath) return relativePath.length < filePath.length ? relativePath : filePath } } return source } export type DangerousPermissionInfo = { ruleValue: PermissionRuleValue source: PermissionRuleSource /** The permission rule formatted for display, e.g. "Bash(*)" or "Bash(python:*)" */ ruleDisplay: string /** The source formatted for display, e.g. a file path or "--allowed-tools" */ sourceDisplay: string } /** * Checks if a permission rule is dangerous for auto mode. * A rule is dangerous if it would auto-allow actions before the auto mode classifier * can evaluate them, bypassing safety checks. */ function isDangerousClassifierPermission( toolName: string, ruleContent: string | undefined, ): boolean { if (process.env.USER_TYPE === 'ant') { // Tmux send-keys executes arbitrary shell, bypassing the classifier same as Bash(*) if (toolName === 'Tmux') return true } return ( isDangerousBashPermission(toolName, ruleContent) || isDangerousPowerShellPermission(toolName, ruleContent) || isDangerousTaskPermission(toolName, ruleContent) ) } /** * Finds all dangerous permissions from rules loaded from disk and CLI arguments. * Returns structured info about each dangerous permission found. * * Checks Bash permissions (wildcard/interpreter patterns), PowerShell permissions * (wildcard/iex/Start-Process patterns), and Agent permissions (any allow rule * bypasses the classifier's sub-agent evaluation). */ export function findDangerousClassifierPermissions( rules: PermissionRule[], cliAllowedTools: string[], ): DangerousPermissionInfo[] { const dangerous: DangerousPermissionInfo[] = [] // Check rules loaded from settings for (const rule of rules) { if ( rule.ruleBehavior === 'allow' && isDangerousClassifierPermission( rule.ruleValue.toolName, rule.ruleValue.ruleContent, ) ) { const ruleString = rule.ruleValue.ruleContent ? `${rule.ruleValue.toolName}(${rule.ruleValue.ruleContent})` : `${rule.ruleValue.toolName}(*)` dangerous.push({ ruleValue: rule.ruleValue, source: rule.source, ruleDisplay: ruleString, sourceDisplay: formatPermissionSource(rule.source), }) } } // Check CLI --allowed-tools arguments for (const toolSpec of cliAllowedTools) { // Parse tool spec: "Bash" or "Bash(pattern)" or "Agent" or "Agent(subagent_type)" const match = toolSpec.match(/^([^(]+)(?:\(([^)]*)\))?$/) if (match) { const toolName = match[1]!.trim() const ruleContent = match[2]?.trim() if (isDangerousClassifierPermission(toolName, ruleContent)) { dangerous.push({ ruleValue: { toolName, ruleContent }, source: 'cliArg', ruleDisplay: ruleContent ? toolSpec : `${toolName}(*)`, sourceDisplay: '--allowed-tools', }) } } } return dangerous } /** * Checks if a Bash allow rule is overly broad (equivalent to YOLO mode). * Returns true for tool-level Bash allow rules with no content restriction, * which auto-allow every bash command. * * Matches: Bash, Bash(*), Bash() — all parse to { toolName: 'Bash' } with no ruleContent. */ export function isOverlyBroadBashAllowRule( ruleValue: PermissionRuleValue, ): boolean { return ( ruleValue.toolName === BASH_TOOL_NAME && ruleValue.ruleContent === undefined ) } /** * PowerShell equivalent of isOverlyBroadBashAllowRule. * * Matches: PowerShell, PowerShell(*), PowerShell() — all parse to * { toolName: 'PowerShell' } with no ruleContent. */ export function isOverlyBroadPowerShellAllowRule( ruleValue: PermissionRuleValue, ): boolean { return ( ruleValue.toolName === POWERSHELL_TOOL_NAME && ruleValue.ruleContent === undefined ) } /** * Finds all overly broad Bash allow rules from settings and CLI arguments. * An overly broad rule allows ALL bash commands (e.g., Bash or Bash(*)), * which is effectively equivalent to YOLO/bypass-permissions mode. */ export function findOverlyBroadBashPermissions( rules: PermissionRule[], cliAllowedTools: string[], ): DangerousPermissionInfo[] { const overlyBroad: DangerousPermissionInfo[] = [] for (const rule of rules) { if ( rule.ruleBehavior === 'allow' && isOverlyBroadBashAllowRule(rule.ruleValue) ) { overlyBroad.push({ ruleValue: rule.ruleValue, source: rule.source, ruleDisplay: `${BASH_TOOL_NAME}(*)`, sourceDisplay: formatPermissionSource(rule.source), }) } } for (const toolSpec of cliAllowedTools) { const parsed = permissionRuleValueFromString(toolSpec) if (isOverlyBroadBashAllowRule(parsed)) { overlyBroad.push({ ruleValue: parsed, source: 'cliArg', ruleDisplay: `${BASH_TOOL_NAME}(*)`, sourceDisplay: '--allowed-tools', }) } } return overlyBroad } /** * PowerShell equivalent of findOverlyBroadBashPermissions. */ export function findOverlyBroadPowerShellPermissions( rules: PermissionRule[], cliAllowedTools: string[], ): DangerousPermissionInfo[] { const overlyBroad: DangerousPermissionInfo[] = [] for (const rule of rules) { if ( rule.ruleBehavior === 'allow' && isOverlyBroadPowerShellAllowRule(rule.ruleValue) ) { overlyBroad.push({ ruleValue: rule.ruleValue, source: rule.source, ruleDisplay: `${POWERSHELL_TOOL_NAME}(*)`, sourceDisplay: formatPermissionSource(rule.source), }) } } for (const toolSpec of cliAllowedTools) { const parsed = permissionRuleValueFromString(toolSpec) if (isOverlyBroadPowerShellAllowRule(parsed)) { overlyBroad.push({ ruleValue: parsed, source: 'cliArg', ruleDisplay: `${POWERSHELL_TOOL_NAME}(*)`, sourceDisplay: '--allowed-tools', }) } } return overlyBroad } /** * Type guard to check if a PermissionRuleSource is a valid PermissionUpdateDestination. * Sources like 'flagSettings', 'policySettings', and 'command' are not valid destinations. */ function isPermissionUpdateDestination( source: PermissionRuleSource, ): source is PermissionUpdateDestination { return [ 'userSettings', 'projectSettings', 'localSettings', 'session', 'cliArg', ].includes(source) } /** * Removes dangerous permissions from the in-memory context, and optionally * persists the removal to settings files on disk. */ export function removeDangerousPermissions( context: ToolPermissionContext, dangerousPermissions: DangerousPermissionInfo[], ): ToolPermissionContext { // Group dangerous rules by their source (destination for updates) const rulesBySource = new Map< PermissionUpdateDestination, PermissionRuleValue[] >() for (const perm of dangerousPermissions) { // Skip sources that can't be persisted (flagSettings, policySettings, command) if (!isPermissionUpdateDestination(perm.source)) { continue } const destination = perm.source const existing = rulesBySource.get(destination) || [] existing.push(perm.ruleValue) rulesBySource.set(destination, existing) } let updatedContext = context for (const [destination, rules] of rulesBySource) { updatedContext = applyPermissionUpdate(updatedContext, { type: 'removeRules' as const, rules, behavior: 'allow' as const, destination, }) } return updatedContext } /** * Prepares a ToolPermissionContext for auto mode by stripping * dangerous permissions that would bypass the classifier. * Returns the cleaned context (with mode unchanged — caller sets the mode). */ export function stripDangerousPermissionsForAutoMode( context: ToolPermissionContext, ): ToolPermissionContext { const rules: PermissionRule[] = [] for (const [source, ruleStrings] of Object.entries( context.alwaysAllowRules, )) { if (!ruleStrings) { continue } for (const ruleString of ruleStrings) { const ruleValue = permissionRuleValueFromString(ruleString) rules.push({ source: source as PermissionRuleSource, ruleBehavior: 'allow', ruleValue, }) } } const dangerousPermissions = findDangerousClassifierPermissions(rules, []) if (dangerousPermissions.length === 0) { return { ...context, strippedDangerousRules: context.strippedDangerousRules ?? {}, } } for (const permission of dangerousPermissions) { logForDebugging( `Ignoring dangerous permission ${permission.ruleDisplay} from ${permission.sourceDisplay} (bypasses classifier)`, ) } // Mirror removeDangerousPermissions' source filter so stash == what was actually removed. const stripped: ToolPermissionRulesBySource = {} for (const perm of dangerousPermissions) { if (!isPermissionUpdateDestination(perm.source)) continue ;(stripped[perm.source] ??= []).push( permissionRuleValueToString(perm.ruleValue), ) } return { ...removeDangerousPermissions(context, dangerousPermissions), strippedDangerousRules: stripped, } } /** * Restores dangerous allow rules previously stashed by * stripDangerousPermissionsForAutoMode. Called when leaving auto mode so that * the user's Bash(python:*), Agent(*), etc. rules work again in default mode. * Clears the stash so a second exit is a no-op. */ export function restoreDangerousPermissions( context: ToolPermissionContext, ): ToolPermissionContext { const stash = context.strippedDangerousRules if (!stash) { return context } let result = context for (const [source, ruleStrings] of Object.entries(stash)) { if (!ruleStrings || ruleStrings.length === 0) continue result = applyPermissionUpdate(result, { type: 'addRules', rules: ruleStrings.map(permissionRuleValueFromString), behavior: 'allow', destination: source as PermissionUpdateDestination, }) } return { ...result, strippedDangerousRules: undefined } } /** * Handles all state transitions when switching permission modes. * Centralises side-effects so that every activation path (CLI Shift+Tab, * SDK control messages, etc.) behaves identically. * * Currently handles: * - Plan mode enter/exit attachments (via handlePlanModeTransition) * - Auto mode activation: setAutoModeActive, stripDangerousPermissionsForAutoMode * * Returns the (possibly modified) context. Caller is responsible for setting * the mode on the returned context. * * @param fromMode The current permission mode * @param toMode The target permission mode * @param context The current tool permission context */ export function transitionPermissionMode( fromMode: string, toMode: string, context: ToolPermissionContext, ): ToolPermissionContext { // plan→plan (SDK set_permission_mode) would wrongly hit the leave branch below if (fromMode === toMode) return context handlePlanModeTransition(fromMode, toMode) handleAutoModeTransition(fromMode, toMode) if (fromMode === 'plan' && toMode !== 'plan') { setHasExitedPlanMode(true) } if (feature('TRANSCRIPT_CLASSIFIER')) { if (toMode === 'plan' && fromMode !== 'plan') { return prepareContextForPlanMode(context) } // Plan with auto active counts as using the classifier (for the leaving side). // isAutoModeActive() is the authoritative signal — prePlanMode/strippedDangerousRules // are unreliable proxies because auto can be deactivated mid-plan (non-opt-in // entry, transitionPlanAutoMode) while those fields remain set/unset. const fromUsesClassifier = fromMode === 'auto' || (fromMode === 'plan' && (autoModeStateModule?.isAutoModeActive() ?? false)) const toUsesClassifier = toMode === 'auto' // plan entry handled above if (toUsesClassifier && !fromUsesClassifier) { if (!isAutoModeGateEnabled()) { throw new Error('Cannot transition to auto mode: gate is not enabled') } autoModeStateModule?.setAutoModeActive(true) context = stripDangerousPermissionsForAutoMode(context) } else if (fromUsesClassifier && !toUsesClassifier) { autoModeStateModule?.setAutoModeActive(false) setNeedsAutoModeExitAttachment(true) context = restoreDangerousPermissions(context) } } // Only spread if there's something to clear (preserves ref equality) if (fromMode === 'plan' && toMode !== 'plan' && context.prePlanMode) { return { ...context, prePlanMode: undefined } } return context } /** * Parse base tools specification from CLI * Handles both preset names (default, none) and custom tool lists */ export function parseBaseToolsFromCLI(baseTools: string[]): string[] { // Join all array elements and check if it's a single preset name const joinedInput = baseTools.join(' ').trim() const preset = parseToolPreset(joinedInput) if (preset) { return getToolsForDefaultPreset() } // Parse as a custom tool list using the same parsing logic as allowedTools/disallowedTools const parsedTools = parseToolListFromCLI(baseTools) return parsedTools } /** * Check if processPwd is a symlink that resolves to originalCwd */ function isSymlinkTo({ processPwd, originalCwd, }: { processPwd: string originalCwd: string }): boolean { // Use safeResolvePath to check if processPwd is a symlink and get its resolved path const { resolvedPath: resolvedProcessPwd, isSymlink: isProcessPwdSymlink } = safeResolvePath(getFsImplementation(), processPwd) return isProcessPwdSymlink ? resolvedProcessPwd === resolve(originalCwd) : false } /** * Safely convert CLI flags to a PermissionMode */ export function initialPermissionModeFromCLI({ permissionModeCli, dangerouslySkipPermissions, }: { permissionModeCli: string | undefined dangerouslySkipPermissions: boolean | undefined }): { mode: PermissionMode; notification?: string } { const settings = getSettings_DEPRECATED() || {} // Check GrowthBook gate first - highest precedence const growthBookDisableBypassPermissionsMode = checkStatsigFeatureGate_CACHED_MAY_BE_STALE( 'tengu_disable_bypass_permissions_mode', ) // Then check settings - lower precedence const settingsDisableBypassPermissionsMode = settings.permissions?.disableBypassPermissionsMode === 'disable' // Statsig gate takes precedence over settings const disableBypassPermissionsMode = growthBookDisableBypassPermissionsMode || settingsDisableBypassPermissionsMode // Sync circuit-breaker check (cached GB read). Prevents the // AutoModeOptInDialog from showing in showSetupScreens() when auto can't // actually be entered. autoModeFlagCli still carries intent through to // verifyAutoModeGateAccess, which notifies the user why. const autoModeCircuitBrokenSync = feature('TRANSCRIPT_CLASSIFIER') ? getAutoModeEnabledStateIfCached() === 'disabled' : false // Modes in order of priority const orderedModes: PermissionMode[] = [] let notification: string | undefined if (dangerouslySkipPermissions) { orderedModes.push('bypassPermissions') } if (permissionModeCli) { const parsedMode = permissionModeFromString(permissionModeCli) if (feature('TRANSCRIPT_CLASSIFIER') && parsedMode === 'auto') { if (autoModeCircuitBrokenSync) { logForDebugging( 'auto mode circuit breaker active (cached) — falling back to default', { level: 'warn' }, ) } else { orderedModes.push('auto') } } else { orderedModes.push(parsedMode) } } if (settings.permissions?.defaultMode) { const settingsMode = settings.permissions.defaultMode as PermissionMode // CCR only supports acceptEdits and plan — ignore other defaultModes from // settings (e.g. bypassPermissions would otherwise silently grant full // access in a remote environment). if ( isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) && !['acceptEdits', 'plan', 'default'].includes(settingsMode) ) { logForDebugging( `settings defaultMode "${settingsMode}" is not supported in CLAUDE_CODE_REMOTE — only acceptEdits and plan are allowed`, { level: 'warn' }, ) logEvent('tengu_ccr_unsupported_default_mode_ignored', { mode: settingsMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) } // auto from settings requires the same gate check as from CLI else if (feature('TRANSCRIPT_CLASSIFIER') && settingsMode === 'auto') { if (autoModeCircuitBrokenSync) { logForDebugging( 'auto mode circuit breaker active (cached) — falling back to default', { level: 'warn' }, ) } else { orderedModes.push('auto') } } else { orderedModes.push(settingsMode) } } let result: { mode: PermissionMode; notification?: string } | undefined for (const mode of orderedModes) { if (mode === 'bypassPermissions' && disableBypassPermissionsMode) { if (growthBookDisableBypassPermissionsMode) { logForDebugging('bypassPermissions mode is disabled by Statsig gate', { level: 'warn', }) notification = 'Bypass permissions mode was disabled by your organization policy' } else { logForDebugging('bypassPermissions mode is disabled by settings', { level: 'warn', }) notification = 'Bypass permissions mode was disabled by settings' } continue // Skip this mode if it's disabled } result = { mode, notification } // Use the first valid mode break } if (!result) { result = { mode: 'default', notification } } if (!result) { result = { mode: 'default', notification } } if (feature('TRANSCRIPT_CLASSIFIER') && result.mode === 'auto') { autoModeStateModule?.setAutoModeActive(true) } return result } export function parseToolListFromCLI(tools: string[]): string[] { if (tools.length === 0) { return [] } const result: string[] = [] // Process each string in the array for (const toolString of tools) { if (!toolString) continue let current = '' let isInParens = false // Parse each character in the string for (const char of toolString) { switch (char) { case '(': isInParens = true current += char break case ')': isInParens = false current += char break case ',': if (isInParens) { current += char } else { // Comma separator - push current tool and start new one if (current.trim()) { result.push(current.trim()) } current = '' } break case ' ': if (isInParens) { current += char } else if (current.trim()) { // Space separator - push current tool and start new one result.push(current.trim()) current = '' } break default: current += char } } // Push any remaining tool if (current.trim()) { result.push(current.trim()) } } return result } export async function initializeToolPermissionContext({ allowedToolsCli, disallowedToolsCli, baseToolsCli, permissionMode, allowDangerouslySkipPermissions, addDirs, }: { allowedToolsCli: string[] disallowedToolsCli: string[] baseToolsCli?: string[] permissionMode: PermissionMode allowDangerouslySkipPermissions: boolean addDirs: string[] }): Promise<{ toolPermissionContext: ToolPermissionContext warnings: string[] dangerousPermissions: DangerousPermissionInfo[] overlyBroadBashPermissions: DangerousPermissionInfo[] }> { // Parse comma-separated allowed and disallowed tools if provided // Normalize legacy tool names (e.g., 'Task' → 'Agent') so that in-memory // rule removal in stripDangerousPermissionsForAutoMode matches correctly. const parsedAllowedToolsCli = parseToolListFromCLI(allowedToolsCli).map( rule => permissionRuleValueToString(permissionRuleValueFromString(rule)), ) let parsedDisallowedToolsCli = parseToolListFromCLI(disallowedToolsCli) // If base tools are specified, automatically deny all tools NOT in the base set // We need to check if base tools were explicitly provided (not just empty default) if (baseToolsCli && baseToolsCli.length > 0) { const baseToolsResult = parseBaseToolsFromCLI(baseToolsCli) // Normalize legacy tool names (e.g., 'Task' → 'Agent') so user-provided // base tool lists using old names still match canonical names. const baseToolsSet = new Set(baseToolsResult.map(normalizeLegacyToolName)) const allToolNames = getToolsForDefaultPreset() const toolsToDisallow = allToolNames.filter(tool => !baseToolsSet.has(tool)) parsedDisallowedToolsCli = [...parsedDisallowedToolsCli, ...toolsToDisallow] } const warnings: string[] = [] const additionalWorkingDirectories = new Map< string, AdditionalWorkingDirectory >() // process.env.PWD may be a symlink, while getOriginalCwd() uses the real path const processPwd = process.env.PWD if ( processPwd && processPwd !== getOriginalCwd() && isSymlinkTo({ originalCwd: getOriginalCwd(), processPwd }) ) { additionalWorkingDirectories.set(processPwd, { path: processPwd, source: 'session', }) } // Check if bypassPermissions mode is available (not disabled by Statsig gate or settings) // Use cached values to avoid blocking on startup const growthBookDisableBypassPermissionsMode = checkStatsigFeatureGate_CACHED_MAY_BE_STALE( 'tengu_disable_bypass_permissions_mode', ) const settings = getSettings_DEPRECATED() || {} const settingsDisableBypassPermissionsMode = settings.permissions?.disableBypassPermissionsMode === 'disable' const isBypassPermissionsModeAvailable = (permissionMode === 'bypassPermissions' || allowDangerouslySkipPermissions) && !growthBookDisableBypassPermissionsMode && !settingsDisableBypassPermissionsMode // Load all permission rules from disk const rulesFromDisk = loadAllPermissionRulesFromDisk() // Ant-only: Detect overly broad shell allow rules for all modes. // Bash(*) or PowerShell(*) are equivalent to YOLO mode for that shell. // Skip in CCR/BYOC where --allowed-tools is the intended pre-approval mechanism. // Variable name kept for return-field compat; contains both shells. let overlyBroadBashPermissions: DangerousPermissionInfo[] = [] if ( process.env.USER_TYPE === 'ant' && !isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) && process.env.CLAUDE_CODE_ENTRYPOINT !== 'local-agent' ) { overlyBroadBashPermissions = [ ...findOverlyBroadBashPermissions(rulesFromDisk, parsedAllowedToolsCli), ...findOverlyBroadPowerShellPermissions( rulesFromDisk, parsedAllowedToolsCli, ), ] } // Ant-only: Detect dangerous shell permissions for auto mode // Dangerous permissions (like Bash(*), Bash(python:*), PowerShell(iex:*)) would auto-allow // before the classifier can evaluate them, defeating the purpose of safer YOLO mode let dangerousPermissions: DangerousPermissionInfo[] = [] if (feature('TRANSCRIPT_CLASSIFIER') && permissionMode === 'auto') { dangerousPermissions = findDangerousClassifierPermissions( rulesFromDisk, parsedAllowedToolsCli, ) } let toolPermissionContext = applyPermissionRulesToPermissionContext( { mode: permissionMode, additionalWorkingDirectories, alwaysAllowRules: { cliArg: parsedAllowedToolsCli }, alwaysDenyRules: { cliArg: parsedDisallowedToolsCli }, alwaysAskRules: {}, isBypassPermissionsModeAvailable, ...(feature('TRANSCRIPT_CLASSIFIER') ? { isAutoModeAvailable: isAutoModeGateEnabled() } : {}), }, rulesFromDisk, ) // Add directories from settings and --add-dir const allAdditionalDirectories = [ ...(settings.permissions?.additionalDirectories || []), ...addDirs, ] // Parallelize fs validation; apply updates serially (cumulative context). // validateDirectoryForWorkspace only reads permissionContext to check if the // dir is already covered — behavioral difference from parallelizing is benign // (two overlapping --add-dirs both succeed instead of one being flagged // alreadyInWorkingDirectory, which was silently skipped anyway). const validationResults = await Promise.all( allAdditionalDirectories.map(dir => validateDirectoryForWorkspace(dir, toolPermissionContext), ), ) for (const result of validationResults) { if (result.resultType === 'success') { toolPermissionContext = applyPermissionUpdate(toolPermissionContext, { type: 'addDirectories', directories: [result.absolutePath], destination: 'cliArg', }) } else if ( result.resultType !== 'alreadyInWorkingDirectory' && result.resultType !== 'pathNotFound' ) { // Warn for actual config mistakes (e.g. specifying a file instead of a // directory). But if the directory doesn't exist anymore (e.g. someone // was working under /tmp and it got cleared), silently skip. They'll get // prompted again if they try to access it later. warnings.push(addDirHelpMessage(result)) } } return { toolPermissionContext, warnings, dangerousPermissions, overlyBroadBashPermissions, } } export type AutoModeGateCheckResult = { // Transform function (not a pre-computed context) so callers can apply it // inside setAppState(prev => ...) against the CURRENT context. Pre-computing // the context here captured a stale snapshot: the async GrowthBook await // below can be outrun by a mid-turn shift-tab, and returning // { ...currentContext, ... } would overwrite the user's mode change. updateContext: (ctx: ToolPermissionContext) => ToolPermissionContext notification?: string } export type AutoModeUnavailableReason = 'settings' | 'circuit-breaker' | 'model' export function getAutoModeUnavailableNotification( reason: AutoModeUnavailableReason, ): string { let base: string switch (reason) { case 'settings': base = 'auto mode disabled by settings' break case 'circuit-breaker': base = 'auto mode is unavailable for your plan' break case 'model': base = 'auto mode unavailable for this model' break } return process.env.USER_TYPE === 'ant' ? `${base} · #claude-code-feedback` : base } /** * Async check of auto mode availability. * * Returns a transform function (not a pre-computed context) that callers * apply inside setAppState(prev => ...) against the CURRENT context. This * prevents the async GrowthBook await from clobbering mid-turn mode changes * (e.g., user shift-tabs to acceptEdits while this check is in flight). * * The transform re-checks mode/prePlanMode against the fresh ctx to avoid * kicking the user out of a mode they've already left during the await. */ export async function verifyAutoModeGateAccess( currentContext: ToolPermissionContext, // Runtime AppState.fastMode — passed from callers with AppState access so // the disableFastMode circuit breaker reads current state, not stale // settings.fastMode (which is intentionally sticky across /model auto- // downgrades). Optional for callers without AppState (e.g. SDK init paths). fastMode?: boolean, ): Promise { // Auto-mode config — runs in ALL builds (circuit breaker, carousel, kick-out) // Fresh read of tengu_auto_mode_config.enabled — this async check runs once // after GrowthBook initialization and is the authoritative source for // isAutoModeAvailable. The sync startup path uses stale cache; this // corrects it. Circuit breaker (enabled==='disabled') takes effect here. const autoModeConfig = await getDynamicConfig_BLOCKS_ON_INIT<{ enabled?: AutoModeEnabledState disableFastMode?: boolean }>('tengu_auto_mode_config', {}) const enabledState = parseAutoModeEnabledState(autoModeConfig?.enabled) const disabledBySettings = isAutoModeDisabledBySettings() // Treat settings-disable the same as GrowthBook 'disabled' for circuit-breaker // semantics — blocks SDK/explicit re-entry via isAutoModeGateEnabled(). autoModeStateModule?.setAutoModeCircuitBroken( enabledState === 'disabled' || disabledBySettings, ) // Carousel availability: not circuit-broken, not disabled-by-settings, // model supports it, disableFastMode breaker not firing, and (enabled or opted-in) const mainModel = getMainLoopModel() // Temp circuit breaker: tengu_auto_mode_config.disableFastMode blocks auto // mode when fast mode is on. Checks runtime AppState.fastMode (if provided) // and, for ants, model name '-fast' substring (ant-internal fast models // like capybara-v2-fast[1m] encode speed in the model ID itself). // Remove once auto+fast mode interaction is validated. const disableFastModeBreakerFires = !!autoModeConfig?.disableFastMode && (!!fastMode || (process.env.USER_TYPE === 'ant' && mainModel.toLowerCase().includes('-fast'))) const modelSupported = modelSupportsAutoMode(mainModel) && !disableFastModeBreakerFires let carouselAvailable = false if (enabledState !== 'disabled' && !disabledBySettings && modelSupported) { carouselAvailable = enabledState === 'enabled' || hasAutoModeOptInAnySource() } // canEnterAuto gates explicit entry (--permission-mode auto, defaultMode: auto) // — explicit entry IS an opt-in, so we only block on circuit breaker + settings + model const canEnterAuto = enabledState !== 'disabled' && !disabledBySettings && modelSupported logForDebugging( `[auto-mode] verifyAutoModeGateAccess: enabledState=${enabledState} disabledBySettings=${disabledBySettings} model=${mainModel} modelSupported=${modelSupported} disableFastModeBreakerFires=${disableFastModeBreakerFires} carouselAvailable=${carouselAvailable} canEnterAuto=${canEnterAuto}`, ) // Capture CLI-flag intent now (doesn't depend on context). const autoModeFlagCli = autoModeStateModule?.getAutoModeFlagCli() ?? false // Return a transform function that re-evaluates context-dependent conditions // against the CURRENT context at setAppState time. The async GrowthBook // results above (canEnterAuto, carouselAvailable, enabledState, reason) are // closure-captured — those don't depend on context. But mode, prePlanMode, // and isAutoModeAvailable checks MUST use the fresh ctx or a mid-await // shift-tab gets reverted (or worse, the user stays in auto despite the // circuit breaker if they entered auto DURING the await — which is possible // because setAutoModeCircuitBroken above runs AFTER the await). const setAvailable = ( ctx: ToolPermissionContext, available: boolean, ): ToolPermissionContext => { if (ctx.isAutoModeAvailable !== available) { logForDebugging( `[auto-mode] verifyAutoModeGateAccess setAvailable: ${ctx.isAutoModeAvailable} -> ${available}`, ) } return ctx.isAutoModeAvailable === available ? ctx : { ...ctx, isAutoModeAvailable: available } } if (canEnterAuto) { return { updateContext: ctx => setAvailable(ctx, carouselAvailable) } } // Gate is off or circuit-broken — determine reason (context-independent). let reason: AutoModeUnavailableReason if (disabledBySettings) { reason = 'settings' logForDebugging('auto mode disabled: disableAutoMode in settings', { level: 'warn', }) } else if (enabledState === 'disabled') { reason = 'circuit-breaker' logForDebugging( 'auto mode disabled: tengu_auto_mode_config.enabled === "disabled" (circuit breaker)', { level: 'warn' }, ) } else { reason = 'model' logForDebugging( `auto mode disabled: model ${getMainLoopModel()} does not support auto mode`, { level: 'warn' }, ) } const notification = getAutoModeUnavailableNotification(reason) // Unified kick-out transform. Re-checks the FRESH ctx and only fires // side effects (setAutoModeActive(false), setNeedsAutoModeExitAttachment) // when the kick-out actually applies. This keeps autoModeActive in sync // with toolPermissionContext.mode even if the user changed modes during // the await: if they already left auto on their own, handleCycleMode // already deactivated the classifier and we don't fire again; if they // ENTERED auto during the await (possible before setAutoModeCircuitBroken // landed), we kick them out here. const kickOutOfAutoIfNeeded = ( ctx: ToolPermissionContext, ): ToolPermissionContext => { const inAuto = ctx.mode === 'auto' logForDebugging( `[auto-mode] kickOutOfAutoIfNeeded applying: ctx.mode=${ctx.mode} ctx.prePlanMode=${ctx.prePlanMode} reason=${reason}`, ) // Plan mode with auto active: either from prePlanMode='auto' (entered // from auto) or from opt-in (strippedDangerousRules present). const inPlanWithAutoActive = ctx.mode === 'plan' && (ctx.prePlanMode === 'auto' || !!ctx.strippedDangerousRules) if (!inAuto && !inPlanWithAutoActive) { return setAvailable(ctx, false) } if (inAuto) { autoModeStateModule?.setAutoModeActive(false) setNeedsAutoModeExitAttachment(true) return { ...applyPermissionUpdate(restoreDangerousPermissions(ctx), { type: 'setMode', mode: 'default', destination: 'session', }), isAutoModeAvailable: false, } } // Plan with auto active: deactivate auto, restore permissions, defuse // prePlanMode so ExitPlanMode goes to default. autoModeStateModule?.setAutoModeActive(false) setNeedsAutoModeExitAttachment(true) return { ...restoreDangerousPermissions(ctx), prePlanMode: ctx.prePlanMode === 'auto' ? 'default' : ctx.prePlanMode, isAutoModeAvailable: false, } } // Notification decisions use the stale context — that's OK: we're deciding // WHETHER to notify based on what the user WAS doing when this check started. // (Side effects and mode mutation are decided inside the transform above, // against the fresh ctx.) const wasInAuto = currentContext.mode === 'auto' // Auto was used during plan: entered from auto or opt-in auto active const autoActiveDuringPlan = currentContext.mode === 'plan' && (currentContext.prePlanMode === 'auto' || !!currentContext.strippedDangerousRules) const wantedAuto = wasInAuto || autoActiveDuringPlan || autoModeFlagCli if (!wantedAuto) { // User didn't want auto at call time — no notification. But still apply // the full kick-out transform: if they shift-tabbed INTO auto during the // await (before setAutoModeCircuitBroken landed), we need to evict them. return { updateContext: kickOutOfAutoIfNeeded } } if (wasInAuto || autoActiveDuringPlan) { // User was in auto or had auto active during plan — kick out + notify. return { updateContext: kickOutOfAutoIfNeeded, notification } } // autoModeFlagCli only: defaultMode was auto but sync check rejected it. // Suppress notification if isAutoModeAvailable is already false (already // notified on a prior check; prevents repeat notifications on successive // unsupported-model switches). return { updateContext: kickOutOfAutoIfNeeded, notification: currentContext.isAutoModeAvailable ? notification : undefined, } } /** * Core logic to check if bypassPermissions should be disabled based on Statsig gate */ export function shouldDisableBypassPermissions(): Promise { return checkSecurityRestrictionGate('tengu_disable_bypass_permissions_mode') } function isAutoModeDisabledBySettings(): boolean { const settings = getSettings_DEPRECATED() || {} return ( (settings as { disableAutoMode?: 'disable' }).disableAutoMode === 'disable' || (settings.permissions as { disableAutoMode?: 'disable' } | undefined) ?.disableAutoMode === 'disable' ) } /** * Checks if auto mode can be entered: circuit breaker is not active and settings * have not disabled it. Synchronous. */ export function isAutoModeGateEnabled(): boolean { if (autoModeStateModule?.isAutoModeCircuitBroken() ?? false) return false if (isAutoModeDisabledBySettings()) return false if (!modelSupportsAutoMode(getMainLoopModel())) return false return true } /** * Returns the reason auto mode is currently unavailable, or null if available. * Synchronous — uses state populated by verifyAutoModeGateAccess. */ export function getAutoModeUnavailableReason(): AutoModeUnavailableReason | null { if (isAutoModeDisabledBySettings()) return 'settings' if (autoModeStateModule?.isAutoModeCircuitBroken() ?? false) { return 'circuit-breaker' } if (!modelSupportsAutoMode(getMainLoopModel())) return 'model' return null } /** * The `enabled` field in the tengu_auto_mode_config GrowthBook JSON config. * Controls auto mode availability in UI surfaces (CLI, IDE, Desktop). * - 'enabled': auto mode is available in the shift-tab carousel (or equivalent) * - 'disabled': auto mode is fully unavailable — circuit breaker for incident response * - 'opt-in': auto mode is available only if the user has explicitly opted in * (via --enable-auto-mode in CLI, or a settings toggle in IDE/Desktop) */ export type AutoModeEnabledState = 'enabled' | 'disabled' | 'opt-in' const AUTO_MODE_ENABLED_DEFAULT: AutoModeEnabledState = 'disabled' function parseAutoModeEnabledState(value: unknown): AutoModeEnabledState { if (value === 'enabled' || value === 'disabled' || value === 'opt-in') { return value } return AUTO_MODE_ENABLED_DEFAULT } /** * Reads the `enabled` field from tengu_auto_mode_config (cached, may be stale). * Defaults to 'disabled' if GrowthBook is unavailable or the field is unset. * Other surfaces (IDE, Desktop) should call this to decide whether to surface * auto mode in their mode pickers. */ export function getAutoModeEnabledState(): AutoModeEnabledState { const config = getFeatureValue_CACHED_MAY_BE_STALE<{ enabled?: AutoModeEnabledState }>('tengu_auto_mode_config', {}) return parseAutoModeEnabledState(config?.enabled) } const NO_CACHED_AUTO_MODE_CONFIG = Symbol('no-cached-auto-mode-config') /** * Like getAutoModeEnabledState but returns undefined when no cached value * exists (cold start, before GrowthBook init). Used by the sync * circuit-breaker check in initialPermissionModeFromCLI, which must not * conflate "not yet fetched" with "fetched and disabled" — the former * defers to verifyAutoModeGateAccess, the latter blocks immediately. */ export function getAutoModeEnabledStateIfCached(): | AutoModeEnabledState | undefined { const config = getFeatureValue_CACHED_MAY_BE_STALE< { enabled?: AutoModeEnabledState } | typeof NO_CACHED_AUTO_MODE_CONFIG >('tengu_auto_mode_config', NO_CACHED_AUTO_MODE_CONFIG) if (config === NO_CACHED_AUTO_MODE_CONFIG) return undefined return parseAutoModeEnabledState(config?.enabled) } /** * Returns true if the user has opted in to auto mode via any trusted mechanism: * - CLI flag (--enable-auto-mode / --permission-mode auto) — session-scoped * availability request; the startup dialog in showSetupScreens enforces * persistent consent before the REPL renders. * - skipAutoPermissionPrompt setting (persistent; set by accepting the opt-in * dialog or by IDE/Desktop settings toggle) */ export function hasAutoModeOptInAnySource(): boolean { if (autoModeStateModule?.getAutoModeFlagCli() ?? false) return true return hasAutoModeOptIn() } /** * Checks if bypassPermissions mode is currently disabled by Statsig gate or settings. * This is a synchronous version that uses cached Statsig values. */ export function isBypassPermissionsModeDisabled(): boolean { const growthBookDisableBypassPermissionsMode = checkStatsigFeatureGate_CACHED_MAY_BE_STALE( 'tengu_disable_bypass_permissions_mode', ) const settings = getSettings_DEPRECATED() || {} const settingsDisableBypassPermissionsMode = settings.permissions?.disableBypassPermissionsMode === 'disable' return ( growthBookDisableBypassPermissionsMode || settingsDisableBypassPermissionsMode ) } /** * Creates an updated context with bypassPermissions disabled */ export function createDisabledBypassPermissionsContext( currentContext: ToolPermissionContext, ): ToolPermissionContext { let updatedContext = currentContext if (currentContext.mode === 'bypassPermissions') { updatedContext = applyPermissionUpdate(currentContext, { type: 'setMode', mode: 'default', destination: 'session', }) } return { ...updatedContext, isBypassPermissionsModeAvailable: false, } } /** * Asynchronously checks if the bypassPermissions mode should be disabled based on Statsig gate * and returns an updated toolPermissionContext if needed */ export async function checkAndDisableBypassPermissions( currentContext: ToolPermissionContext, ): Promise { // Only proceed if bypassPermissions mode is available if (!currentContext.isBypassPermissionsModeAvailable) { return } const shouldDisable = await shouldDisableBypassPermissions() if (!shouldDisable) { return } // Gate is enabled, need to disable bypassPermissions mode logForDebugging( 'bypassPermissions mode is being disabled by Statsig gate (async check)', { level: 'warn' }, ) void gracefulShutdown(1, 'bypass_permissions_disabled') } export function isDefaultPermissionModeAuto(): boolean { if (feature('TRANSCRIPT_CLASSIFIER')) { const settings = getSettings_DEPRECATED() || {} return settings.permissions?.defaultMode === 'auto' } return false } /** * Whether plan mode should use auto mode semantics (classifier runs during * plan). True when the user has opted in to auto mode and the gate is enabled. * Evaluated at permission-check time so it's reactive to config changes. */ export function shouldPlanUseAutoMode(): boolean { if (feature('TRANSCRIPT_CLASSIFIER')) { return ( hasAutoModeOptIn() && isAutoModeGateEnabled() && getUseAutoModeDuringPlan() ) } return false } /** * Centralized plan-mode entry. Stashes the current mode as prePlanMode so * ExitPlanMode can restore it. When the user has opted in to auto mode, * auto semantics stay active during plan mode. */ export function prepareContextForPlanMode( context: ToolPermissionContext, ): ToolPermissionContext { const currentMode = context.mode if (currentMode === 'plan') return context if (feature('TRANSCRIPT_CLASSIFIER')) { const planAutoMode = shouldPlanUseAutoMode() if (currentMode === 'auto') { if (planAutoMode) { return { ...context, prePlanMode: 'auto' } } autoModeStateModule?.setAutoModeActive(false) setNeedsAutoModeExitAttachment(true) return { ...restoreDangerousPermissions(context), prePlanMode: 'auto', } } if (planAutoMode && currentMode !== 'bypassPermissions') { autoModeStateModule?.setAutoModeActive(true) return { ...stripDangerousPermissionsForAutoMode(context), prePlanMode: currentMode, } } } logForDebugging( `[prepareContextForPlanMode] plain plan entry, prePlanMode=${currentMode}`, { level: 'info' }, ) return { ...context, prePlanMode: currentMode } } /** * Reconciles auto-mode state during plan mode after a settings change. * Compares desired state (shouldPlanUseAutoMode) against actual state * (isAutoModeActive) and activates/deactivates auto accordingly. No-op when * not in plan mode. Called from applySettingsChange so that toggling * useAutoModeDuringPlan mid-plan takes effect immediately. */ export function transitionPlanAutoMode( context: ToolPermissionContext, ): ToolPermissionContext { if (!feature('TRANSCRIPT_CLASSIFIER')) return context if (context.mode !== 'plan') return context // Mirror prepareContextForPlanMode's entry-time exclusion — never activate // auto mid-plan when the user entered from a dangerous mode. if (context.prePlanMode === 'bypassPermissions') { return context } const want = shouldPlanUseAutoMode() const have = autoModeStateModule?.isAutoModeActive() ?? false if (want && have) { // syncPermissionRulesFromDisk (called before us in applySettingsChange) // re-adds dangerous rules from disk without touching strippedDangerousRules. // Re-strip so the classifier isn't bypassed by prefix-rule allow matches. return stripDangerousPermissionsForAutoMode(context) } if (!want && !have) return context if (want) { autoModeStateModule?.setAutoModeActive(true) setNeedsAutoModeExitAttachment(false) return stripDangerousPermissionsForAutoMode(context) } autoModeStateModule?.setAutoModeActive(false) setNeedsAutoModeExitAttachment(true) return restoreDangerousPermissions(context) }