/ src / utils / permissions / permissions.ts
permissions.ts
   1  import { feature } from 'bun:bundle'
   2  import { APIUserAbortError } from '@anthropic-ai/sdk'
   3  import type { CanUseToolFn } from '../../hooks/useCanUseTool.js'
   4  import {
   5    getToolNameForPermissionCheck,
   6    mcpInfoFromString,
   7  } from '../../services/mcp/mcpStringUtils.js'
   8  import type { Tool, ToolPermissionContext, ToolUseContext } from '../../Tool.js'
   9  import { AGENT_TOOL_NAME } from '../../tools/AgentTool/constants.js'
  10  import { shouldUseSandbox } from '../../tools/BashTool/shouldUseSandbox.js'
  11  import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js'
  12  import { POWERSHELL_TOOL_NAME } from '../../tools/PowerShellTool/toolName.js'
  13  import { REPL_TOOL_NAME } from '../../tools/REPLTool/constants.js'
  14  import type { AssistantMessage } from '../../types/message.js'
  15  import { extractOutputRedirections } from '../bash/commands.js'
  16  import { logForDebugging } from '../debug.js'
  17  import { AbortError, toError } from '../errors.js'
  18  import { logError } from '../log.js'
  19  import { SandboxManager } from '../sandbox/sandbox-adapter.js'
  20  import {
  21    getSettingSourceDisplayNameLowercase,
  22    SETTING_SOURCES,
  23  } from '../settings/constants.js'
  24  import { plural } from '../stringUtils.js'
  25  import { permissionModeTitle } from './PermissionMode.js'
  26  import type {
  27    PermissionAskDecision,
  28    PermissionDecision,
  29    PermissionDecisionReason,
  30    PermissionDenyDecision,
  31    PermissionResult,
  32  } from './PermissionResult.js'
  33  import type {
  34    PermissionBehavior,
  35    PermissionRule,
  36    PermissionRuleSource,
  37    PermissionRuleValue,
  38  } from './PermissionRule.js'
  39  import {
  40    applyPermissionUpdate,
  41    applyPermissionUpdates,
  42    persistPermissionUpdates,
  43  } from './PermissionUpdate.js'
  44  import type {
  45    PermissionUpdate,
  46    PermissionUpdateDestination,
  47  } from './PermissionUpdateSchema.js'
  48  import {
  49    permissionRuleValueFromString,
  50    permissionRuleValueToString,
  51  } from './permissionRuleParser.js'
  52  import {
  53    deletePermissionRuleFromSettings,
  54    type PermissionRuleFromEditableSettings,
  55    shouldAllowManagedPermissionRulesOnly,
  56  } from './permissionsLoader.js'
  57  
  58  /* eslint-disable @typescript-eslint/no-require-imports */
  59  const classifierDecisionModule = feature('TRANSCRIPT_CLASSIFIER')
  60    ? (require('./classifierDecision.js') as typeof import('./classifierDecision.js'))
  61    : null
  62  const autoModeStateModule = feature('TRANSCRIPT_CLASSIFIER')
  63    ? (require('./autoModeState.js') as typeof import('./autoModeState.js'))
  64    : null
  65  
  66  import {
  67    addToTurnClassifierDuration,
  68    getTotalCacheCreationInputTokens,
  69    getTotalCacheReadInputTokens,
  70    getTotalInputTokens,
  71    getTotalOutputTokens,
  72  } from '../../bootstrap/state.js'
  73  import { getFeatureValue_CACHED_WITH_REFRESH } from '../../services/analytics/growthbook.js'
  74  import {
  75    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  76    logEvent,
  77  } from '../../services/analytics/index.js'
  78  import { sanitizeToolNameForAnalytics } from '../../services/analytics/metadata.js'
  79  import {
  80    clearClassifierChecking,
  81    setClassifierChecking,
  82  } from '../classifierApprovals.js'
  83  import { isInProtectedNamespace } from '../envUtils.js'
  84  import { executePermissionRequestHooks } from '../hooks.js'
  85  import {
  86    AUTO_REJECT_MESSAGE,
  87    buildClassifierUnavailableMessage,
  88    buildYoloRejectionMessage,
  89    DONT_ASK_REJECT_MESSAGE,
  90  } from '../messages.js'
  91  import { calculateCostFromTokens } from '../modelCost.js'
  92  /* eslint-enable @typescript-eslint/no-require-imports */
  93  import { jsonStringify } from '../slowOperations.js'
  94  import {
  95    createDenialTrackingState,
  96    DENIAL_LIMITS,
  97    type DenialTrackingState,
  98    recordDenial,
  99    recordSuccess,
 100    shouldFallbackToPrompting,
 101  } from './denialTracking.js'
 102  import {
 103    classifyYoloAction,
 104    formatActionForClassifier,
 105  } from './yoloClassifier.js'
 106  
 107  const CLASSIFIER_FAIL_CLOSED_REFRESH_MS = 30 * 60 * 1000 // 30 minutes
 108  
 109  const PERMISSION_RULE_SOURCES = [
 110    ...SETTING_SOURCES,
 111    'cliArg',
 112    'command',
 113    'session',
 114  ] as const satisfies readonly PermissionRuleSource[]
 115  
 116  export function permissionRuleSourceDisplayString(
 117    source: PermissionRuleSource,
 118  ): string {
 119    return getSettingSourceDisplayNameLowercase(source)
 120  }
 121  
 122  export function getAllowRules(
 123    context: ToolPermissionContext,
 124  ): PermissionRule[] {
 125    return PERMISSION_RULE_SOURCES.flatMap(source =>
 126      (context.alwaysAllowRules[source] || []).map(ruleString => ({
 127        source,
 128        ruleBehavior: 'allow',
 129        ruleValue: permissionRuleValueFromString(ruleString),
 130      })),
 131    )
 132  }
 133  
 134  /**
 135   * Creates a permission request message that explain the permission request
 136   */
 137  export function createPermissionRequestMessage(
 138    toolName: string,
 139    decisionReason?: PermissionDecisionReason,
 140  ): string {
 141    // Handle different decision reason types
 142    if (decisionReason) {
 143      if (
 144        (feature('BASH_CLASSIFIER') || feature('TRANSCRIPT_CLASSIFIER')) &&
 145        decisionReason.type === 'classifier'
 146      ) {
 147        return `Classifier '${decisionReason.classifier}' requires approval for this ${toolName} command: ${decisionReason.reason}`
 148      }
 149      switch (decisionReason.type) {
 150        case 'hook': {
 151          const hookMessage = decisionReason.reason
 152            ? `Hook '${decisionReason.hookName}' blocked this action: ${decisionReason.reason}`
 153            : `Hook '${decisionReason.hookName}' requires approval for this ${toolName} command`
 154          return hookMessage
 155        }
 156        case 'rule': {
 157          const ruleString = permissionRuleValueToString(
 158            decisionReason.rule.ruleValue,
 159          )
 160          const sourceString = permissionRuleSourceDisplayString(
 161            decisionReason.rule.source,
 162          )
 163          return `Permission rule '${ruleString}' from ${sourceString} requires approval for this ${toolName} command`
 164        }
 165        case 'subcommandResults': {
 166          const needsApproval: string[] = []
 167          for (const [cmd, result] of decisionReason.reasons) {
 168            if (result.behavior === 'ask' || result.behavior === 'passthrough') {
 169              // Strip output redirections for display to avoid showing filenames as commands
 170              // Only do this for Bash tool to avoid affecting other tools
 171              if (toolName === 'Bash') {
 172                const { commandWithoutRedirections, redirections } =
 173                  extractOutputRedirections(cmd)
 174                // Only use stripped version if there were actual redirections
 175                const displayCmd =
 176                  redirections.length > 0 ? commandWithoutRedirections : cmd
 177                needsApproval.push(displayCmd)
 178              } else {
 179                needsApproval.push(cmd)
 180              }
 181            }
 182          }
 183          if (needsApproval.length > 0) {
 184            const n = needsApproval.length
 185            return `This ${toolName} command contains multiple operations. The following ${plural(n, 'part')} ${plural(n, 'requires', 'require')} approval: ${needsApproval.join(', ')}`
 186          }
 187          return `This ${toolName} command contains multiple operations that require approval`
 188        }
 189        case 'permissionPromptTool':
 190          return `Tool '${decisionReason.permissionPromptToolName}' requires approval for this ${toolName} command`
 191        case 'sandboxOverride':
 192          return 'Run outside of the sandbox'
 193        case 'workingDir':
 194          return decisionReason.reason
 195        case 'safetyCheck':
 196        case 'other':
 197          return decisionReason.reason
 198        case 'mode': {
 199          const modeTitle = permissionModeTitle(decisionReason.mode)
 200          return `Current permission mode (${modeTitle}) requires approval for this ${toolName} command`
 201        }
 202        case 'asyncAgent':
 203          return decisionReason.reason
 204      }
 205    }
 206  
 207    // Default message without listing allowed commands
 208    const message = `Claude requested permissions to use ${toolName}, but you haven't granted it yet.`
 209  
 210    return message
 211  }
 212  
 213  export function getDenyRules(context: ToolPermissionContext): PermissionRule[] {
 214    return PERMISSION_RULE_SOURCES.flatMap(source =>
 215      (context.alwaysDenyRules[source] || []).map(ruleString => ({
 216        source,
 217        ruleBehavior: 'deny',
 218        ruleValue: permissionRuleValueFromString(ruleString),
 219      })),
 220    )
 221  }
 222  
 223  export function getAskRules(context: ToolPermissionContext): PermissionRule[] {
 224    return PERMISSION_RULE_SOURCES.flatMap(source =>
 225      (context.alwaysAskRules[source] || []).map(ruleString => ({
 226        source,
 227        ruleBehavior: 'ask',
 228        ruleValue: permissionRuleValueFromString(ruleString),
 229      })),
 230    )
 231  }
 232  
 233  /**
 234   * Check if the entire tool matches a rule
 235   * For example, this matches "Bash" but not "Bash(prefix:*)" for BashTool
 236   * This also matches MCP tools with a server name, e.g. the rule "mcp__server1"
 237   */
 238  function toolMatchesRule(
 239    tool: Pick<Tool, 'name' | 'mcpInfo'>,
 240    rule: PermissionRule,
 241  ): boolean {
 242    // Rule must not have content to match the entire tool
 243    if (rule.ruleValue.ruleContent !== undefined) {
 244      return false
 245    }
 246  
 247    // MCP tools are matched by their fully qualified mcp__server__tool name. In
 248    // skip-prefix mode (CLAUDE_AGENT_SDK_MCP_NO_PREFIX), MCP tools have unprefixed
 249    // display names (e.g., "Write") that collide with builtin names; rules targeting
 250    // builtins should not match their MCP replacements.
 251    const nameForRuleMatch = getToolNameForPermissionCheck(tool)
 252  
 253    // Direct tool name match
 254    if (rule.ruleValue.toolName === nameForRuleMatch) {
 255      return true
 256    }
 257  
 258    // MCP server-level permission: rule "mcp__server1" matches tool "mcp__server1__tool1"
 259    // Also supports wildcard: rule "mcp__server1__*" matches all tools from server1
 260    const ruleInfo = mcpInfoFromString(rule.ruleValue.toolName)
 261    const toolInfo = mcpInfoFromString(nameForRuleMatch)
 262  
 263    return (
 264      ruleInfo !== null &&
 265      toolInfo !== null &&
 266      (ruleInfo.toolName === undefined || ruleInfo.toolName === '*') &&
 267      ruleInfo.serverName === toolInfo.serverName
 268    )
 269  }
 270  
 271  /**
 272   * Check if the entire tool is listed in the always allow rules
 273   * For example, this finds "Bash" but not "Bash(prefix:*)" for BashTool
 274   */
 275  export function toolAlwaysAllowedRule(
 276    context: ToolPermissionContext,
 277    tool: Pick<Tool, 'name' | 'mcpInfo'>,
 278  ): PermissionRule | null {
 279    return (
 280      getAllowRules(context).find(rule => toolMatchesRule(tool, rule)) || null
 281    )
 282  }
 283  
 284  /**
 285   * Check if the tool is listed in the always deny rules
 286   */
 287  export function getDenyRuleForTool(
 288    context: ToolPermissionContext,
 289    tool: Pick<Tool, 'name' | 'mcpInfo'>,
 290  ): PermissionRule | null {
 291    return getDenyRules(context).find(rule => toolMatchesRule(tool, rule)) || null
 292  }
 293  
 294  /**
 295   * Check if the tool is listed in the always ask rules
 296   */
 297  export function getAskRuleForTool(
 298    context: ToolPermissionContext,
 299    tool: Pick<Tool, 'name' | 'mcpInfo'>,
 300  ): PermissionRule | null {
 301    return getAskRules(context).find(rule => toolMatchesRule(tool, rule)) || null
 302  }
 303  
 304  /**
 305   * Check if a specific agent is denied via Agent(agentType) syntax.
 306   * For example, Agent(Explore) would deny the Explore agent.
 307   */
 308  export function getDenyRuleForAgent(
 309    context: ToolPermissionContext,
 310    agentToolName: string,
 311    agentType: string,
 312  ): PermissionRule | null {
 313    return (
 314      getDenyRules(context).find(
 315        rule =>
 316          rule.ruleValue.toolName === agentToolName &&
 317          rule.ruleValue.ruleContent === agentType,
 318      ) || null
 319    )
 320  }
 321  
 322  /**
 323   * Filter agents to exclude those that are denied via Agent(agentType) syntax.
 324   */
 325  export function filterDeniedAgents<T extends { agentType: string }>(
 326    agents: T[],
 327    context: ToolPermissionContext,
 328    agentToolName: string,
 329  ): T[] {
 330    // Parse deny rules once and collect Agent(x) contents into a Set.
 331    // Previously this called getDenyRuleForAgent per agent, which re-parsed
 332    // every deny rule for every agent (O(agents×rules) parse calls).
 333    const deniedAgentTypes = new Set<string>()
 334    for (const rule of getDenyRules(context)) {
 335      if (
 336        rule.ruleValue.toolName === agentToolName &&
 337        rule.ruleValue.ruleContent !== undefined
 338      ) {
 339        deniedAgentTypes.add(rule.ruleValue.ruleContent)
 340      }
 341    }
 342    return agents.filter(agent => !deniedAgentTypes.has(agent.agentType))
 343  }
 344  
 345  /**
 346   * Map of rule contents to the associated rule for a given tool.
 347   * e.g. the string key is "prefix:*" from "Bash(prefix:*)" for BashTool
 348   */
 349  export function getRuleByContentsForTool(
 350    context: ToolPermissionContext,
 351    tool: Tool,
 352    behavior: PermissionBehavior,
 353  ): Map<string, PermissionRule> {
 354    return getRuleByContentsForToolName(
 355      context,
 356      getToolNameForPermissionCheck(tool),
 357      behavior,
 358    )
 359  }
 360  
 361  // Used to break circular dependency where a Tool calls this function
 362  export function getRuleByContentsForToolName(
 363    context: ToolPermissionContext,
 364    toolName: string,
 365    behavior: PermissionBehavior,
 366  ): Map<string, PermissionRule> {
 367    const ruleByContents = new Map<string, PermissionRule>()
 368    let rules: PermissionRule[] = []
 369    switch (behavior) {
 370      case 'allow':
 371        rules = getAllowRules(context)
 372        break
 373      case 'deny':
 374        rules = getDenyRules(context)
 375        break
 376      case 'ask':
 377        rules = getAskRules(context)
 378        break
 379    }
 380    for (const rule of rules) {
 381      if (
 382        rule.ruleValue.toolName === toolName &&
 383        rule.ruleValue.ruleContent !== undefined &&
 384        rule.ruleBehavior === behavior
 385      ) {
 386        ruleByContents.set(rule.ruleValue.ruleContent, rule)
 387      }
 388    }
 389    return ruleByContents
 390  }
 391  
 392  /**
 393   * Runs PermissionRequest hooks for headless/async agents that cannot show
 394   * permission prompts. This gives hooks an opportunity to allow or deny
 395   * tool use before the fallback auto-deny kicks in.
 396   *
 397   * Returns a PermissionDecision if a hook made a decision, or null if no
 398   * hook provided a decision (caller should proceed to auto-deny).
 399   */
 400  async function runPermissionRequestHooksForHeadlessAgent(
 401    tool: Tool,
 402    input: { [key: string]: unknown },
 403    toolUseID: string,
 404    context: ToolUseContext,
 405    permissionMode: string | undefined,
 406    suggestions: PermissionUpdate[] | undefined,
 407  ): Promise<PermissionDecision | null> {
 408    try {
 409      for await (const hookResult of executePermissionRequestHooks(
 410        tool.name,
 411        toolUseID,
 412        input,
 413        context,
 414        permissionMode,
 415        suggestions,
 416        context.abortController.signal,
 417      )) {
 418        if (!hookResult.permissionRequestResult) {
 419          continue
 420        }
 421        const decision = hookResult.permissionRequestResult
 422        if (decision.behavior === 'allow') {
 423          const finalInput = decision.updatedInput ?? input
 424          // Persist permission updates if provided
 425          if (decision.updatedPermissions?.length) {
 426            persistPermissionUpdates(decision.updatedPermissions)
 427            context.setAppState(prev => ({
 428              ...prev,
 429              toolPermissionContext: applyPermissionUpdates(
 430                prev.toolPermissionContext,
 431                decision.updatedPermissions!,
 432              ),
 433            }))
 434          }
 435          return {
 436            behavior: 'allow',
 437            updatedInput: finalInput,
 438            decisionReason: {
 439              type: 'hook',
 440              hookName: 'PermissionRequest',
 441            },
 442          }
 443        }
 444        if (decision.behavior === 'deny') {
 445          if (decision.interrupt) {
 446            logForDebugging(
 447              `Hook interrupt: tool=${tool.name} hookMessage=${decision.message}`,
 448            )
 449            context.abortController.abort()
 450          }
 451          return {
 452            behavior: 'deny',
 453            message: decision.message || 'Permission denied by hook',
 454            decisionReason: {
 455              type: 'hook',
 456              hookName: 'PermissionRequest',
 457              reason: decision.message,
 458            },
 459          }
 460        }
 461      }
 462    } catch (error) {
 463      // If hooks fail, fall through to auto-deny rather than crashing
 464      logError(
 465        new Error('PermissionRequest hook failed for headless agent', {
 466          cause: toError(error),
 467        }),
 468      )
 469    }
 470    return null
 471  }
 472  
 473  export const hasPermissionsToUseTool: CanUseToolFn = async (
 474    tool,
 475    input,
 476    context,
 477    assistantMessage,
 478    toolUseID,
 479  ): Promise<PermissionDecision> => {
 480    const result = await hasPermissionsToUseToolInner(tool, input, context)
 481  
 482  
 483    // Reset consecutive denials on any allowed tool use in auto mode.
 484    // This ensures that a successful tool use (even one auto-allowed by rules)
 485    // breaks the consecutive denial streak.
 486    if (result.behavior === 'allow') {
 487      const appState = context.getAppState()
 488      if (feature('TRANSCRIPT_CLASSIFIER')) {
 489        const currentDenialState =
 490          context.localDenialTracking ?? appState.denialTracking
 491        if (
 492          appState.toolPermissionContext.mode === 'auto' &&
 493          currentDenialState &&
 494          currentDenialState.consecutiveDenials > 0
 495        ) {
 496          const newDenialState = recordSuccess(currentDenialState)
 497          persistDenialState(context, newDenialState)
 498        }
 499      }
 500      return result
 501    }
 502  
 503    // Apply dontAsk mode transformation: convert 'ask' to 'deny'
 504    // This is done at the end so it can't be bypassed by early returns
 505    if (result.behavior === 'ask') {
 506      const appState = context.getAppState()
 507  
 508      if (appState.toolPermissionContext.mode === 'dontAsk') {
 509        return {
 510          behavior: 'deny',
 511          decisionReason: {
 512            type: 'mode',
 513            mode: 'dontAsk',
 514          },
 515          message: DONT_ASK_REJECT_MESSAGE(tool.name),
 516        }
 517      }
 518      // Apply auto mode: use AI classifier instead of prompting user
 519      // Check this BEFORE shouldAvoidPermissionPrompts so classifiers work in headless mode
 520      if (
 521        feature('TRANSCRIPT_CLASSIFIER') &&
 522        (appState.toolPermissionContext.mode === 'auto' ||
 523          (appState.toolPermissionContext.mode === 'plan' &&
 524            (autoModeStateModule?.isAutoModeActive() ?? false)))
 525      ) {
 526        // Non-classifier-approvable safetyCheck decisions stay immune to ALL
 527        // auto-approve paths: the acceptEdits fast-path, the safe-tool allowlist,
 528        // and the classifier. Step 1g only guards bypassPermissions; this guards
 529        // auto. classifierApprovable safetyChecks (sensitive-file paths) fall
 530        // through to the classifier — the fast-paths below naturally don't fire
 531        // because the tool's own checkPermissions still returns 'ask'.
 532        if (
 533          result.decisionReason?.type === 'safetyCheck' &&
 534          !result.decisionReason.classifierApprovable
 535        ) {
 536          if (appState.toolPermissionContext.shouldAvoidPermissionPrompts) {
 537            return {
 538              behavior: 'deny',
 539              message: result.message,
 540              decisionReason: {
 541                type: 'asyncAgent',
 542                reason:
 543                  'Safety check requires interactive approval and permission prompts are not available in this context',
 544              },
 545            }
 546          }
 547          return result
 548        }
 549        if (tool.requiresUserInteraction?.() && result.behavior === 'ask') {
 550          return result
 551        }
 552  
 553        // Use local denial tracking for async subagents (whose setAppState
 554        // is a no-op), otherwise read from appState as before.
 555        const denialState =
 556          context.localDenialTracking ??
 557          appState.denialTracking ??
 558          createDenialTrackingState()
 559  
 560        // PowerShell requires explicit user permission in auto mode unless
 561        // POWERSHELL_AUTO_MODE (ant-only build flag) is on. When disabled, this
 562        // guard keeps PS out of the classifier and skips the acceptEdits
 563        // fast-path below. When enabled, PS flows through to the classifier like
 564        // Bash — the classifier prompt gets POWERSHELL_DENY_GUIDANCE appended so
 565        // it recognizes `iex (iwr ...)` as download-and-execute, etc.
 566        // Note: this runs inside the behavior === 'ask' branch, so allow rules
 567        // that fire earlier (step 2b toolAlwaysAllowedRule, PS prefix allow)
 568        // return before reaching here. Allow-rule protection is handled by
 569        // permissionSetup.ts: isOverlyBroadPowerShellAllowRule strips PowerShell(*)
 570        // and isDangerousPowerShellPermission strips iex/pwsh/Start-Process
 571        // prefix rules for ant users and auto mode entry.
 572        if (
 573          tool.name === POWERSHELL_TOOL_NAME &&
 574          !feature('POWERSHELL_AUTO_MODE')
 575        ) {
 576          if (appState.toolPermissionContext.shouldAvoidPermissionPrompts) {
 577            return {
 578              behavior: 'deny',
 579              message: 'PowerShell tool requires interactive approval',
 580              decisionReason: {
 581                type: 'asyncAgent',
 582                reason:
 583                  'PowerShell tool requires interactive approval and permission prompts are not available in this context',
 584              },
 585            }
 586          }
 587          logForDebugging(
 588            `Skipping auto mode classifier for ${tool.name}: tool requires explicit user permission`,
 589          )
 590          return result
 591        }
 592  
 593        // Before running the auto mode classifier, check if acceptEdits mode would
 594        // allow this action. This avoids expensive classifier API calls for safe
 595        // operations like file edits in the working directory.
 596        // Skip for Agent and REPL — their checkPermissions returns 'allow' for
 597        // acceptEdits mode, which would silently bypass the classifier. REPL
 598        // code can contain VM escapes between inner tool calls; the classifier
 599        // must see the glue JavaScript, not just the inner tool calls.
 600        if (
 601          result.behavior === 'ask' &&
 602          tool.name !== AGENT_TOOL_NAME &&
 603          tool.name !== REPL_TOOL_NAME
 604        ) {
 605          try {
 606            const parsedInput = tool.inputSchema.parse(input)
 607            const acceptEditsResult = await tool.checkPermissions(parsedInput, {
 608              ...context,
 609              getAppState: () => {
 610                const state = context.getAppState()
 611                return {
 612                  ...state,
 613                  toolPermissionContext: {
 614                    ...state.toolPermissionContext,
 615                    mode: 'acceptEdits' as const,
 616                  },
 617                }
 618              },
 619            })
 620            if (acceptEditsResult.behavior === 'allow') {
 621              const newDenialState = recordSuccess(denialState)
 622              persistDenialState(context, newDenialState)
 623              logForDebugging(
 624                `Skipping auto mode classifier for ${tool.name}: would be allowed in acceptEdits mode`,
 625              )
 626              logEvent('tengu_auto_mode_decision', {
 627                decision:
 628                  'allowed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 629                toolName: sanitizeToolNameForAnalytics(tool.name),
 630                inProtectedNamespace: isInProtectedNamespace(),
 631                // msg_id of the agent completion that produced this tool_use —
 632                // the action at the bottom of the classifier transcript. Joins
 633                // the decision back to the main agent's API response.
 634                agentMsgId: assistantMessage.message
 635                  .id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 636                confidence:
 637                  'high' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 638                fastPath:
 639                  'acceptEdits' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 640              })
 641              return {
 642                behavior: 'allow',
 643                updatedInput: acceptEditsResult.updatedInput ?? input,
 644                decisionReason: {
 645                  type: 'mode',
 646                  mode: 'auto',
 647                },
 648              }
 649            }
 650          } catch (e) {
 651            if (e instanceof AbortError || e instanceof APIUserAbortError) {
 652              throw e
 653            }
 654            // If the acceptEdits check fails, fall through to the classifier
 655          }
 656        }
 657  
 658        // Allowlisted tools are safe and don't need YOLO classification.
 659        // This uses the safe-tool allowlist to skip unnecessary classifier API calls.
 660        if (classifierDecisionModule!.isAutoModeAllowlistedTool(tool.name)) {
 661          const newDenialState = recordSuccess(denialState)
 662          persistDenialState(context, newDenialState)
 663          logForDebugging(
 664            `Skipping auto mode classifier for ${tool.name}: tool is on the safe allowlist`,
 665          )
 666          logEvent('tengu_auto_mode_decision', {
 667            decision:
 668              'allowed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 669            toolName: sanitizeToolNameForAnalytics(tool.name),
 670            inProtectedNamespace: isInProtectedNamespace(),
 671            agentMsgId: assistantMessage.message
 672              .id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 673            confidence:
 674              'high' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 675            fastPath:
 676              'allowlist' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 677          })
 678          return {
 679            behavior: 'allow',
 680            updatedInput: input,
 681            decisionReason: {
 682              type: 'mode',
 683              mode: 'auto',
 684            },
 685          }
 686        }
 687  
 688        // Run the auto mode classifier
 689        const action = formatActionForClassifier(tool.name, input)
 690        setClassifierChecking(toolUseID)
 691        let classifierResult
 692        try {
 693          classifierResult = await classifyYoloAction(
 694            context.messages,
 695            action,
 696            context.options.tools,
 697            appState.toolPermissionContext,
 698            context.abortController.signal,
 699          )
 700        } finally {
 701          clearClassifierChecking(toolUseID)
 702        }
 703  
 704        // Notify ants when classifier error dumped prompts (will be in /share)
 705        if (
 706          process.env.USER_TYPE === 'ant' &&
 707          classifierResult.errorDumpPath &&
 708          context.addNotification
 709        ) {
 710          context.addNotification({
 711            key: 'auto-mode-error-dump',
 712            text: `Auto mode classifier error — prompts dumped to ${classifierResult.errorDumpPath} (included in /share)`,
 713            priority: 'immediate',
 714            color: 'error',
 715          })
 716        }
 717  
 718        // Log classifier decision for metrics (including overhead telemetry)
 719        const yoloDecision = classifierResult.unavailable
 720          ? 'unavailable'
 721          : classifierResult.shouldBlock
 722            ? 'blocked'
 723            : 'allowed'
 724  
 725        // Compute classifier cost in USD for overhead analysis
 726        const classifierCostUSD =
 727          classifierResult.usage && classifierResult.model
 728            ? calculateCostFromTokens(
 729                classifierResult.model,
 730                classifierResult.usage,
 731              )
 732            : undefined
 733        logEvent('tengu_auto_mode_decision', {
 734          decision:
 735            yoloDecision as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 736          toolName: sanitizeToolNameForAnalytics(tool.name),
 737          inProtectedNamespace: isInProtectedNamespace(),
 738          // msg_id of the agent completion that produced this tool_use —
 739          // the action at the bottom of the classifier transcript.
 740          agentMsgId: assistantMessage.message
 741            .id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 742          classifierModel:
 743            classifierResult.model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 744          consecutiveDenials: classifierResult.shouldBlock
 745            ? denialState.consecutiveDenials + 1
 746            : 0,
 747          totalDenials: classifierResult.shouldBlock
 748            ? denialState.totalDenials + 1
 749            : denialState.totalDenials,
 750          // Overhead telemetry: token usage and latency for the classifier API call
 751          classifierInputTokens: classifierResult.usage?.inputTokens,
 752          classifierOutputTokens: classifierResult.usage?.outputTokens,
 753          classifierCacheReadInputTokens:
 754            classifierResult.usage?.cacheReadInputTokens,
 755          classifierCacheCreationInputTokens:
 756            classifierResult.usage?.cacheCreationInputTokens,
 757          classifierDurationMs: classifierResult.durationMs,
 758          // Character lengths of the prompt components sent to the classifier
 759          classifierSystemPromptLength:
 760            classifierResult.promptLengths?.systemPrompt,
 761          classifierToolCallsLength: classifierResult.promptLengths?.toolCalls,
 762          classifierUserPromptsLength:
 763            classifierResult.promptLengths?.userPrompts,
 764          // Session totals at time of classifier call (for computing overhead %).
 765          // These are main-transcript-only — sideQuery (used by the classifier)
 766          // does NOT call addToTotalSessionCost, so classifier tokens are excluded.
 767          sessionInputTokens: getTotalInputTokens(),
 768          sessionOutputTokens: getTotalOutputTokens(),
 769          sessionCacheReadInputTokens: getTotalCacheReadInputTokens(),
 770          sessionCacheCreationInputTokens: getTotalCacheCreationInputTokens(),
 771          classifierCostUSD,
 772          classifierStage:
 773            classifierResult.stage as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 774          classifierStage1InputTokens: classifierResult.stage1Usage?.inputTokens,
 775          classifierStage1OutputTokens:
 776            classifierResult.stage1Usage?.outputTokens,
 777          classifierStage1CacheReadInputTokens:
 778            classifierResult.stage1Usage?.cacheReadInputTokens,
 779          classifierStage1CacheCreationInputTokens:
 780            classifierResult.stage1Usage?.cacheCreationInputTokens,
 781          classifierStage1DurationMs: classifierResult.stage1DurationMs,
 782          classifierStage1RequestId:
 783            classifierResult.stage1RequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 784          classifierStage1MsgId:
 785            classifierResult.stage1MsgId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 786          classifierStage1CostUSD:
 787            classifierResult.stage1Usage && classifierResult.model
 788              ? calculateCostFromTokens(
 789                  classifierResult.model,
 790                  classifierResult.stage1Usage,
 791                )
 792              : undefined,
 793          classifierStage2InputTokens: classifierResult.stage2Usage?.inputTokens,
 794          classifierStage2OutputTokens:
 795            classifierResult.stage2Usage?.outputTokens,
 796          classifierStage2CacheReadInputTokens:
 797            classifierResult.stage2Usage?.cacheReadInputTokens,
 798          classifierStage2CacheCreationInputTokens:
 799            classifierResult.stage2Usage?.cacheCreationInputTokens,
 800          classifierStage2DurationMs: classifierResult.stage2DurationMs,
 801          classifierStage2RequestId:
 802            classifierResult.stage2RequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 803          classifierStage2MsgId:
 804            classifierResult.stage2MsgId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 805          classifierStage2CostUSD:
 806            classifierResult.stage2Usage && classifierResult.model
 807              ? calculateCostFromTokens(
 808                  classifierResult.model,
 809                  classifierResult.stage2Usage,
 810                )
 811              : undefined,
 812        })
 813  
 814        if (classifierResult.durationMs !== undefined) {
 815          addToTurnClassifierDuration(classifierResult.durationMs)
 816        }
 817  
 818        if (classifierResult.shouldBlock) {
 819          // Transcript exceeded the classifier's context window — deterministic
 820          // error, won't recover on retry. Skip iron_gate and fall back to
 821          // normal prompting so the user can approve/deny manually.
 822          if (classifierResult.transcriptTooLong) {
 823            if (appState.toolPermissionContext.shouldAvoidPermissionPrompts) {
 824              // Permanent condition (transcript only grows) — deny-retry-deny
 825              // wastes tokens without ever hitting the denial-limit abort.
 826              throw new AbortError(
 827                'Agent aborted: auto mode classifier transcript exceeded context window in headless mode',
 828              )
 829            }
 830            logForDebugging(
 831              'Auto mode classifier transcript too long, falling back to normal permission handling',
 832              { level: 'warn' },
 833            )
 834            return {
 835              ...result,
 836              decisionReason: {
 837                type: 'other',
 838                reason:
 839                  'Auto mode classifier transcript exceeded context window — falling back to manual approval',
 840              },
 841            }
 842          }
 843          // When classifier is unavailable (API error), behavior depends on
 844          // the tengu_iron_gate_closed gate.
 845          if (classifierResult.unavailable) {
 846            if (
 847              getFeatureValue_CACHED_WITH_REFRESH(
 848                'tengu_iron_gate_closed',
 849                true,
 850                CLASSIFIER_FAIL_CLOSED_REFRESH_MS,
 851              )
 852            ) {
 853              logForDebugging(
 854                'Auto mode classifier unavailable, denying with retry guidance (fail closed)',
 855                { level: 'warn' },
 856              )
 857              return {
 858                behavior: 'deny',
 859                decisionReason: {
 860                  type: 'classifier',
 861                  classifier: 'auto-mode',
 862                  reason: 'Classifier unavailable',
 863                },
 864                message: buildClassifierUnavailableMessage(
 865                  tool.name,
 866                  classifierResult.model,
 867                ),
 868              }
 869            }
 870            // Fail open: fall back to normal permission handling
 871            logForDebugging(
 872              'Auto mode classifier unavailable, falling back to normal permission handling (fail open)',
 873              { level: 'warn' },
 874            )
 875            return result
 876          }
 877  
 878          // Update denial tracking and check limits
 879          const newDenialState = recordDenial(denialState)
 880          persistDenialState(context, newDenialState)
 881  
 882          logForDebugging(
 883            `Auto mode classifier blocked action: ${classifierResult.reason}`,
 884            { level: 'warn' },
 885          )
 886  
 887          // If denial limit hit, fall back to prompting so the user
 888          // can review. We check after the classifier so we can include
 889          // its reason in the prompt.
 890          const denialLimitResult = handleDenialLimitExceeded(
 891            newDenialState,
 892            appState,
 893            classifierResult.reason,
 894            assistantMessage,
 895            tool,
 896            result,
 897            context,
 898          )
 899          if (denialLimitResult) {
 900            return denialLimitResult
 901          }
 902  
 903          return {
 904            behavior: 'deny',
 905            decisionReason: {
 906              type: 'classifier',
 907              classifier: 'auto-mode',
 908              reason: classifierResult.reason,
 909            },
 910            message: buildYoloRejectionMessage(classifierResult.reason),
 911          }
 912        }
 913  
 914        // Reset consecutive denials on success
 915        const newDenialState = recordSuccess(denialState)
 916        persistDenialState(context, newDenialState)
 917  
 918        return {
 919          behavior: 'allow',
 920          updatedInput: input,
 921          decisionReason: {
 922            type: 'classifier',
 923            classifier: 'auto-mode',
 924            reason: classifierResult.reason,
 925          },
 926        }
 927      }
 928  
 929      // When permission prompts should be avoided (e.g., background/headless agents),
 930      // run PermissionRequest hooks first to give them a chance to allow/deny.
 931      // Only auto-deny if no hook provides a decision.
 932      if (appState.toolPermissionContext.shouldAvoidPermissionPrompts) {
 933        const hookDecision = await runPermissionRequestHooksForHeadlessAgent(
 934          tool,
 935          input,
 936          toolUseID,
 937          context,
 938          appState.toolPermissionContext.mode,
 939          result.suggestions,
 940        )
 941        if (hookDecision) {
 942          return hookDecision
 943        }
 944        return {
 945          behavior: 'deny',
 946          decisionReason: {
 947            type: 'asyncAgent',
 948            reason: 'Permission prompts are not available in this context',
 949          },
 950          message: AUTO_REJECT_MESSAGE(tool.name),
 951        }
 952      }
 953    }
 954  
 955    return result
 956  }
 957  
 958  /**
 959   * Persist denial tracking state. For async subagents with localDenialTracking,
 960   * mutate the local state in place (since setAppState is a no-op). Otherwise,
 961   * write to appState as usual.
 962   */
 963  function persistDenialState(
 964    context: ToolUseContext,
 965    newState: DenialTrackingState,
 966  ): void {
 967    if (context.localDenialTracking) {
 968      Object.assign(context.localDenialTracking, newState)
 969    } else {
 970      context.setAppState(prev => {
 971        // recordSuccess returns the same reference when state is
 972        // unchanged. Returning prev here lets store.setState's Object.is check
 973        // skip the listener loop entirely.
 974        if (prev.denialTracking === newState) return prev
 975        return { ...prev, denialTracking: newState }
 976      })
 977    }
 978  }
 979  
 980  /**
 981   * Check if a denial limit was exceeded and return an 'ask' result
 982   * so the user can review. Returns null if no limit was hit.
 983   */
 984  function handleDenialLimitExceeded(
 985    denialState: DenialTrackingState,
 986    appState: {
 987      toolPermissionContext: { shouldAvoidPermissionPrompts?: boolean }
 988    },
 989    classifierReason: string,
 990    assistantMessage: AssistantMessage,
 991    tool: Tool,
 992    result: PermissionDecision,
 993    context: ToolUseContext,
 994  ): PermissionDecision | null {
 995    if (!shouldFallbackToPrompting(denialState)) {
 996      return null
 997    }
 998  
 999    const hitTotalLimit = denialState.totalDenials >= DENIAL_LIMITS.maxTotal
1000    const isHeadless = appState.toolPermissionContext.shouldAvoidPermissionPrompts
1001    // Capture counts before persistDenialState, which may mutate denialState
1002    // in-place via Object.assign for subagents with localDenialTracking.
1003    const totalCount = denialState.totalDenials
1004    const consecutiveCount = denialState.consecutiveDenials
1005    const warning = hitTotalLimit
1006      ? `${totalCount} actions were blocked this session. Please review the transcript before continuing.`
1007      : `${consecutiveCount} consecutive actions were blocked. Please review the transcript before continuing.`
1008  
1009    logEvent('tengu_auto_mode_denial_limit_exceeded', {
1010      limit: (hitTotalLimit
1011        ? 'total'
1012        : 'consecutive') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
1013      mode: (isHeadless
1014        ? 'headless'
1015        : 'cli') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
1016      messageID: assistantMessage.message
1017        .id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
1018      consecutiveDenials: consecutiveCount,
1019      totalDenials: totalCount,
1020      toolName: sanitizeToolNameForAnalytics(tool.name),
1021    })
1022  
1023    if (isHeadless) {
1024      throw new AbortError(
1025        'Agent aborted: too many classifier denials in headless mode',
1026      )
1027    }
1028  
1029    logForDebugging(
1030      `Classifier denial limit exceeded, falling back to prompting: ${warning}`,
1031      { level: 'warn' },
1032    )
1033  
1034    if (hitTotalLimit) {
1035      persistDenialState(context, {
1036        ...denialState,
1037        totalDenials: 0,
1038        consecutiveDenials: 0,
1039      })
1040    }
1041  
1042    // Preserve the original classifier value (e.g. 'dangerous-agent-action')
1043    // so downstream analytics in interactiveHandler can log the correct
1044    // user override event.
1045    const originalClassifier =
1046      result.decisionReason?.type === 'classifier'
1047        ? result.decisionReason.classifier
1048        : 'auto-mode'
1049  
1050    return {
1051      ...result,
1052      decisionReason: {
1053        type: 'classifier',
1054        classifier: originalClassifier,
1055        reason: `${warning}\n\nLatest blocked action: ${classifierReason}`,
1056      },
1057    }
1058  }
1059  
1060  /**
1061   * Check only the rule-based steps of the permission pipeline — the subset
1062   * that bypassPermissions mode respects (everything that fires before step 2a).
1063   *
1064   * Returns a deny/ask decision if a rule blocks the tool, or null if no rule
1065   * objects. Unlike hasPermissionsToUseTool, this does NOT run the auto mode classifier,
1066   * mode-based transformations (dontAsk/auto/asyncAgent), PermissionRequest hooks,
1067   * or bypassPermissions / always-allowed checks.
1068   *
1069   * Caller must pre-check tool.requiresUserInteraction() — step 1e is not replicated.
1070   */
1071  export async function checkRuleBasedPermissions(
1072    tool: Tool,
1073    input: { [key: string]: unknown },
1074    context: ToolUseContext,
1075  ): Promise<PermissionAskDecision | PermissionDenyDecision | null> {
1076    const appState = context.getAppState()
1077  
1078    // 1a. Entire tool is denied by rule
1079    const denyRule = getDenyRuleForTool(appState.toolPermissionContext, tool)
1080    if (denyRule) {
1081      return {
1082        behavior: 'deny',
1083        decisionReason: {
1084          type: 'rule',
1085          rule: denyRule,
1086        },
1087        message: `Permission to use ${tool.name} has been denied.`,
1088      }
1089    }
1090  
1091    // 1b. Entire tool has an ask rule
1092    const askRule = getAskRuleForTool(appState.toolPermissionContext, tool)
1093    if (askRule) {
1094      const canSandboxAutoAllow =
1095        tool.name === BASH_TOOL_NAME &&
1096        SandboxManager.isSandboxingEnabled() &&
1097        SandboxManager.isAutoAllowBashIfSandboxedEnabled() &&
1098        shouldUseSandbox(input)
1099  
1100      if (!canSandboxAutoAllow) {
1101        return {
1102          behavior: 'ask',
1103          decisionReason: {
1104            type: 'rule',
1105            rule: askRule,
1106          },
1107          message: createPermissionRequestMessage(tool.name),
1108        }
1109      }
1110      // Fall through to let tool.checkPermissions handle command-specific rules
1111    }
1112  
1113    // 1c. Tool-specific permission check (e.g. bash subcommand rules)
1114    let toolPermissionResult: PermissionResult = {
1115      behavior: 'passthrough',
1116      message: createPermissionRequestMessage(tool.name),
1117    }
1118    try {
1119      const parsedInput = tool.inputSchema.parse(input)
1120      toolPermissionResult = await tool.checkPermissions(parsedInput, context)
1121    } catch (e) {
1122      if (e instanceof AbortError || e instanceof APIUserAbortError) {
1123        throw e
1124      }
1125      logError(e)
1126    }
1127  
1128    // 1d. Tool implementation denied (catches bash subcommand denies wrapped
1129    // in subcommandResults — no need to inspect decisionReason.type)
1130    if (toolPermissionResult?.behavior === 'deny') {
1131      return toolPermissionResult
1132    }
1133  
1134    // 1f. Content-specific ask rules from tool.checkPermissions
1135    // (e.g. Bash(npm publish:*) → {ask, type:'rule', ruleBehavior:'ask'})
1136    if (
1137      toolPermissionResult?.behavior === 'ask' &&
1138      toolPermissionResult.decisionReason?.type === 'rule' &&
1139      toolPermissionResult.decisionReason.rule.ruleBehavior === 'ask'
1140    ) {
1141      return toolPermissionResult
1142    }
1143  
1144    // 1g. Safety checks (e.g. .git/, .claude/, .vscode/, shell configs) are
1145    // bypass-immune — they must prompt even when a PreToolUse hook returned
1146    // allow. checkPathSafetyForAutoEdit returns {type:'safetyCheck'} for these.
1147    if (
1148      toolPermissionResult?.behavior === 'ask' &&
1149      toolPermissionResult.decisionReason?.type === 'safetyCheck'
1150    ) {
1151      return toolPermissionResult
1152    }
1153  
1154    // No rule-based objection
1155    return null
1156  }
1157  
1158  async function hasPermissionsToUseToolInner(
1159    tool: Tool,
1160    input: { [key: string]: unknown },
1161    context: ToolUseContext,
1162  ): Promise<PermissionDecision> {
1163    if (context.abortController.signal.aborted) {
1164      throw new AbortError()
1165    }
1166  
1167    let appState = context.getAppState()
1168  
1169    // 1. Check if the tool is denied
1170    // 1a. Entire tool is denied
1171    const denyRule = getDenyRuleForTool(appState.toolPermissionContext, tool)
1172    if (denyRule) {
1173      return {
1174        behavior: 'deny',
1175        decisionReason: {
1176          type: 'rule',
1177          rule: denyRule,
1178        },
1179        message: `Permission to use ${tool.name} has been denied.`,
1180      }
1181    }
1182  
1183    // 1b. Check if the entire tool should always ask for permission
1184    const askRule = getAskRuleForTool(appState.toolPermissionContext, tool)
1185    if (askRule) {
1186      // When autoAllowBashIfSandboxed is on, sandboxed commands skip the ask rule and
1187      // auto-allow via Bash's checkPermissions. Commands that won't be sandboxed (excluded
1188      // commands, dangerouslyDisableSandbox) still need to respect the ask rule.
1189      const canSandboxAutoAllow =
1190        tool.name === BASH_TOOL_NAME &&
1191        SandboxManager.isSandboxingEnabled() &&
1192        SandboxManager.isAutoAllowBashIfSandboxedEnabled() &&
1193        shouldUseSandbox(input)
1194  
1195      if (!canSandboxAutoAllow) {
1196        return {
1197          behavior: 'ask',
1198          decisionReason: {
1199            type: 'rule',
1200            rule: askRule,
1201          },
1202          message: createPermissionRequestMessage(tool.name),
1203        }
1204      }
1205      // Fall through to let Bash's checkPermissions handle command-specific rules
1206    }
1207  
1208    // 1c. Ask the tool implementation for a permission result
1209    // Overridden unless tool input schema is not valid
1210    let toolPermissionResult: PermissionResult = {
1211      behavior: 'passthrough',
1212      message: createPermissionRequestMessage(tool.name),
1213    }
1214    try {
1215      const parsedInput = tool.inputSchema.parse(input)
1216      toolPermissionResult = await tool.checkPermissions(parsedInput, context)
1217    } catch (e) {
1218      // Rethrow abort errors so they propagate properly
1219      if (e instanceof AbortError || e instanceof APIUserAbortError) {
1220        throw e
1221      }
1222      logError(e)
1223    }
1224  
1225    // 1d. Tool implementation denied permission
1226    if (toolPermissionResult?.behavior === 'deny') {
1227      return toolPermissionResult
1228    }
1229  
1230    // 1e. Tool requires user interaction even in bypass mode
1231    if (
1232      tool.requiresUserInteraction?.() &&
1233      toolPermissionResult?.behavior === 'ask'
1234    ) {
1235      return toolPermissionResult
1236    }
1237  
1238    // 1f. Content-specific ask rules from tool.checkPermissions take precedence
1239    // over bypassPermissions mode. When a user explicitly configures a
1240    // content-specific ask rule (e.g. Bash(npm publish:*)), the tool's
1241    // checkPermissions returns {behavior:'ask', decisionReason:{type:'rule',
1242    // rule:{ruleBehavior:'ask'}}}. This must be respected even in bypass mode,
1243    // just as deny rules are respected at step 1d.
1244    if (
1245      toolPermissionResult?.behavior === 'ask' &&
1246      toolPermissionResult.decisionReason?.type === 'rule' &&
1247      toolPermissionResult.decisionReason.rule.ruleBehavior === 'ask'
1248    ) {
1249      return toolPermissionResult
1250    }
1251  
1252    // 1g. Safety checks (e.g. .git/, .claude/, .vscode/, shell configs) are
1253    // bypass-immune — they must prompt even in bypassPermissions mode.
1254    // checkPathSafetyForAutoEdit returns {type:'safetyCheck'} for these paths.
1255    if (
1256      toolPermissionResult?.behavior === 'ask' &&
1257      toolPermissionResult.decisionReason?.type === 'safetyCheck'
1258    ) {
1259      return toolPermissionResult
1260    }
1261  
1262    // 2a. Check if mode allows the tool to run
1263    // IMPORTANT: Call getAppState() to get the latest value
1264    appState = context.getAppState()
1265    // Check if permissions should be bypassed:
1266    // - Direct bypassPermissions mode
1267    // - Plan mode when the user originally started with bypass mode (isBypassPermissionsModeAvailable)
1268    const shouldBypassPermissions =
1269      appState.toolPermissionContext.mode === 'bypassPermissions' ||
1270      (appState.toolPermissionContext.mode === 'plan' &&
1271        appState.toolPermissionContext.isBypassPermissionsModeAvailable)
1272    if (shouldBypassPermissions) {
1273      return {
1274        behavior: 'allow',
1275        updatedInput: getUpdatedInputOrFallback(toolPermissionResult, input),
1276        decisionReason: {
1277          type: 'mode',
1278          mode: appState.toolPermissionContext.mode,
1279        },
1280      }
1281    }
1282  
1283    // 2b. Entire tool is allowed
1284    const alwaysAllowedRule = toolAlwaysAllowedRule(
1285      appState.toolPermissionContext,
1286      tool,
1287    )
1288    if (alwaysAllowedRule) {
1289      return {
1290        behavior: 'allow',
1291        updatedInput: getUpdatedInputOrFallback(toolPermissionResult, input),
1292        decisionReason: {
1293          type: 'rule',
1294          rule: alwaysAllowedRule,
1295        },
1296      }
1297    }
1298  
1299    // 3. Convert "passthrough" to "ask"
1300    const result: PermissionDecision =
1301      toolPermissionResult.behavior === 'passthrough'
1302        ? {
1303            ...toolPermissionResult,
1304            behavior: 'ask' as const,
1305            message: createPermissionRequestMessage(
1306              tool.name,
1307              toolPermissionResult.decisionReason,
1308            ),
1309          }
1310        : toolPermissionResult
1311  
1312    if (result.behavior === 'ask' && result.suggestions) {
1313      logForDebugging(
1314        `Permission suggestions for ${tool.name}: ${jsonStringify(result.suggestions, null, 2)}`,
1315      )
1316    }
1317  
1318    return result
1319  }
1320  
1321  type EditPermissionRuleArgs = {
1322    initialContext: ToolPermissionContext
1323    setToolPermissionContext: (updatedContext: ToolPermissionContext) => void
1324  }
1325  
1326  /**
1327   * Delete a permission rule from the appropriate destination
1328   */
1329  export async function deletePermissionRule({
1330    rule,
1331    initialContext,
1332    setToolPermissionContext,
1333  }: EditPermissionRuleArgs & { rule: PermissionRule }): Promise<void> {
1334    if (
1335      rule.source === 'policySettings' ||
1336      rule.source === 'flagSettings' ||
1337      rule.source === 'command'
1338    ) {
1339      throw new Error('Cannot delete permission rules from read-only settings')
1340    }
1341  
1342    const updatedContext = applyPermissionUpdate(initialContext, {
1343      type: 'removeRules',
1344      rules: [rule.ruleValue],
1345      behavior: rule.ruleBehavior,
1346      destination: rule.source as PermissionUpdateDestination,
1347    })
1348  
1349    // Per-destination logic to delete the rule from settings
1350    const destination = rule.source
1351    switch (destination) {
1352      case 'localSettings':
1353      case 'userSettings':
1354      case 'projectSettings': {
1355        // Note: Typescript doesn't know that rule conforms to `PermissionRuleFromEditableSettings` even when we switch on `rule.source`
1356        deletePermissionRuleFromSettings(
1357          rule as PermissionRuleFromEditableSettings,
1358        )
1359        break
1360      }
1361      case 'cliArg':
1362      case 'session': {
1363        // No action needed for in-memory sources - not persisted to disk
1364        break
1365      }
1366    }
1367  
1368    // Update React state with updated context
1369    setToolPermissionContext(updatedContext)
1370  }
1371  
1372  /**
1373   * Helper to convert PermissionRule array to PermissionUpdate array
1374   */
1375  function convertRulesToUpdates(
1376    rules: PermissionRule[],
1377    updateType: 'addRules' | 'replaceRules',
1378  ): PermissionUpdate[] {
1379    // Group rules by source and behavior
1380    const grouped = new Map<string, PermissionRuleValue[]>()
1381  
1382    for (const rule of rules) {
1383      const key = `${rule.source}:${rule.ruleBehavior}`
1384      if (!grouped.has(key)) {
1385        grouped.set(key, [])
1386      }
1387      grouped.get(key)!.push(rule.ruleValue)
1388    }
1389  
1390    // Convert to PermissionUpdate array
1391    const updates: PermissionUpdate[] = []
1392    for (const [key, ruleValues] of grouped) {
1393      const [source, behavior] = key.split(':')
1394      updates.push({
1395        type: updateType,
1396        rules: ruleValues,
1397        behavior: behavior as PermissionBehavior,
1398        destination: source as PermissionUpdateDestination,
1399      })
1400    }
1401  
1402    return updates
1403  }
1404  
1405  /**
1406   * Apply permission rules to context (additive - for initial setup)
1407   */
1408  export function applyPermissionRulesToPermissionContext(
1409    toolPermissionContext: ToolPermissionContext,
1410    rules: PermissionRule[],
1411  ): ToolPermissionContext {
1412    const updates = convertRulesToUpdates(rules, 'addRules')
1413    return applyPermissionUpdates(toolPermissionContext, updates)
1414  }
1415  
1416  /**
1417   * Sync permission rules from disk (replacement - for settings changes)
1418   */
1419  export function syncPermissionRulesFromDisk(
1420    toolPermissionContext: ToolPermissionContext,
1421    rules: PermissionRule[],
1422  ): ToolPermissionContext {
1423    let context = toolPermissionContext
1424  
1425    // When allowManagedPermissionRulesOnly is enabled, clear all non-policy sources
1426    if (shouldAllowManagedPermissionRulesOnly()) {
1427      const sourcesToClear: PermissionUpdateDestination[] = [
1428        'userSettings',
1429        'projectSettings',
1430        'localSettings',
1431        'cliArg',
1432        'session',
1433      ]
1434      const behaviors: PermissionBehavior[] = ['allow', 'deny', 'ask']
1435  
1436      for (const source of sourcesToClear) {
1437        for (const behavior of behaviors) {
1438          context = applyPermissionUpdate(context, {
1439            type: 'replaceRules',
1440            rules: [],
1441            behavior,
1442            destination: source,
1443          })
1444        }
1445      }
1446    }
1447  
1448    // Clear all disk-based source:behavior combos before applying new rules.
1449    // Without this, removing a rule from settings (e.g. deleting a deny entry)
1450    // would leave the old rule in the context because convertRulesToUpdates
1451    // only generates replaceRules for source:behavior pairs that have rules —
1452    // an empty group produces no update, so stale rules persist.
1453    const diskSources: PermissionUpdateDestination[] = [
1454      'userSettings',
1455      'projectSettings',
1456      'localSettings',
1457    ]
1458    for (const diskSource of diskSources) {
1459      for (const behavior of ['allow', 'deny', 'ask'] as PermissionBehavior[]) {
1460        context = applyPermissionUpdate(context, {
1461          type: 'replaceRules',
1462          rules: [],
1463          behavior,
1464          destination: diskSource,
1465        })
1466      }
1467    }
1468  
1469    const updates = convertRulesToUpdates(rules, 'replaceRules')
1470    return applyPermissionUpdates(context, updates)
1471  }
1472  
1473  /**
1474   * Extract updatedInput from a permission result, falling back to the original input.
1475   * Handles the case where some PermissionResult variants don't have updatedInput.
1476   */
1477  function getUpdatedInputOrFallback(
1478    permissionResult: PermissionResult,
1479    fallback: Record<string, unknown>,
1480  ): Record<string, unknown> {
1481    return (
1482      ('updatedInput' in permissionResult
1483        ? permissionResult.updatedInput
1484        : undefined) ?? fallback
1485    )
1486  }