π File detail
utils/permissions/permissionSetup.ts
π― Use case
This file lives under βutils/β, which covers cross-cutting helpers (shell, tempfiles, settings, messages, process input, β¦). On the API surface it exposes isDangerousBashPermission, isDangerousPowerShellPermission, isDangerousTaskPermission, DangerousPermissionInfo, and findDangerousClassifierPermissions (and more) β mainly functions, hooks, or classes. Dependencies touch bun:bundle, Node path helpers, and src. It composes internal code from bootstrap, Tool, cwd, envUtils, and settings (relative imports).
Generated from folder role, exports, dependency roots, and inline comments β not hand-reviewed for every path.
π§ Inline summary
import { feature } from 'bun:bundle' import { relative } from 'path' import { getOriginalCwd, handleAutoModeTransition,
π€ Exports (heuristic)
isDangerousBashPermissionisDangerousPowerShellPermissionisDangerousTaskPermissionDangerousPermissionInfofindDangerousClassifierPermissionsisOverlyBroadBashAllowRuleisOverlyBroadPowerShellAllowRulefindOverlyBroadBashPermissionsfindOverlyBroadPowerShellPermissionsremoveDangerousPermissionsstripDangerousPermissionsForAutoModerestoreDangerousPermissionstransitionPermissionModeparseBaseToolsFromCLIinitialPermissionModeFromCLIparseToolListFromCLIinitializeToolPermissionContextAutoModeGateCheckResultAutoModeUnavailableReasongetAutoModeUnavailableNotificationverifyAutoModeGateAccessshouldDisableBypassPermissionsisAutoModeGateEnabledgetAutoModeUnavailableReasonAutoModeEnabledStategetAutoModeEnabledStategetAutoModeEnabledStateIfCachedhasAutoModeOptInAnySourceisBypassPermissionsModeDisabledcreateDisabledBypassPermissionsContextcheckAndDisableBypassPermissionsisDefaultPermissionModeAutoshouldPlanUseAutoModeprepareContextForPlanModetransitionPlanAutoMode
π External import roots
Package roots from from "β¦" (relative paths omitted).
bun:bundlepathsrc
π₯οΈ Source preview
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 '<C#>' β 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<AutoModeGateCheckResult> {
// 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<boolean> {
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<void> {
// 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)
}