source dump of claude code
at main 1486 lines 52 kB view raw
1import { feature } from 'bun:bundle' 2import { APIUserAbortError } from '@anthropic-ai/sdk' 3import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' 4import { 5 getToolNameForPermissionCheck, 6 mcpInfoFromString, 7} from '../../services/mcp/mcpStringUtils.js' 8import type { Tool, ToolPermissionContext, ToolUseContext } from '../../Tool.js' 9import { AGENT_TOOL_NAME } from '../../tools/AgentTool/constants.js' 10import { shouldUseSandbox } from '../../tools/BashTool/shouldUseSandbox.js' 11import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js' 12import { POWERSHELL_TOOL_NAME } from '../../tools/PowerShellTool/toolName.js' 13import { REPL_TOOL_NAME } from '../../tools/REPLTool/constants.js' 14import type { AssistantMessage } from '../../types/message.js' 15import { extractOutputRedirections } from '../bash/commands.js' 16import { logForDebugging } from '../debug.js' 17import { AbortError, toError } from '../errors.js' 18import { logError } from '../log.js' 19import { SandboxManager } from '../sandbox/sandbox-adapter.js' 20import { 21 getSettingSourceDisplayNameLowercase, 22 SETTING_SOURCES, 23} from '../settings/constants.js' 24import { plural } from '../stringUtils.js' 25import { permissionModeTitle } from './PermissionMode.js' 26import type { 27 PermissionAskDecision, 28 PermissionDecision, 29 PermissionDecisionReason, 30 PermissionDenyDecision, 31 PermissionResult, 32} from './PermissionResult.js' 33import type { 34 PermissionBehavior, 35 PermissionRule, 36 PermissionRuleSource, 37 PermissionRuleValue, 38} from './PermissionRule.js' 39import { 40 applyPermissionUpdate, 41 applyPermissionUpdates, 42 persistPermissionUpdates, 43} from './PermissionUpdate.js' 44import type { 45 PermissionUpdate, 46 PermissionUpdateDestination, 47} from './PermissionUpdateSchema.js' 48import { 49 permissionRuleValueFromString, 50 permissionRuleValueToString, 51} from './permissionRuleParser.js' 52import { 53 deletePermissionRuleFromSettings, 54 type PermissionRuleFromEditableSettings, 55 shouldAllowManagedPermissionRulesOnly, 56} from './permissionsLoader.js' 57 58/* eslint-disable @typescript-eslint/no-require-imports */ 59const classifierDecisionModule = feature('TRANSCRIPT_CLASSIFIER') 60 ? (require('./classifierDecision.js') as typeof import('./classifierDecision.js')) 61 : null 62const autoModeStateModule = feature('TRANSCRIPT_CLASSIFIER') 63 ? (require('./autoModeState.js') as typeof import('./autoModeState.js')) 64 : null 65 66import { 67 addToTurnClassifierDuration, 68 getTotalCacheCreationInputTokens, 69 getTotalCacheReadInputTokens, 70 getTotalInputTokens, 71 getTotalOutputTokens, 72} from '../../bootstrap/state.js' 73import { getFeatureValue_CACHED_WITH_REFRESH } from '../../services/analytics/growthbook.js' 74import { 75 type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 76 logEvent, 77} from '../../services/analytics/index.js' 78import { sanitizeToolNameForAnalytics } from '../../services/analytics/metadata.js' 79import { 80 clearClassifierChecking, 81 setClassifierChecking, 82} from '../classifierApprovals.js' 83import { isInProtectedNamespace } from '../envUtils.js' 84import { executePermissionRequestHooks } from '../hooks.js' 85import { 86 AUTO_REJECT_MESSAGE, 87 buildClassifierUnavailableMessage, 88 buildYoloRejectionMessage, 89 DONT_ASK_REJECT_MESSAGE, 90} from '../messages.js' 91import { calculateCostFromTokens } from '../modelCost.js' 92/* eslint-enable @typescript-eslint/no-require-imports */ 93import { jsonStringify } from '../slowOperations.js' 94import { 95 createDenialTrackingState, 96 DENIAL_LIMITS, 97 type DenialTrackingState, 98 recordDenial, 99 recordSuccess, 100 shouldFallbackToPrompting, 101} from './denialTracking.js' 102import { 103 classifyYoloAction, 104 formatActionForClassifier, 105} from './yoloClassifier.js' 106 107const CLASSIFIER_FAIL_CLOSED_REFRESH_MS = 30 * 60 * 1000 // 30 minutes 108 109const PERMISSION_RULE_SOURCES = [ 110 ...SETTING_SOURCES, 111 'cliArg', 112 'command', 113 'session', 114] as const satisfies readonly PermissionRuleSource[] 115 116export function permissionRuleSourceDisplayString( 117 source: PermissionRuleSource, 118): string { 119 return getSettingSourceDisplayNameLowercase(source) 120} 121 122export 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 */ 137export 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 213export 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 223export 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 */ 238function 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 */ 275export 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 */ 287export 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 */ 297export 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 */ 308export 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 */ 325export 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 */ 349export 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 362export 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 */ 400async 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 473export 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 */ 963function 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 */ 984function 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 */ 1071export 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 1158async 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 1321type EditPermissionRuleArgs = { 1322 initialContext: ToolPermissionContext 1323 setToolPermissionContext: (updatedContext: ToolPermissionContext) => void 1324} 1325 1326/** 1327 * Delete a permission rule from the appropriate destination 1328 */ 1329export 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 */ 1375function 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 */ 1408export 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 */ 1419export 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 */ 1477function 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}