1533 lines
52 KiB
TypeScript
1533 lines
52 KiB
TypeScript
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)
|
|
}
|