source dump of claude code
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 1532 lines 53 kB view raw
1import { feature } from 'bun:bundle' 2import { relative } from 'path' 3import { 4 getOriginalCwd, 5 handleAutoModeTransition, 6 handlePlanModeTransition, 7 setHasExitedPlanMode, 8 setNeedsAutoModeExitAttachment, 9} from '../../bootstrap/state.js' 10import type { 11 ToolPermissionContext, 12 ToolPermissionRulesBySource, 13} from '../../Tool.js' 14import { getCwd } from '../cwd.js' 15import { isEnvTruthy } from '../envUtils.js' 16import type { SettingSource } from '../settings/constants.js' 17import { SETTING_SOURCES } from '../settings/constants.js' 18import { 19 getSettings_DEPRECATED, 20 getSettingsFilePathForSource, 21 getUseAutoModeDuringPlan, 22 hasAutoModeOptIn, 23} from '../settings/settings.js' 24import { 25 type PermissionMode, 26 permissionModeFromString, 27} from './PermissionMode.js' 28import { applyPermissionRulesToPermissionContext } from './permissions.js' 29import { loadAllPermissionRulesFromDisk } from './permissionsLoader.js' 30 31/* eslint-disable @typescript-eslint/no-require-imports */ 32const autoModeStateModule = feature('TRANSCRIPT_CLASSIFIER') 33 ? (require('./autoModeState.js') as typeof import('./autoModeState.js')) 34 : null 35 36import { resolve } from 'path' 37import { 38 checkSecurityRestrictionGate, 39 checkStatsigFeatureGate_CACHED_MAY_BE_STALE, 40 getDynamicConfig_BLOCKS_ON_INIT, 41 getFeatureValue_CACHED_MAY_BE_STALE, 42} from 'src/services/analytics/growthbook.js' 43import { 44 addDirHelpMessage, 45 validateDirectoryForWorkspace, 46} from '../../commands/add-dir/validation.js' 47import { 48 type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 49 logEvent, 50} from '../../services/analytics/index.js' 51import { AGENT_TOOL_NAME } from '../../tools/AgentTool/constants.js' 52import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js' 53/* eslint-enable @typescript-eslint/no-require-imports */ 54import { POWERSHELL_TOOL_NAME } from '../../tools/PowerShellTool/toolName.js' 55import { getToolsForDefaultPreset, parseToolPreset } from '../../tools.js' 56import { 57 getFsImplementation, 58 safeResolvePath, 59} from '../../utils/fsOperations.js' 60import { modelSupportsAutoMode } from '../betas.js' 61import { logForDebugging } from '../debug.js' 62import { gracefulShutdown } from '../gracefulShutdown.js' 63import { getMainLoopModel } from '../model/model.js' 64import { 65 CROSS_PLATFORM_CODE_EXEC, 66 DANGEROUS_BASH_PATTERNS, 67} from './dangerousPatterns.js' 68import type { 69 PermissionRule, 70 PermissionRuleSource, 71 PermissionRuleValue, 72} from './PermissionRule.js' 73import { 74 type AdditionalWorkingDirectory, 75 applyPermissionUpdate, 76} from './PermissionUpdate.js' 77import type { PermissionUpdateDestination } from './PermissionUpdateSchema.js' 78import { 79 normalizeLegacyToolName, 80 permissionRuleValueFromString, 81 permissionRuleValueToString, 82} from './permissionRuleParser.js' 83 84/** 85 * Checks if a Bash permission rule is dangerous for auto mode. 86 * A rule is dangerous if it would auto-allow commands that execute arbitrary code, 87 * bypassing the classifier's safety evaluation. 88 * 89 * Dangerous patterns: 90 * 1. Tool-level allow (Bash with no ruleContent) - allows ALL commands 91 * 2. Prefix rules for script interpreters (python:*, node:*, etc.) 92 * 3. Wildcard rules matching interpreters (python*, node*, etc.) 93 */ 94export function isDangerousBashPermission( 95 toolName: string, 96 ruleContent: string | undefined, 97): boolean { 98 // Only check Bash rules 99 if (toolName !== BASH_TOOL_NAME) { 100 return false 101 } 102 103 // Tool-level allow (Bash with no content, or Bash(*)) - allows ALL commands 104 if (ruleContent === undefined || ruleContent === '') { 105 return true 106 } 107 108 const content = ruleContent.trim().toLowerCase() 109 110 // Standalone wildcard (*) matches everything 111 if (content === '*') { 112 return true 113 } 114 115 // Check for dangerous patterns with prefix syntax (e.g., "python:*") 116 // or wildcard syntax (e.g., "python*") 117 for (const pattern of DANGEROUS_BASH_PATTERNS) { 118 const lowerPattern = pattern.toLowerCase() 119 120 // Exact match to the pattern itself (e.g., "python" as a rule) 121 if (content === lowerPattern) { 122 return true 123 } 124 125 // Prefix syntax: "python:*" allows any python command 126 if (content === `${lowerPattern}:*`) { 127 return true 128 } 129 130 // Wildcard at end: "python*" matches python, python3, etc. 131 if (content === `${lowerPattern}*`) { 132 return true 133 } 134 135 // Wildcard with space: "python *" would match "python script.py" 136 if (content === `${lowerPattern} *`) { 137 return true 138 } 139 140 // Check for patterns like "python -*" which would match "python -c 'code'" 141 if (content.startsWith(`${lowerPattern} -`) && content.endsWith('*')) { 142 return true 143 } 144 } 145 146 return false 147} 148 149/** 150 * Checks if a PowerShell permission rule is dangerous for auto mode. 151 * A rule is dangerous if it would auto-allow commands that execute arbitrary 152 * code (nested shells, Invoke-Expression, Start-Process, etc.), bypassing the 153 * classifier's safety evaluation. 154 * 155 * PowerShell is case-insensitive, so rule content is lowercased before matching. 156 */ 157export function isDangerousPowerShellPermission( 158 toolName: string, 159 ruleContent: string | undefined, 160): boolean { 161 if (toolName !== POWERSHELL_TOOL_NAME) { 162 return false 163 } 164 165 // Tool-level allow (PowerShell with no content, or PowerShell(*)) - allows ALL commands 166 if (ruleContent === undefined || ruleContent === '') { 167 return true 168 } 169 170 const content = ruleContent.trim().toLowerCase() 171 172 // Standalone wildcard (*) matches everything 173 if (content === '*') { 174 return true 175 } 176 177 // PS-specific cmdlet names. CROSS_PLATFORM_CODE_EXEC is shared with bash. 178 const patterns: readonly string[] = [ 179 ...CROSS_PLATFORM_CODE_EXEC, 180 // Nested PS + shells launchable from PS 181 'pwsh', 182 'powershell', 183 'cmd', 184 'wsl', 185 // String/scriptblock evaluators 186 'iex', 187 'invoke-expression', 188 'icm', 189 'invoke-command', 190 // Process spawners 191 'start-process', 192 'saps', 193 'start', 194 'start-job', 195 'sajb', 196 'start-threadjob', // bundled PS 6.1+; takes -ScriptBlock like Start-Job 197 // Event/session code exec 198 'register-objectevent', 199 'register-engineevent', 200 'register-wmievent', 201 'register-scheduledjob', 202 'new-pssession', 203 'nsn', // alias 204 'enter-pssession', 205 'etsn', // alias 206 // .NET escape hatches 207 'add-type', // Add-Type -TypeDefinition '<C#>' → P/Invoke 208 'new-object', // New-Object -ComObject WScript.Shell → .Run() 209 ] 210 211 for (const pattern of patterns) { 212 // patterns stored lowercase; content lowercased above 213 if (content === pattern) return true 214 if (content === `${pattern}:*`) return true 215 if (content === `${pattern}*`) return true 216 if (content === `${pattern} *`) return true 217 if (content.startsWith(`${pattern} -`) && content.endsWith('*')) return true 218 // .exe — goes on the FIRST word. `python` → `python.exe`. 219 // `npm run` → `npm.exe run` (npm.exe is the real Windows binary name). 220 // A rule like `PowerShell(npm.exe run:*)` needs to match `npm run`. 221 const sp = pattern.indexOf(' ') 222 const exe = 223 sp === -1 224 ? `${pattern}.exe` 225 : `${pattern.slice(0, sp)}.exe${pattern.slice(sp)}` 226 if (content === exe) return true 227 if (content === `${exe}:*`) return true 228 if (content === `${exe}*`) return true 229 if (content === `${exe} *`) return true 230 if (content.startsWith(`${exe} -`) && content.endsWith('*')) return true 231 } 232 return false 233} 234 235/** 236 * Checks if an Agent (sub-agent) permission rule is dangerous for auto mode. 237 * Any Agent allow rule would auto-approve sub-agent spawns before the auto mode classifier 238 * can evaluate the sub-agent's prompt, defeating delegation attack prevention. 239 */ 240export function isDangerousTaskPermission( 241 toolName: string, 242 _ruleContent: string | undefined, 243): boolean { 244 return normalizeLegacyToolName(toolName) === AGENT_TOOL_NAME 245} 246 247function formatPermissionSource(source: PermissionRuleSource): string { 248 if ((SETTING_SOURCES as readonly string[]).includes(source)) { 249 const filePath = getSettingsFilePathForSource(source as SettingSource) 250 if (filePath) { 251 const relativePath = relative(getCwd(), filePath) 252 return relativePath.length < filePath.length ? relativePath : filePath 253 } 254 } 255 return source 256} 257 258export type DangerousPermissionInfo = { 259 ruleValue: PermissionRuleValue 260 source: PermissionRuleSource 261 /** The permission rule formatted for display, e.g. "Bash(*)" or "Bash(python:*)" */ 262 ruleDisplay: string 263 /** The source formatted for display, e.g. a file path or "--allowed-tools" */ 264 sourceDisplay: string 265} 266 267/** 268 * Checks if a permission rule is dangerous for auto mode. 269 * A rule is dangerous if it would auto-allow actions before the auto mode classifier 270 * can evaluate them, bypassing safety checks. 271 */ 272function isDangerousClassifierPermission( 273 toolName: string, 274 ruleContent: string | undefined, 275): boolean { 276 if (process.env.USER_TYPE === 'ant') { 277 // Tmux send-keys executes arbitrary shell, bypassing the classifier same as Bash(*) 278 if (toolName === 'Tmux') return true 279 } 280 return ( 281 isDangerousBashPermission(toolName, ruleContent) || 282 isDangerousPowerShellPermission(toolName, ruleContent) || 283 isDangerousTaskPermission(toolName, ruleContent) 284 ) 285} 286 287/** 288 * Finds all dangerous permissions from rules loaded from disk and CLI arguments. 289 * Returns structured info about each dangerous permission found. 290 * 291 * Checks Bash permissions (wildcard/interpreter patterns), PowerShell permissions 292 * (wildcard/iex/Start-Process patterns), and Agent permissions (any allow rule 293 * bypasses the classifier's sub-agent evaluation). 294 */ 295export function findDangerousClassifierPermissions( 296 rules: PermissionRule[], 297 cliAllowedTools: string[], 298): DangerousPermissionInfo[] { 299 const dangerous: DangerousPermissionInfo[] = [] 300 301 // Check rules loaded from settings 302 for (const rule of rules) { 303 if ( 304 rule.ruleBehavior === 'allow' && 305 isDangerousClassifierPermission( 306 rule.ruleValue.toolName, 307 rule.ruleValue.ruleContent, 308 ) 309 ) { 310 const ruleString = rule.ruleValue.ruleContent 311 ? `${rule.ruleValue.toolName}(${rule.ruleValue.ruleContent})` 312 : `${rule.ruleValue.toolName}(*)` 313 dangerous.push({ 314 ruleValue: rule.ruleValue, 315 source: rule.source, 316 ruleDisplay: ruleString, 317 sourceDisplay: formatPermissionSource(rule.source), 318 }) 319 } 320 } 321 322 // Check CLI --allowed-tools arguments 323 for (const toolSpec of cliAllowedTools) { 324 // Parse tool spec: "Bash" or "Bash(pattern)" or "Agent" or "Agent(subagent_type)" 325 const match = toolSpec.match(/^([^(]+)(?:\(([^)]*)\))?$/) 326 if (match) { 327 const toolName = match[1]!.trim() 328 const ruleContent = match[2]?.trim() 329 330 if (isDangerousClassifierPermission(toolName, ruleContent)) { 331 dangerous.push({ 332 ruleValue: { toolName, ruleContent }, 333 source: 'cliArg', 334 ruleDisplay: ruleContent ? toolSpec : `${toolName}(*)`, 335 sourceDisplay: '--allowed-tools', 336 }) 337 } 338 } 339 } 340 341 return dangerous 342} 343 344/** 345 * Checks if a Bash allow rule is overly broad (equivalent to YOLO mode). 346 * Returns true for tool-level Bash allow rules with no content restriction, 347 * which auto-allow every bash command. 348 * 349 * Matches: Bash, Bash(*), Bash() — all parse to { toolName: 'Bash' } with no ruleContent. 350 */ 351export function isOverlyBroadBashAllowRule( 352 ruleValue: PermissionRuleValue, 353): boolean { 354 return ( 355 ruleValue.toolName === BASH_TOOL_NAME && ruleValue.ruleContent === undefined 356 ) 357} 358 359/** 360 * PowerShell equivalent of isOverlyBroadBashAllowRule. 361 * 362 * Matches: PowerShell, PowerShell(*), PowerShell() — all parse to 363 * { toolName: 'PowerShell' } with no ruleContent. 364 */ 365export function isOverlyBroadPowerShellAllowRule( 366 ruleValue: PermissionRuleValue, 367): boolean { 368 return ( 369 ruleValue.toolName === POWERSHELL_TOOL_NAME && 370 ruleValue.ruleContent === undefined 371 ) 372} 373 374/** 375 * Finds all overly broad Bash allow rules from settings and CLI arguments. 376 * An overly broad rule allows ALL bash commands (e.g., Bash or Bash(*)), 377 * which is effectively equivalent to YOLO/bypass-permissions mode. 378 */ 379export function findOverlyBroadBashPermissions( 380 rules: PermissionRule[], 381 cliAllowedTools: string[], 382): DangerousPermissionInfo[] { 383 const overlyBroad: DangerousPermissionInfo[] = [] 384 385 for (const rule of rules) { 386 if ( 387 rule.ruleBehavior === 'allow' && 388 isOverlyBroadBashAllowRule(rule.ruleValue) 389 ) { 390 overlyBroad.push({ 391 ruleValue: rule.ruleValue, 392 source: rule.source, 393 ruleDisplay: `${BASH_TOOL_NAME}(*)`, 394 sourceDisplay: formatPermissionSource(rule.source), 395 }) 396 } 397 } 398 399 for (const toolSpec of cliAllowedTools) { 400 const parsed = permissionRuleValueFromString(toolSpec) 401 if (isOverlyBroadBashAllowRule(parsed)) { 402 overlyBroad.push({ 403 ruleValue: parsed, 404 source: 'cliArg', 405 ruleDisplay: `${BASH_TOOL_NAME}(*)`, 406 sourceDisplay: '--allowed-tools', 407 }) 408 } 409 } 410 411 return overlyBroad 412} 413 414/** 415 * PowerShell equivalent of findOverlyBroadBashPermissions. 416 */ 417export function findOverlyBroadPowerShellPermissions( 418 rules: PermissionRule[], 419 cliAllowedTools: string[], 420): DangerousPermissionInfo[] { 421 const overlyBroad: DangerousPermissionInfo[] = [] 422 423 for (const rule of rules) { 424 if ( 425 rule.ruleBehavior === 'allow' && 426 isOverlyBroadPowerShellAllowRule(rule.ruleValue) 427 ) { 428 overlyBroad.push({ 429 ruleValue: rule.ruleValue, 430 source: rule.source, 431 ruleDisplay: `${POWERSHELL_TOOL_NAME}(*)`, 432 sourceDisplay: formatPermissionSource(rule.source), 433 }) 434 } 435 } 436 437 for (const toolSpec of cliAllowedTools) { 438 const parsed = permissionRuleValueFromString(toolSpec) 439 if (isOverlyBroadPowerShellAllowRule(parsed)) { 440 overlyBroad.push({ 441 ruleValue: parsed, 442 source: 'cliArg', 443 ruleDisplay: `${POWERSHELL_TOOL_NAME}(*)`, 444 sourceDisplay: '--allowed-tools', 445 }) 446 } 447 } 448 449 return overlyBroad 450} 451 452/** 453 * Type guard to check if a PermissionRuleSource is a valid PermissionUpdateDestination. 454 * Sources like 'flagSettings', 'policySettings', and 'command' are not valid destinations. 455 */ 456function isPermissionUpdateDestination( 457 source: PermissionRuleSource, 458): source is PermissionUpdateDestination { 459 return [ 460 'userSettings', 461 'projectSettings', 462 'localSettings', 463 'session', 464 'cliArg', 465 ].includes(source) 466} 467 468/** 469 * Removes dangerous permissions from the in-memory context, and optionally 470 * persists the removal to settings files on disk. 471 */ 472export function removeDangerousPermissions( 473 context: ToolPermissionContext, 474 dangerousPermissions: DangerousPermissionInfo[], 475): ToolPermissionContext { 476 // Group dangerous rules by their source (destination for updates) 477 const rulesBySource = new Map< 478 PermissionUpdateDestination, 479 PermissionRuleValue[] 480 >() 481 for (const perm of dangerousPermissions) { 482 // Skip sources that can't be persisted (flagSettings, policySettings, command) 483 if (!isPermissionUpdateDestination(perm.source)) { 484 continue 485 } 486 const destination = perm.source 487 const existing = rulesBySource.get(destination) || [] 488 existing.push(perm.ruleValue) 489 rulesBySource.set(destination, existing) 490 } 491 492 let updatedContext = context 493 for (const [destination, rules] of rulesBySource) { 494 updatedContext = applyPermissionUpdate(updatedContext, { 495 type: 'removeRules' as const, 496 rules, 497 behavior: 'allow' as const, 498 destination, 499 }) 500 } 501 502 return updatedContext 503} 504 505/** 506 * Prepares a ToolPermissionContext for auto mode by stripping 507 * dangerous permissions that would bypass the classifier. 508 * Returns the cleaned context (with mode unchanged — caller sets the mode). 509 */ 510export function stripDangerousPermissionsForAutoMode( 511 context: ToolPermissionContext, 512): ToolPermissionContext { 513 const rules: PermissionRule[] = [] 514 for (const [source, ruleStrings] of Object.entries( 515 context.alwaysAllowRules, 516 )) { 517 if (!ruleStrings) { 518 continue 519 } 520 for (const ruleString of ruleStrings) { 521 const ruleValue = permissionRuleValueFromString(ruleString) 522 rules.push({ 523 source: source as PermissionRuleSource, 524 ruleBehavior: 'allow', 525 ruleValue, 526 }) 527 } 528 } 529 const dangerousPermissions = findDangerousClassifierPermissions(rules, []) 530 if (dangerousPermissions.length === 0) { 531 return { 532 ...context, 533 strippedDangerousRules: context.strippedDangerousRules ?? {}, 534 } 535 } 536 for (const permission of dangerousPermissions) { 537 logForDebugging( 538 `Ignoring dangerous permission ${permission.ruleDisplay} from ${permission.sourceDisplay} (bypasses classifier)`, 539 ) 540 } 541 // Mirror removeDangerousPermissions' source filter so stash == what was actually removed. 542 const stripped: ToolPermissionRulesBySource = {} 543 for (const perm of dangerousPermissions) { 544 if (!isPermissionUpdateDestination(perm.source)) continue 545 ;(stripped[perm.source] ??= []).push( 546 permissionRuleValueToString(perm.ruleValue), 547 ) 548 } 549 return { 550 ...removeDangerousPermissions(context, dangerousPermissions), 551 strippedDangerousRules: stripped, 552 } 553} 554 555/** 556 * Restores dangerous allow rules previously stashed by 557 * stripDangerousPermissionsForAutoMode. Called when leaving auto mode so that 558 * the user's Bash(python:*), Agent(*), etc. rules work again in default mode. 559 * Clears the stash so a second exit is a no-op. 560 */ 561export function restoreDangerousPermissions( 562 context: ToolPermissionContext, 563): ToolPermissionContext { 564 const stash = context.strippedDangerousRules 565 if (!stash) { 566 return context 567 } 568 let result = context 569 for (const [source, ruleStrings] of Object.entries(stash)) { 570 if (!ruleStrings || ruleStrings.length === 0) continue 571 result = applyPermissionUpdate(result, { 572 type: 'addRules', 573 rules: ruleStrings.map(permissionRuleValueFromString), 574 behavior: 'allow', 575 destination: source as PermissionUpdateDestination, 576 }) 577 } 578 return { ...result, strippedDangerousRules: undefined } 579} 580 581/** 582 * Handles all state transitions when switching permission modes. 583 * Centralises side-effects so that every activation path (CLI Shift+Tab, 584 * SDK control messages, etc.) behaves identically. 585 * 586 * Currently handles: 587 * - Plan mode enter/exit attachments (via handlePlanModeTransition) 588 * - Auto mode activation: setAutoModeActive, stripDangerousPermissionsForAutoMode 589 * 590 * Returns the (possibly modified) context. Caller is responsible for setting 591 * the mode on the returned context. 592 * 593 * @param fromMode The current permission mode 594 * @param toMode The target permission mode 595 * @param context The current tool permission context 596 */ 597export function transitionPermissionMode( 598 fromMode: string, 599 toMode: string, 600 context: ToolPermissionContext, 601): ToolPermissionContext { 602 // plan→plan (SDK set_permission_mode) would wrongly hit the leave branch below 603 if (fromMode === toMode) return context 604 605 handlePlanModeTransition(fromMode, toMode) 606 handleAutoModeTransition(fromMode, toMode) 607 608 if (fromMode === 'plan' && toMode !== 'plan') { 609 setHasExitedPlanMode(true) 610 } 611 612 if (feature('TRANSCRIPT_CLASSIFIER')) { 613 if (toMode === 'plan' && fromMode !== 'plan') { 614 return prepareContextForPlanMode(context) 615 } 616 617 // Plan with auto active counts as using the classifier (for the leaving side). 618 // isAutoModeActive() is the authoritative signal — prePlanMode/strippedDangerousRules 619 // are unreliable proxies because auto can be deactivated mid-plan (non-opt-in 620 // entry, transitionPlanAutoMode) while those fields remain set/unset. 621 const fromUsesClassifier = 622 fromMode === 'auto' || 623 (fromMode === 'plan' && 624 (autoModeStateModule?.isAutoModeActive() ?? false)) 625 const toUsesClassifier = toMode === 'auto' // plan entry handled above 626 627 if (toUsesClassifier && !fromUsesClassifier) { 628 if (!isAutoModeGateEnabled()) { 629 throw new Error('Cannot transition to auto mode: gate is not enabled') 630 } 631 autoModeStateModule?.setAutoModeActive(true) 632 context = stripDangerousPermissionsForAutoMode(context) 633 } else if (fromUsesClassifier && !toUsesClassifier) { 634 autoModeStateModule?.setAutoModeActive(false) 635 setNeedsAutoModeExitAttachment(true) 636 context = restoreDangerousPermissions(context) 637 } 638 } 639 640 // Only spread if there's something to clear (preserves ref equality) 641 if (fromMode === 'plan' && toMode !== 'plan' && context.prePlanMode) { 642 return { ...context, prePlanMode: undefined } 643 } 644 645 return context 646} 647 648/** 649 * Parse base tools specification from CLI 650 * Handles both preset names (default, none) and custom tool lists 651 */ 652export function parseBaseToolsFromCLI(baseTools: string[]): string[] { 653 // Join all array elements and check if it's a single preset name 654 const joinedInput = baseTools.join(' ').trim() 655 const preset = parseToolPreset(joinedInput) 656 657 if (preset) { 658 return getToolsForDefaultPreset() 659 } 660 661 // Parse as a custom tool list using the same parsing logic as allowedTools/disallowedTools 662 const parsedTools = parseToolListFromCLI(baseTools) 663 664 return parsedTools 665} 666 667/** 668 * Check if processPwd is a symlink that resolves to originalCwd 669 */ 670function isSymlinkTo({ 671 processPwd, 672 originalCwd, 673}: { 674 processPwd: string 675 originalCwd: string 676}): boolean { 677 // Use safeResolvePath to check if processPwd is a symlink and get its resolved path 678 const { resolvedPath: resolvedProcessPwd, isSymlink: isProcessPwdSymlink } = 679 safeResolvePath(getFsImplementation(), processPwd) 680 681 return isProcessPwdSymlink 682 ? resolvedProcessPwd === resolve(originalCwd) 683 : false 684} 685 686/** 687 * Safely convert CLI flags to a PermissionMode 688 */ 689export function initialPermissionModeFromCLI({ 690 permissionModeCli, 691 dangerouslySkipPermissions, 692}: { 693 permissionModeCli: string | undefined 694 dangerouslySkipPermissions: boolean | undefined 695}): { mode: PermissionMode; notification?: string } { 696 const settings = getSettings_DEPRECATED() || {} 697 698 // Check GrowthBook gate first - highest precedence 699 const growthBookDisableBypassPermissionsMode = 700 checkStatsigFeatureGate_CACHED_MAY_BE_STALE( 701 'tengu_disable_bypass_permissions_mode', 702 ) 703 704 // Then check settings - lower precedence 705 const settingsDisableBypassPermissionsMode = 706 settings.permissions?.disableBypassPermissionsMode === 'disable' 707 708 // Statsig gate takes precedence over settings 709 const disableBypassPermissionsMode = 710 growthBookDisableBypassPermissionsMode || 711 settingsDisableBypassPermissionsMode 712 713 // Sync circuit-breaker check (cached GB read). Prevents the 714 // AutoModeOptInDialog from showing in showSetupScreens() when auto can't 715 // actually be entered. autoModeFlagCli still carries intent through to 716 // verifyAutoModeGateAccess, which notifies the user why. 717 const autoModeCircuitBrokenSync = feature('TRANSCRIPT_CLASSIFIER') 718 ? getAutoModeEnabledStateIfCached() === 'disabled' 719 : false 720 721 // Modes in order of priority 722 const orderedModes: PermissionMode[] = [] 723 let notification: string | undefined 724 725 if (dangerouslySkipPermissions) { 726 orderedModes.push('bypassPermissions') 727 } 728 if (permissionModeCli) { 729 const parsedMode = permissionModeFromString(permissionModeCli) 730 if (feature('TRANSCRIPT_CLASSIFIER') && parsedMode === 'auto') { 731 if (autoModeCircuitBrokenSync) { 732 logForDebugging( 733 'auto mode circuit breaker active (cached) — falling back to default', 734 { level: 'warn' }, 735 ) 736 } else { 737 orderedModes.push('auto') 738 } 739 } else { 740 orderedModes.push(parsedMode) 741 } 742 } 743 if (settings.permissions?.defaultMode) { 744 const settingsMode = settings.permissions.defaultMode as PermissionMode 745 // CCR only supports acceptEdits and plan — ignore other defaultModes from 746 // settings (e.g. bypassPermissions would otherwise silently grant full 747 // access in a remote environment). 748 if ( 749 isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) && 750 !['acceptEdits', 'plan', 'default'].includes(settingsMode) 751 ) { 752 logForDebugging( 753 `settings defaultMode "${settingsMode}" is not supported in CLAUDE_CODE_REMOTE — only acceptEdits and plan are allowed`, 754 { level: 'warn' }, 755 ) 756 logEvent('tengu_ccr_unsupported_default_mode_ignored', { 757 mode: settingsMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 758 }) 759 } 760 // auto from settings requires the same gate check as from CLI 761 else if (feature('TRANSCRIPT_CLASSIFIER') && settingsMode === 'auto') { 762 if (autoModeCircuitBrokenSync) { 763 logForDebugging( 764 'auto mode circuit breaker active (cached) — falling back to default', 765 { level: 'warn' }, 766 ) 767 } else { 768 orderedModes.push('auto') 769 } 770 } else { 771 orderedModes.push(settingsMode) 772 } 773 } 774 775 let result: { mode: PermissionMode; notification?: string } | undefined 776 777 for (const mode of orderedModes) { 778 if (mode === 'bypassPermissions' && disableBypassPermissionsMode) { 779 if (growthBookDisableBypassPermissionsMode) { 780 logForDebugging('bypassPermissions mode is disabled by Statsig gate', { 781 level: 'warn', 782 }) 783 notification = 784 'Bypass permissions mode was disabled by your organization policy' 785 } else { 786 logForDebugging('bypassPermissions mode is disabled by settings', { 787 level: 'warn', 788 }) 789 notification = 'Bypass permissions mode was disabled by settings' 790 } 791 continue // Skip this mode if it's disabled 792 } 793 794 result = { mode, notification } // Use the first valid mode 795 break 796 } 797 798 if (!result) { 799 result = { mode: 'default', notification } 800 } 801 802 if (!result) { 803 result = { mode: 'default', notification } 804 } 805 806 if (feature('TRANSCRIPT_CLASSIFIER') && result.mode === 'auto') { 807 autoModeStateModule?.setAutoModeActive(true) 808 } 809 810 return result 811} 812 813export function parseToolListFromCLI(tools: string[]): string[] { 814 if (tools.length === 0) { 815 return [] 816 } 817 818 const result: string[] = [] 819 820 // Process each string in the array 821 for (const toolString of tools) { 822 if (!toolString) continue 823 824 let current = '' 825 let isInParens = false 826 827 // Parse each character in the string 828 for (const char of toolString) { 829 switch (char) { 830 case '(': 831 isInParens = true 832 current += char 833 break 834 case ')': 835 isInParens = false 836 current += char 837 break 838 case ',': 839 if (isInParens) { 840 current += char 841 } else { 842 // Comma separator - push current tool and start new one 843 if (current.trim()) { 844 result.push(current.trim()) 845 } 846 current = '' 847 } 848 break 849 case ' ': 850 if (isInParens) { 851 current += char 852 } else if (current.trim()) { 853 // Space separator - push current tool and start new one 854 result.push(current.trim()) 855 current = '' 856 } 857 break 858 default: 859 current += char 860 } 861 } 862 863 // Push any remaining tool 864 if (current.trim()) { 865 result.push(current.trim()) 866 } 867 } 868 869 return result 870} 871 872export async function initializeToolPermissionContext({ 873 allowedToolsCli, 874 disallowedToolsCli, 875 baseToolsCli, 876 permissionMode, 877 allowDangerouslySkipPermissions, 878 addDirs, 879}: { 880 allowedToolsCli: string[] 881 disallowedToolsCli: string[] 882 baseToolsCli?: string[] 883 permissionMode: PermissionMode 884 allowDangerouslySkipPermissions: boolean 885 addDirs: string[] 886}): Promise<{ 887 toolPermissionContext: ToolPermissionContext 888 warnings: string[] 889 dangerousPermissions: DangerousPermissionInfo[] 890 overlyBroadBashPermissions: DangerousPermissionInfo[] 891}> { 892 // Parse comma-separated allowed and disallowed tools if provided 893 // Normalize legacy tool names (e.g., 'Task' → 'Agent') so that in-memory 894 // rule removal in stripDangerousPermissionsForAutoMode matches correctly. 895 const parsedAllowedToolsCli = parseToolListFromCLI(allowedToolsCli).map( 896 rule => permissionRuleValueToString(permissionRuleValueFromString(rule)), 897 ) 898 let parsedDisallowedToolsCli = parseToolListFromCLI(disallowedToolsCli) 899 900 // If base tools are specified, automatically deny all tools NOT in the base set 901 // We need to check if base tools were explicitly provided (not just empty default) 902 if (baseToolsCli && baseToolsCli.length > 0) { 903 const baseToolsResult = parseBaseToolsFromCLI(baseToolsCli) 904 // Normalize legacy tool names (e.g., 'Task' → 'Agent') so user-provided 905 // base tool lists using old names still match canonical names. 906 const baseToolsSet = new Set(baseToolsResult.map(normalizeLegacyToolName)) 907 const allToolNames = getToolsForDefaultPreset() 908 const toolsToDisallow = allToolNames.filter(tool => !baseToolsSet.has(tool)) 909 parsedDisallowedToolsCli = [...parsedDisallowedToolsCli, ...toolsToDisallow] 910 } 911 912 const warnings: string[] = [] 913 const additionalWorkingDirectories = new Map< 914 string, 915 AdditionalWorkingDirectory 916 >() 917 // process.env.PWD may be a symlink, while getOriginalCwd() uses the real path 918 const processPwd = process.env.PWD 919 if ( 920 processPwd && 921 processPwd !== getOriginalCwd() && 922 isSymlinkTo({ originalCwd: getOriginalCwd(), processPwd }) 923 ) { 924 additionalWorkingDirectories.set(processPwd, { 925 path: processPwd, 926 source: 'session', 927 }) 928 } 929 930 // Check if bypassPermissions mode is available (not disabled by Statsig gate or settings) 931 // Use cached values to avoid blocking on startup 932 const growthBookDisableBypassPermissionsMode = 933 checkStatsigFeatureGate_CACHED_MAY_BE_STALE( 934 'tengu_disable_bypass_permissions_mode', 935 ) 936 const settings = getSettings_DEPRECATED() || {} 937 const settingsDisableBypassPermissionsMode = 938 settings.permissions?.disableBypassPermissionsMode === 'disable' 939 const isBypassPermissionsModeAvailable = 940 (permissionMode === 'bypassPermissions' || 941 allowDangerouslySkipPermissions) && 942 !growthBookDisableBypassPermissionsMode && 943 !settingsDisableBypassPermissionsMode 944 945 // Load all permission rules from disk 946 const rulesFromDisk = loadAllPermissionRulesFromDisk() 947 948 // Ant-only: Detect overly broad shell allow rules for all modes. 949 // Bash(*) or PowerShell(*) are equivalent to YOLO mode for that shell. 950 // Skip in CCR/BYOC where --allowed-tools is the intended pre-approval mechanism. 951 // Variable name kept for return-field compat; contains both shells. 952 let overlyBroadBashPermissions: DangerousPermissionInfo[] = [] 953 if ( 954 process.env.USER_TYPE === 'ant' && 955 !isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) && 956 process.env.CLAUDE_CODE_ENTRYPOINT !== 'local-agent' 957 ) { 958 overlyBroadBashPermissions = [ 959 ...findOverlyBroadBashPermissions(rulesFromDisk, parsedAllowedToolsCli), 960 ...findOverlyBroadPowerShellPermissions( 961 rulesFromDisk, 962 parsedAllowedToolsCli, 963 ), 964 ] 965 } 966 967 // Ant-only: Detect dangerous shell permissions for auto mode 968 // Dangerous permissions (like Bash(*), Bash(python:*), PowerShell(iex:*)) would auto-allow 969 // before the classifier can evaluate them, defeating the purpose of safer YOLO mode 970 let dangerousPermissions: DangerousPermissionInfo[] = [] 971 if (feature('TRANSCRIPT_CLASSIFIER') && permissionMode === 'auto') { 972 dangerousPermissions = findDangerousClassifierPermissions( 973 rulesFromDisk, 974 parsedAllowedToolsCli, 975 ) 976 } 977 978 let toolPermissionContext = applyPermissionRulesToPermissionContext( 979 { 980 mode: permissionMode, 981 additionalWorkingDirectories, 982 alwaysAllowRules: { cliArg: parsedAllowedToolsCli }, 983 alwaysDenyRules: { cliArg: parsedDisallowedToolsCli }, 984 alwaysAskRules: {}, 985 isBypassPermissionsModeAvailable, 986 ...(feature('TRANSCRIPT_CLASSIFIER') 987 ? { isAutoModeAvailable: isAutoModeGateEnabled() } 988 : {}), 989 }, 990 rulesFromDisk, 991 ) 992 993 // Add directories from settings and --add-dir 994 const allAdditionalDirectories = [ 995 ...(settings.permissions?.additionalDirectories || []), 996 ...addDirs, 997 ] 998 // Parallelize fs validation; apply updates serially (cumulative context). 999 // validateDirectoryForWorkspace only reads permissionContext to check if the 1000 // dir is already covered — behavioral difference from parallelizing is benign 1001 // (two overlapping --add-dirs both succeed instead of one being flagged 1002 // alreadyInWorkingDirectory, which was silently skipped anyway). 1003 const validationResults = await Promise.all( 1004 allAdditionalDirectories.map(dir => 1005 validateDirectoryForWorkspace(dir, toolPermissionContext), 1006 ), 1007 ) 1008 for (const result of validationResults) { 1009 if (result.resultType === 'success') { 1010 toolPermissionContext = applyPermissionUpdate(toolPermissionContext, { 1011 type: 'addDirectories', 1012 directories: [result.absolutePath], 1013 destination: 'cliArg', 1014 }) 1015 } else if ( 1016 result.resultType !== 'alreadyInWorkingDirectory' && 1017 result.resultType !== 'pathNotFound' 1018 ) { 1019 // Warn for actual config mistakes (e.g. specifying a file instead of a 1020 // directory). But if the directory doesn't exist anymore (e.g. someone 1021 // was working under /tmp and it got cleared), silently skip. They'll get 1022 // prompted again if they try to access it later. 1023 warnings.push(addDirHelpMessage(result)) 1024 } 1025 } 1026 1027 return { 1028 toolPermissionContext, 1029 warnings, 1030 dangerousPermissions, 1031 overlyBroadBashPermissions, 1032 } 1033} 1034 1035export type AutoModeGateCheckResult = { 1036 // Transform function (not a pre-computed context) so callers can apply it 1037 // inside setAppState(prev => ...) against the CURRENT context. Pre-computing 1038 // the context here captured a stale snapshot: the async GrowthBook await 1039 // below can be outrun by a mid-turn shift-tab, and returning 1040 // { ...currentContext, ... } would overwrite the user's mode change. 1041 updateContext: (ctx: ToolPermissionContext) => ToolPermissionContext 1042 notification?: string 1043} 1044 1045export type AutoModeUnavailableReason = 'settings' | 'circuit-breaker' | 'model' 1046 1047export function getAutoModeUnavailableNotification( 1048 reason: AutoModeUnavailableReason, 1049): string { 1050 let base: string 1051 switch (reason) { 1052 case 'settings': 1053 base = 'auto mode disabled by settings' 1054 break 1055 case 'circuit-breaker': 1056 base = 'auto mode is unavailable for your plan' 1057 break 1058 case 'model': 1059 base = 'auto mode unavailable for this model' 1060 break 1061 } 1062 return process.env.USER_TYPE === 'ant' 1063 ? `${base} · #claude-code-feedback` 1064 : base 1065} 1066 1067/** 1068 * Async check of auto mode availability. 1069 * 1070 * Returns a transform function (not a pre-computed context) that callers 1071 * apply inside setAppState(prev => ...) against the CURRENT context. This 1072 * prevents the async GrowthBook await from clobbering mid-turn mode changes 1073 * (e.g., user shift-tabs to acceptEdits while this check is in flight). 1074 * 1075 * The transform re-checks mode/prePlanMode against the fresh ctx to avoid 1076 * kicking the user out of a mode they've already left during the await. 1077 */ 1078export async function verifyAutoModeGateAccess( 1079 currentContext: ToolPermissionContext, 1080 // Runtime AppState.fastMode — passed from callers with AppState access so 1081 // the disableFastMode circuit breaker reads current state, not stale 1082 // settings.fastMode (which is intentionally sticky across /model auto- 1083 // downgrades). Optional for callers without AppState (e.g. SDK init paths). 1084 fastMode?: boolean, 1085): Promise<AutoModeGateCheckResult> { 1086 // Auto-mode config — runs in ALL builds (circuit breaker, carousel, kick-out) 1087 // Fresh read of tengu_auto_mode_config.enabled — this async check runs once 1088 // after GrowthBook initialization and is the authoritative source for 1089 // isAutoModeAvailable. The sync startup path uses stale cache; this 1090 // corrects it. Circuit breaker (enabled==='disabled') takes effect here. 1091 const autoModeConfig = await getDynamicConfig_BLOCKS_ON_INIT<{ 1092 enabled?: AutoModeEnabledState 1093 disableFastMode?: boolean 1094 }>('tengu_auto_mode_config', {}) 1095 const enabledState = parseAutoModeEnabledState(autoModeConfig?.enabled) 1096 const disabledBySettings = isAutoModeDisabledBySettings() 1097 // Treat settings-disable the same as GrowthBook 'disabled' for circuit-breaker 1098 // semantics — blocks SDK/explicit re-entry via isAutoModeGateEnabled(). 1099 autoModeStateModule?.setAutoModeCircuitBroken( 1100 enabledState === 'disabled' || disabledBySettings, 1101 ) 1102 1103 // Carousel availability: not circuit-broken, not disabled-by-settings, 1104 // model supports it, disableFastMode breaker not firing, and (enabled or opted-in) 1105 const mainModel = getMainLoopModel() 1106 // Temp circuit breaker: tengu_auto_mode_config.disableFastMode blocks auto 1107 // mode when fast mode is on. Checks runtime AppState.fastMode (if provided) 1108 // and, for ants, model name '-fast' substring (ant-internal fast models 1109 // like capybara-v2-fast[1m] encode speed in the model ID itself). 1110 // Remove once auto+fast mode interaction is validated. 1111 const disableFastModeBreakerFires = 1112 !!autoModeConfig?.disableFastMode && 1113 (!!fastMode || 1114 (process.env.USER_TYPE === 'ant' && 1115 mainModel.toLowerCase().includes('-fast'))) 1116 const modelSupported = 1117 modelSupportsAutoMode(mainModel) && !disableFastModeBreakerFires 1118 let carouselAvailable = false 1119 if (enabledState !== 'disabled' && !disabledBySettings && modelSupported) { 1120 carouselAvailable = 1121 enabledState === 'enabled' || hasAutoModeOptInAnySource() 1122 } 1123 // canEnterAuto gates explicit entry (--permission-mode auto, defaultMode: auto) 1124 // — explicit entry IS an opt-in, so we only block on circuit breaker + settings + model 1125 const canEnterAuto = 1126 enabledState !== 'disabled' && !disabledBySettings && modelSupported 1127 logForDebugging( 1128 `[auto-mode] verifyAutoModeGateAccess: enabledState=${enabledState} disabledBySettings=${disabledBySettings} model=${mainModel} modelSupported=${modelSupported} disableFastModeBreakerFires=${disableFastModeBreakerFires} carouselAvailable=${carouselAvailable} canEnterAuto=${canEnterAuto}`, 1129 ) 1130 1131 // Capture CLI-flag intent now (doesn't depend on context). 1132 const autoModeFlagCli = autoModeStateModule?.getAutoModeFlagCli() ?? false 1133 1134 // Return a transform function that re-evaluates context-dependent conditions 1135 // against the CURRENT context at setAppState time. The async GrowthBook 1136 // results above (canEnterAuto, carouselAvailable, enabledState, reason) are 1137 // closure-captured — those don't depend on context. But mode, prePlanMode, 1138 // and isAutoModeAvailable checks MUST use the fresh ctx or a mid-await 1139 // shift-tab gets reverted (or worse, the user stays in auto despite the 1140 // circuit breaker if they entered auto DURING the await — which is possible 1141 // because setAutoModeCircuitBroken above runs AFTER the await). 1142 const setAvailable = ( 1143 ctx: ToolPermissionContext, 1144 available: boolean, 1145 ): ToolPermissionContext => { 1146 if (ctx.isAutoModeAvailable !== available) { 1147 logForDebugging( 1148 `[auto-mode] verifyAutoModeGateAccess setAvailable: ${ctx.isAutoModeAvailable} -> ${available}`, 1149 ) 1150 } 1151 return ctx.isAutoModeAvailable === available 1152 ? ctx 1153 : { ...ctx, isAutoModeAvailable: available } 1154 } 1155 1156 if (canEnterAuto) { 1157 return { updateContext: ctx => setAvailable(ctx, carouselAvailable) } 1158 } 1159 1160 // Gate is off or circuit-broken — determine reason (context-independent). 1161 let reason: AutoModeUnavailableReason 1162 if (disabledBySettings) { 1163 reason = 'settings' 1164 logForDebugging('auto mode disabled: disableAutoMode in settings', { 1165 level: 'warn', 1166 }) 1167 } else if (enabledState === 'disabled') { 1168 reason = 'circuit-breaker' 1169 logForDebugging( 1170 'auto mode disabled: tengu_auto_mode_config.enabled === "disabled" (circuit breaker)', 1171 { level: 'warn' }, 1172 ) 1173 } else { 1174 reason = 'model' 1175 logForDebugging( 1176 `auto mode disabled: model ${getMainLoopModel()} does not support auto mode`, 1177 { level: 'warn' }, 1178 ) 1179 } 1180 const notification = getAutoModeUnavailableNotification(reason) 1181 1182 // Unified kick-out transform. Re-checks the FRESH ctx and only fires 1183 // side effects (setAutoModeActive(false), setNeedsAutoModeExitAttachment) 1184 // when the kick-out actually applies. This keeps autoModeActive in sync 1185 // with toolPermissionContext.mode even if the user changed modes during 1186 // the await: if they already left auto on their own, handleCycleMode 1187 // already deactivated the classifier and we don't fire again; if they 1188 // ENTERED auto during the await (possible before setAutoModeCircuitBroken 1189 // landed), we kick them out here. 1190 const kickOutOfAutoIfNeeded = ( 1191 ctx: ToolPermissionContext, 1192 ): ToolPermissionContext => { 1193 const inAuto = ctx.mode === 'auto' 1194 logForDebugging( 1195 `[auto-mode] kickOutOfAutoIfNeeded applying: ctx.mode=${ctx.mode} ctx.prePlanMode=${ctx.prePlanMode} reason=${reason}`, 1196 ) 1197 // Plan mode with auto active: either from prePlanMode='auto' (entered 1198 // from auto) or from opt-in (strippedDangerousRules present). 1199 const inPlanWithAutoActive = 1200 ctx.mode === 'plan' && 1201 (ctx.prePlanMode === 'auto' || !!ctx.strippedDangerousRules) 1202 if (!inAuto && !inPlanWithAutoActive) { 1203 return setAvailable(ctx, false) 1204 } 1205 if (inAuto) { 1206 autoModeStateModule?.setAutoModeActive(false) 1207 setNeedsAutoModeExitAttachment(true) 1208 return { 1209 ...applyPermissionUpdate(restoreDangerousPermissions(ctx), { 1210 type: 'setMode', 1211 mode: 'default', 1212 destination: 'session', 1213 }), 1214 isAutoModeAvailable: false, 1215 } 1216 } 1217 // Plan with auto active: deactivate auto, restore permissions, defuse 1218 // prePlanMode so ExitPlanMode goes to default. 1219 autoModeStateModule?.setAutoModeActive(false) 1220 setNeedsAutoModeExitAttachment(true) 1221 return { 1222 ...restoreDangerousPermissions(ctx), 1223 prePlanMode: ctx.prePlanMode === 'auto' ? 'default' : ctx.prePlanMode, 1224 isAutoModeAvailable: false, 1225 } 1226 } 1227 1228 // Notification decisions use the stale context — that's OK: we're deciding 1229 // WHETHER to notify based on what the user WAS doing when this check started. 1230 // (Side effects and mode mutation are decided inside the transform above, 1231 // against the fresh ctx.) 1232 const wasInAuto = currentContext.mode === 'auto' 1233 // Auto was used during plan: entered from auto or opt-in auto active 1234 const autoActiveDuringPlan = 1235 currentContext.mode === 'plan' && 1236 (currentContext.prePlanMode === 'auto' || 1237 !!currentContext.strippedDangerousRules) 1238 const wantedAuto = wasInAuto || autoActiveDuringPlan || autoModeFlagCli 1239 1240 if (!wantedAuto) { 1241 // User didn't want auto at call time — no notification. But still apply 1242 // the full kick-out transform: if they shift-tabbed INTO auto during the 1243 // await (before setAutoModeCircuitBroken landed), we need to evict them. 1244 return { updateContext: kickOutOfAutoIfNeeded } 1245 } 1246 1247 if (wasInAuto || autoActiveDuringPlan) { 1248 // User was in auto or had auto active during plan — kick out + notify. 1249 return { updateContext: kickOutOfAutoIfNeeded, notification } 1250 } 1251 1252 // autoModeFlagCli only: defaultMode was auto but sync check rejected it. 1253 // Suppress notification if isAutoModeAvailable is already false (already 1254 // notified on a prior check; prevents repeat notifications on successive 1255 // unsupported-model switches). 1256 return { 1257 updateContext: kickOutOfAutoIfNeeded, 1258 notification: currentContext.isAutoModeAvailable ? notification : undefined, 1259 } 1260} 1261 1262/** 1263 * Core logic to check if bypassPermissions should be disabled based on Statsig gate 1264 */ 1265export function shouldDisableBypassPermissions(): Promise<boolean> { 1266 return checkSecurityRestrictionGate('tengu_disable_bypass_permissions_mode') 1267} 1268 1269function isAutoModeDisabledBySettings(): boolean { 1270 const settings = getSettings_DEPRECATED() || {} 1271 return ( 1272 (settings as { disableAutoMode?: 'disable' }).disableAutoMode === 1273 'disable' || 1274 (settings.permissions as { disableAutoMode?: 'disable' } | undefined) 1275 ?.disableAutoMode === 'disable' 1276 ) 1277} 1278 1279/** 1280 * Checks if auto mode can be entered: circuit breaker is not active and settings 1281 * have not disabled it. Synchronous. 1282 */ 1283export function isAutoModeGateEnabled(): boolean { 1284 if (autoModeStateModule?.isAutoModeCircuitBroken() ?? false) return false 1285 if (isAutoModeDisabledBySettings()) return false 1286 if (!modelSupportsAutoMode(getMainLoopModel())) return false 1287 return true 1288} 1289 1290/** 1291 * Returns the reason auto mode is currently unavailable, or null if available. 1292 * Synchronous — uses state populated by verifyAutoModeGateAccess. 1293 */ 1294export function getAutoModeUnavailableReason(): AutoModeUnavailableReason | null { 1295 if (isAutoModeDisabledBySettings()) return 'settings' 1296 if (autoModeStateModule?.isAutoModeCircuitBroken() ?? false) { 1297 return 'circuit-breaker' 1298 } 1299 if (!modelSupportsAutoMode(getMainLoopModel())) return 'model' 1300 return null 1301} 1302 1303/** 1304 * The `enabled` field in the tengu_auto_mode_config GrowthBook JSON config. 1305 * Controls auto mode availability in UI surfaces (CLI, IDE, Desktop). 1306 * - 'enabled': auto mode is available in the shift-tab carousel (or equivalent) 1307 * - 'disabled': auto mode is fully unavailable — circuit breaker for incident response 1308 * - 'opt-in': auto mode is available only if the user has explicitly opted in 1309 * (via --enable-auto-mode in CLI, or a settings toggle in IDE/Desktop) 1310 */ 1311export type AutoModeEnabledState = 'enabled' | 'disabled' | 'opt-in' 1312 1313const AUTO_MODE_ENABLED_DEFAULT: AutoModeEnabledState = 'disabled' 1314 1315function parseAutoModeEnabledState(value: unknown): AutoModeEnabledState { 1316 if (value === 'enabled' || value === 'disabled' || value === 'opt-in') { 1317 return value 1318 } 1319 return AUTO_MODE_ENABLED_DEFAULT 1320} 1321 1322/** 1323 * Reads the `enabled` field from tengu_auto_mode_config (cached, may be stale). 1324 * Defaults to 'disabled' if GrowthBook is unavailable or the field is unset. 1325 * Other surfaces (IDE, Desktop) should call this to decide whether to surface 1326 * auto mode in their mode pickers. 1327 */ 1328export function getAutoModeEnabledState(): AutoModeEnabledState { 1329 const config = getFeatureValue_CACHED_MAY_BE_STALE<{ 1330 enabled?: AutoModeEnabledState 1331 }>('tengu_auto_mode_config', {}) 1332 return parseAutoModeEnabledState(config?.enabled) 1333} 1334 1335const NO_CACHED_AUTO_MODE_CONFIG = Symbol('no-cached-auto-mode-config') 1336 1337/** 1338 * Like getAutoModeEnabledState but returns undefined when no cached value 1339 * exists (cold start, before GrowthBook init). Used by the sync 1340 * circuit-breaker check in initialPermissionModeFromCLI, which must not 1341 * conflate "not yet fetched" with "fetched and disabled" — the former 1342 * defers to verifyAutoModeGateAccess, the latter blocks immediately. 1343 */ 1344export function getAutoModeEnabledStateIfCached(): 1345 | AutoModeEnabledState 1346 | undefined { 1347 const config = getFeatureValue_CACHED_MAY_BE_STALE< 1348 { enabled?: AutoModeEnabledState } | typeof NO_CACHED_AUTO_MODE_CONFIG 1349 >('tengu_auto_mode_config', NO_CACHED_AUTO_MODE_CONFIG) 1350 if (config === NO_CACHED_AUTO_MODE_CONFIG) return undefined 1351 return parseAutoModeEnabledState(config?.enabled) 1352} 1353 1354/** 1355 * Returns true if the user has opted in to auto mode via any trusted mechanism: 1356 * - CLI flag (--enable-auto-mode / --permission-mode auto) — session-scoped 1357 * availability request; the startup dialog in showSetupScreens enforces 1358 * persistent consent before the REPL renders. 1359 * - skipAutoPermissionPrompt setting (persistent; set by accepting the opt-in 1360 * dialog or by IDE/Desktop settings toggle) 1361 */ 1362export function hasAutoModeOptInAnySource(): boolean { 1363 if (autoModeStateModule?.getAutoModeFlagCli() ?? false) return true 1364 return hasAutoModeOptIn() 1365} 1366 1367/** 1368 * Checks if bypassPermissions mode is currently disabled by Statsig gate or settings. 1369 * This is a synchronous version that uses cached Statsig values. 1370 */ 1371export function isBypassPermissionsModeDisabled(): boolean { 1372 const growthBookDisableBypassPermissionsMode = 1373 checkStatsigFeatureGate_CACHED_MAY_BE_STALE( 1374 'tengu_disable_bypass_permissions_mode', 1375 ) 1376 const settings = getSettings_DEPRECATED() || {} 1377 const settingsDisableBypassPermissionsMode = 1378 settings.permissions?.disableBypassPermissionsMode === 'disable' 1379 1380 return ( 1381 growthBookDisableBypassPermissionsMode || 1382 settingsDisableBypassPermissionsMode 1383 ) 1384} 1385 1386/** 1387 * Creates an updated context with bypassPermissions disabled 1388 */ 1389export function createDisabledBypassPermissionsContext( 1390 currentContext: ToolPermissionContext, 1391): ToolPermissionContext { 1392 let updatedContext = currentContext 1393 if (currentContext.mode === 'bypassPermissions') { 1394 updatedContext = applyPermissionUpdate(currentContext, { 1395 type: 'setMode', 1396 mode: 'default', 1397 destination: 'session', 1398 }) 1399 } 1400 1401 return { 1402 ...updatedContext, 1403 isBypassPermissionsModeAvailable: false, 1404 } 1405} 1406 1407/** 1408 * Asynchronously checks if the bypassPermissions mode should be disabled based on Statsig gate 1409 * and returns an updated toolPermissionContext if needed 1410 */ 1411export async function checkAndDisableBypassPermissions( 1412 currentContext: ToolPermissionContext, 1413): Promise<void> { 1414 // Only proceed if bypassPermissions mode is available 1415 if (!currentContext.isBypassPermissionsModeAvailable) { 1416 return 1417 } 1418 1419 const shouldDisable = await shouldDisableBypassPermissions() 1420 if (!shouldDisable) { 1421 return 1422 } 1423 1424 // Gate is enabled, need to disable bypassPermissions mode 1425 logForDebugging( 1426 'bypassPermissions mode is being disabled by Statsig gate (async check)', 1427 { level: 'warn' }, 1428 ) 1429 1430 void gracefulShutdown(1, 'bypass_permissions_disabled') 1431} 1432 1433export function isDefaultPermissionModeAuto(): boolean { 1434 if (feature('TRANSCRIPT_CLASSIFIER')) { 1435 const settings = getSettings_DEPRECATED() || {} 1436 return settings.permissions?.defaultMode === 'auto' 1437 } 1438 return false 1439} 1440 1441/** 1442 * Whether plan mode should use auto mode semantics (classifier runs during 1443 * plan). True when the user has opted in to auto mode and the gate is enabled. 1444 * Evaluated at permission-check time so it's reactive to config changes. 1445 */ 1446export function shouldPlanUseAutoMode(): boolean { 1447 if (feature('TRANSCRIPT_CLASSIFIER')) { 1448 return ( 1449 hasAutoModeOptIn() && 1450 isAutoModeGateEnabled() && 1451 getUseAutoModeDuringPlan() 1452 ) 1453 } 1454 return false 1455} 1456 1457/** 1458 * Centralized plan-mode entry. Stashes the current mode as prePlanMode so 1459 * ExitPlanMode can restore it. When the user has opted in to auto mode, 1460 * auto semantics stay active during plan mode. 1461 */ 1462export function prepareContextForPlanMode( 1463 context: ToolPermissionContext, 1464): ToolPermissionContext { 1465 const currentMode = context.mode 1466 if (currentMode === 'plan') return context 1467 if (feature('TRANSCRIPT_CLASSIFIER')) { 1468 const planAutoMode = shouldPlanUseAutoMode() 1469 if (currentMode === 'auto') { 1470 if (planAutoMode) { 1471 return { ...context, prePlanMode: 'auto' } 1472 } 1473 autoModeStateModule?.setAutoModeActive(false) 1474 setNeedsAutoModeExitAttachment(true) 1475 return { 1476 ...restoreDangerousPermissions(context), 1477 prePlanMode: 'auto', 1478 } 1479 } 1480 if (planAutoMode && currentMode !== 'bypassPermissions') { 1481 autoModeStateModule?.setAutoModeActive(true) 1482 return { 1483 ...stripDangerousPermissionsForAutoMode(context), 1484 prePlanMode: currentMode, 1485 } 1486 } 1487 } 1488 logForDebugging( 1489 `[prepareContextForPlanMode] plain plan entry, prePlanMode=${currentMode}`, 1490 { level: 'info' }, 1491 ) 1492 return { ...context, prePlanMode: currentMode } 1493} 1494 1495/** 1496 * Reconciles auto-mode state during plan mode after a settings change. 1497 * Compares desired state (shouldPlanUseAutoMode) against actual state 1498 * (isAutoModeActive) and activates/deactivates auto accordingly. No-op when 1499 * not in plan mode. Called from applySettingsChange so that toggling 1500 * useAutoModeDuringPlan mid-plan takes effect immediately. 1501 */ 1502export function transitionPlanAutoMode( 1503 context: ToolPermissionContext, 1504): ToolPermissionContext { 1505 if (!feature('TRANSCRIPT_CLASSIFIER')) return context 1506 if (context.mode !== 'plan') return context 1507 // Mirror prepareContextForPlanMode's entry-time exclusion — never activate 1508 // auto mid-plan when the user entered from a dangerous mode. 1509 if (context.prePlanMode === 'bypassPermissions') { 1510 return context 1511 } 1512 1513 const want = shouldPlanUseAutoMode() 1514 const have = autoModeStateModule?.isAutoModeActive() ?? false 1515 1516 if (want && have) { 1517 // syncPermissionRulesFromDisk (called before us in applySettingsChange) 1518 // re-adds dangerous rules from disk without touching strippedDangerousRules. 1519 // Re-strip so the classifier isn't bypassed by prefix-rule allow matches. 1520 return stripDangerousPermissionsForAutoMode(context) 1521 } 1522 if (!want && !have) return context 1523 1524 if (want) { 1525 autoModeStateModule?.setAutoModeActive(true) 1526 setNeedsAutoModeExitAttachment(false) 1527 return stripDangerousPermissionsForAutoMode(context) 1528 } 1529 autoModeStateModule?.setAutoModeActive(false) 1530 setNeedsAutoModeExitAttachment(true) 1531 return restoreDangerousPermissions(context) 1532}