utils/permissions/permissions.ts
51 KB1487 lines
src/utils/permissions/permissions.ts
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'5758/* eslint-disable @typescript-eslint/no-require-imports */59const classifierDecisionModule = feature('TRANSCRIPT_CLASSIFIER')60 ? (require('./classifierDecision.js') as typeof import('./classifierDecision.js'))61 : null62const autoModeStateModule = feature('TRANSCRIPT_CLASSIFIER')63 ? (require('./autoModeState.js') as typeof import('./autoModeState.js'))64 : null6566import {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'106107const CLASSIFIER_FAIL_CLOSED_REFRESH_MS = 30 * 60 * 1000 // 30 minutes108109const PERMISSION_RULE_SOURCES = [110 ...SETTING_SOURCES,111 'cliArg',112 'command',113 'session',114] as const satisfies readonly PermissionRuleSource[]115116export function permissionRuleSourceDisplayString(117 source: PermissionRuleSource,118): string {119 return getSettingSourceDisplayNameLowercase(source)120}121122export 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}133134/**135 * Creates a permission request message that explain the permission request136 */137export function createPermissionRequestMessage(138 toolName: string,139 decisionReason?: PermissionDecisionReason,140): string {141 // Handle different decision reason types142 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.reason152 ? `Hook '${decisionReason.hookName}' blocked this action: ${decisionReason.reason}`153 : `Hook '${decisionReason.hookName}' requires approval for this ${toolName} command`154 return hookMessage155 }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 commands170 // Only do this for Bash tool to avoid affecting other tools171 if (toolName === 'Bash') {172 const { commandWithoutRedirections, redirections } =173 extractOutputRedirections(cmd)174 // Only use stripped version if there were actual redirections175 const displayCmd =176 redirections.length > 0 ? commandWithoutRedirections : cmd177 needsApproval.push(displayCmd)178 } else {179 needsApproval.push(cmd)180 }181 }182 }183 if (needsApproval.length > 0) {184 const n = needsApproval.length185 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.reason195 case 'safetyCheck':196 case 'other':197 return decisionReason.reason198 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.reason204 }205 }206207 // Default message without listing allowed commands208 const message = `Claude requested permissions to use ${toolName}, but you haven't granted it yet.`209210 return message211}212213export 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}222223export 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}232233/**234 * Check if the entire tool matches a rule235 * For example, this matches "Bash" but not "Bash(prefix:*)" for BashTool236 * 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 tool243 if (rule.ruleValue.ruleContent !== undefined) {244 return false245 }246247 // MCP tools are matched by their fully qualified mcp__server__tool name. In248 // skip-prefix mode (CLAUDE_AGENT_SDK_MCP_NO_PREFIX), MCP tools have unprefixed249 // display names (e.g., "Write") that collide with builtin names; rules targeting250 // builtins should not match their MCP replacements.251 const nameForRuleMatch = getToolNameForPermissionCheck(tool)252253 // Direct tool name match254 if (rule.ruleValue.toolName === nameForRuleMatch) {255 return true256 }257258 // MCP server-level permission: rule "mcp__server1" matches tool "mcp__server1__tool1"259 // Also supports wildcard: rule "mcp__server1__*" matches all tools from server1260 const ruleInfo = mcpInfoFromString(rule.ruleValue.toolName)261 const toolInfo = mcpInfoFromString(nameForRuleMatch)262263 return (264 ruleInfo !== null &&265 toolInfo !== null &&266 (ruleInfo.toolName === undefined || ruleInfo.toolName === '*') &&267 ruleInfo.serverName === toolInfo.serverName268 )269}270271/**272 * Check if the entire tool is listed in the always allow rules273 * For example, this finds "Bash" but not "Bash(prefix:*)" for BashTool274 */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)) || null281 )282}283284/**285 * Check if the tool is listed in the always deny rules286 */287export function getDenyRuleForTool(288 context: ToolPermissionContext,289 tool: Pick<Tool, 'name' | 'mcpInfo'>,290): PermissionRule | null {291 return getDenyRules(context).find(rule => toolMatchesRule(tool, rule)) || null292}293294/**295 * Check if the tool is listed in the always ask rules296 */297export function getAskRuleForTool(298 context: ToolPermissionContext,299 tool: Pick<Tool, 'name' | 'mcpInfo'>,300): PermissionRule | null {301 return getAskRules(context).find(rule => toolMatchesRule(tool, rule)) || null302}303304/**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 ) || null319 )320}321322/**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-parsed332 // 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 !== undefined338 ) {339 deniedAgentTypes.add(rule.ruleValue.ruleContent)340 }341 }342 return agents.filter(agent => !deniedAgentTypes.has(agent.agentType))343}344345/**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 BashTool348 */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}360361// Used to break circular dependency where a Tool calls this function362export 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 break373 case 'deny':374 rules = getDenyRules(context)375 break376 case 'ask':377 rules = getAskRules(context)378 break379 }380 for (const rule of rules) {381 if (382 rule.ruleValue.toolName === toolName &&383 rule.ruleValue.ruleContent !== undefined &&384 rule.ruleBehavior === behavior385 ) {386 ruleByContents.set(rule.ruleValue.ruleContent, rule)387 }388 }389 return ruleByContents390}391392/**393 * Runs PermissionRequest hooks for headless/async agents that cannot show394 * permission prompts. This gives hooks an opportunity to allow or deny395 * tool use before the fallback auto-deny kicks in.396 *397 * Returns a PermissionDecision if a hook made a decision, or null if no398 * 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 continue420 }421 const decision = hookResult.permissionRequestResult422 if (decision.behavior === 'allow') {423 const finalInput = decision.updatedInput ?? input424 // Persist permission updates if provided425 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 crashing464 logError(465 new Error('PermissionRequest hook failed for headless agent', {466 cause: toError(error),467 }),468 )469 }470 return null471}472473export 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)481482483 // 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.denialTracking491 if (492 appState.toolPermissionContext.mode === 'auto' &&493 currentDenialState &&494 currentDenialState.consecutiveDenials > 0495 ) {496 const newDenialState = recordSuccess(currentDenialState)497 persistDenialState(context, newDenialState)498 }499 }500 return result501 }502503 // Apply dontAsk mode transformation: convert 'ask' to 'deny'504 // This is done at the end so it can't be bypassed by early returns505 if (result.behavior === 'ask') {506 const appState = context.getAppState()507508 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 user519 // Check this BEFORE shouldAvoidPermissionPrompts so classifiers work in headless mode520 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 ALL527 // auto-approve paths: the acceptEdits fast-path, the safe-tool allowlist,528 // and the classifier. Step 1g only guards bypassPermissions; this guards529 // auto. classifierApprovable safetyChecks (sensitive-file paths) fall530 // through to the classifier — the fast-paths below naturally don't fire531 // because the tool's own checkPermissions still returns 'ask'.532 if (533 result.decisionReason?.type === 'safetyCheck' &&534 !result.decisionReason.classifierApprovable535 ) {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 result548 }549 if (tool.requiresUserInteraction?.() && result.behavior === 'ask') {550 return result551 }552553 // Use local denial tracking for async subagents (whose setAppState554 // is a no-op), otherwise read from appState as before.555 const denialState =556 context.localDenialTracking ??557 appState.denialTracking ??558 createDenialTrackingState()559560 // PowerShell requires explicit user permission in auto mode unless561 // POWERSHELL_AUTO_MODE (ant-only build flag) is on. When disabled, this562 // guard keeps PS out of the classifier and skips the acceptEdits563 // fast-path below. When enabled, PS flows through to the classifier like564 // Bash — the classifier prompt gets POWERSHELL_DENY_GUIDANCE appended so565 // it recognizes `iex (iwr ...)` as download-and-execute, etc.566 // Note: this runs inside the behavior === 'ask' branch, so allow rules567 // that fire earlier (step 2b toolAlwaysAllowedRule, PS prefix allow)568 // return before reaching here. Allow-rule protection is handled by569 // permissionSetup.ts: isOverlyBroadPowerShellAllowRule strips PowerShell(*)570 // and isDangerousPowerShellPermission strips iex/pwsh/Start-Process571 // 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 result591 }592593 // Before running the auto mode classifier, check if acceptEdits mode would594 // allow this action. This avoids expensive classifier API calls for safe595 // operations like file edits in the working directory.596 // Skip for Agent and REPL — their checkPermissions returns 'allow' for597 // acceptEdits mode, which would silently bypass the classifier. REPL598 // code can contain VM escapes between inner tool calls; the classifier599 // 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_NAME604 ) {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. Joins633 // the decision back to the main agent's API response.634 agentMsgId: assistantMessage.message635 .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 e653 }654 // If the acceptEdits check fails, fall through to the classifier655 }656 }657658 // 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.message672 .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 }687688 // Run the auto mode classifier689 const action = formatActionForClassifier(tool.name, input)690 setClassifierChecking(toolUseID)691 let classifierResult692 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 }703704 // Notify ants when classifier error dumped prompts (will be in /share)705 if (706 process.env.USER_TYPE === 'ant' &&707 classifierResult.errorDumpPath &&708 context.addNotification709 ) {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 }717718 // Log classifier decision for metrics (including overhead telemetry)719 const yoloDecision = classifierResult.unavailable720 ? 'unavailable'721 : classifierResult.shouldBlock722 ? 'blocked'723 : 'allowed'724725 // Compute classifier cost in USD for overhead analysis726 const classifierCostUSD =727 classifierResult.usage && classifierResult.model728 ? calculateCostFromTokens(729 classifierResult.model,730 classifierResult.usage,731 )732 : undefined733 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.message741 .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.shouldBlock745 ? denialState.consecutiveDenials + 1746 : 0,747 totalDenials: classifierResult.shouldBlock748 ? denialState.totalDenials + 1749 : denialState.totalDenials,750 // Overhead telemetry: token usage and latency for the classifier API call751 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 classifier759 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.model788 ? 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.model807 ? calculateCostFromTokens(808 classifierResult.model,809 classifierResult.stage2Usage,810 )811 : undefined,812 })813814 if (classifierResult.durationMs !== undefined) {815 addToTurnClassifierDuration(classifierResult.durationMs)816 }817818 if (classifierResult.shouldBlock) {819 // Transcript exceeded the classifier's context window — deterministic820 // error, won't recover on retry. Skip iron_gate and fall back to821 // 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-deny825 // 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 on844 // 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 handling871 logForDebugging(872 'Auto mode classifier unavailable, falling back to normal permission handling (fail open)',873 { level: 'warn' },874 )875 return result876 }877878 // Update denial tracking and check limits879 const newDenialState = recordDenial(denialState)880 persistDenialState(context, newDenialState)881882 logForDebugging(883 `Auto mode classifier blocked action: ${classifierResult.reason}`,884 { level: 'warn' },885 )886887 // If denial limit hit, fall back to prompting so the user888 // can review. We check after the classifier so we can include889 // 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 denialLimitResult901 }902903 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 }913914 // Reset consecutive denials on success915 const newDenialState = recordSuccess(denialState)916 persistDenialState(context, newDenialState)917918 return {919 behavior: 'allow',920 updatedInput: input,921 decisionReason: {922 type: 'classifier',923 classifier: 'auto-mode',924 reason: classifierResult.reason,925 },926 }927 }928929 // 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 hookDecision943 }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 }954955 return result956}957958/**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 is972 // unchanged. Returning prev here lets store.setState's Object.is check973 // skip the listener loop entirely.974 if (prev.denialTracking === newState) return prev975 return { ...prev, denialTracking: newState }976 })977 }978}979980/**981 * Check if a denial limit was exceeded and return an 'ask' result982 * 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 null997 }998999 const hitTotalLimit = denialState.totalDenials >= DENIAL_LIMITS.maxTotal1000 const isHeadless = appState.toolPermissionContext.shouldAvoidPermissionPrompts1001 // Capture counts before persistDenialState, which may mutate denialState1002 // in-place via Object.assign for subagents with localDenialTracking.1003 const totalCount = denialState.totalDenials1004 const consecutiveCount = denialState.consecutiveDenials1005 const warning = hitTotalLimit1006 ? `${totalCount} actions were blocked this session. Please review the transcript before continuing.`1007 : `${consecutiveCount} consecutive actions were blocked. Please review the transcript before continuing.`10081009 logEvent('tengu_auto_mode_denial_limit_exceeded', {1010 limit: (hitTotalLimit1011 ? 'total'1012 : 'consecutive') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,1013 mode: (isHeadless1014 ? 'headless'1015 : 'cli') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,1016 messageID: assistantMessage.message1017 .id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,1018 consecutiveDenials: consecutiveCount,1019 totalDenials: totalCount,1020 toolName: sanitizeToolNameForAnalytics(tool.name),1021 })10221023 if (isHeadless) {1024 throw new AbortError(1025 'Agent aborted: too many classifier denials in headless mode',1026 )1027 }10281029 logForDebugging(1030 `Classifier denial limit exceeded, falling back to prompting: ${warning}`,1031 { level: 'warn' },1032 )10331034 if (hitTotalLimit) {1035 persistDenialState(context, {1036 ...denialState,1037 totalDenials: 0,1038 consecutiveDenials: 0,1039 })1040 }10411042 // Preserve the original classifier value (e.g. 'dangerous-agent-action')1043 // so downstream analytics in interactiveHandler can log the correct1044 // user override event.1045 const originalClassifier =1046 result.decisionReason?.type === 'classifier'1047 ? result.decisionReason.classifier1048 : 'auto-mode'10491050 return {1051 ...result,1052 decisionReason: {1053 type: 'classifier',1054 classifier: originalClassifier,1055 reason: `${warning}\n\nLatest blocked action: ${classifierReason}`,1056 },1057 }1058}10591060/**1061 * Check only the rule-based steps of the permission pipeline — the subset1062 * 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 rule1065 * 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()10771078 // 1a. Entire tool is denied by rule1079 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 }10901091 // 1b. Entire tool has an ask rule1092 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)10991100 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 rules1111 }11121113 // 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 e1124 }1125 logError(e)1126 }11271128 // 1d. Tool implementation denied (catches bash subcommand denies wrapped1129 // in subcommandResults — no need to inspect decisionReason.type)1130 if (toolPermissionResult?.behavior === 'deny') {1131 return toolPermissionResult1132 }11331134 // 1f. Content-specific ask rules from tool.checkPermissions1135 // (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 toolPermissionResult1142 }11431144 // 1g. Safety checks (e.g. .git/, .claude/, .vscode/, shell configs) are1145 // bypass-immune — they must prompt even when a PreToolUse hook returned1146 // allow. checkPathSafetyForAutoEdit returns {type:'safetyCheck'} for these.1147 if (1148 toolPermissionResult?.behavior === 'ask' &&1149 toolPermissionResult.decisionReason?.type === 'safetyCheck'1150 ) {1151 return toolPermissionResult1152 }11531154 // No rule-based objection1155 return null1156}11571158async 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 }11661167 let appState = context.getAppState()11681169 // 1. Check if the tool is denied1170 // 1a. Entire tool is denied1171 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 }11821183 // 1b. Check if the entire tool should always ask for permission1184 const askRule = getAskRuleForTool(appState.toolPermissionContext, tool)1185 if (askRule) {1186 // When autoAllowBashIfSandboxed is on, sandboxed commands skip the ask rule and1187 // auto-allow via Bash's checkPermissions. Commands that won't be sandboxed (excluded1188 // 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)11941195 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 rules1206 }12071208 // 1c. Ask the tool implementation for a permission result1209 // Overridden unless tool input schema is not valid1210 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 properly1219 if (e instanceof AbortError || e instanceof APIUserAbortError) {1220 throw e1221 }1222 logError(e)1223 }12241225 // 1d. Tool implementation denied permission1226 if (toolPermissionResult?.behavior === 'deny') {1227 return toolPermissionResult1228 }12291230 // 1e. Tool requires user interaction even in bypass mode1231 if (1232 tool.requiresUserInteraction?.() &&1233 toolPermissionResult?.behavior === 'ask'1234 ) {1235 return toolPermissionResult1236 }12371238 // 1f. Content-specific ask rules from tool.checkPermissions take precedence1239 // over bypassPermissions mode. When a user explicitly configures a1240 // content-specific ask rule (e.g. Bash(npm publish:*)), the tool's1241 // 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 toolPermissionResult1250 }12511252 // 1g. Safety checks (e.g. .git/, .claude/, .vscode/, shell configs) are1253 // 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 toolPermissionResult1260 }12611262 // 2a. Check if mode allows the tool to run1263 // IMPORTANT: Call getAppState() to get the latest value1264 appState = context.getAppState()1265 // Check if permissions should be bypassed:1266 // - Direct bypassPermissions mode1267 // - 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 }12821283 // 2b. Entire tool is allowed1284 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 }12981299 // 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 : toolPermissionResult13111312 if (result.behavior === 'ask' && result.suggestions) {1313 logForDebugging(1314 `Permission suggestions for ${tool.name}: ${jsonStringify(result.suggestions, null, 2)}`,1315 )1316 }13171318 return result1319}13201321type EditPermissionRuleArgs = {1322 initialContext: ToolPermissionContext1323 setToolPermissionContext: (updatedContext: ToolPermissionContext) => void1324}13251326/**1327 * Delete a permission rule from the appropriate destination1328 */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 }13411342 const updatedContext = applyPermissionUpdate(initialContext, {1343 type: 'removeRules',1344 rules: [rule.ruleValue],1345 behavior: rule.ruleBehavior,1346 destination: rule.source as PermissionUpdateDestination,1347 })13481349 // Per-destination logic to delete the rule from settings1350 const destination = rule.source1351 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 break1360 }1361 case 'cliArg':1362 case 'session': {1363 // No action needed for in-memory sources - not persisted to disk1364 break1365 }1366 }13671368 // Update React state with updated context1369 setToolPermissionContext(updatedContext)1370}13711372/**1373 * Helper to convert PermissionRule array to PermissionUpdate array1374 */1375function convertRulesToUpdates(1376 rules: PermissionRule[],1377 updateType: 'addRules' | 'replaceRules',1378): PermissionUpdate[] {1379 // Group rules by source and behavior1380 const grouped = new Map<string, PermissionRuleValue[]>()13811382 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 }13891390 // Convert to PermissionUpdate array1391 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 }14011402 return updates1403}14041405/**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}14151416/**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 = toolPermissionContext14241425 // When allowManagedPermissionRulesOnly is enabled, clear all non-policy sources1426 if (shouldAllowManagedPermissionRulesOnly()) {1427 const sourcesToClear: PermissionUpdateDestination[] = [1428 'userSettings',1429 'projectSettings',1430 'localSettings',1431 'cliArg',1432 'session',1433 ]1434 const behaviors: PermissionBehavior[] = ['allow', 'deny', 'ask']14351436 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 }14471448 // 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 convertRulesToUpdates1451 // 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 }14681469 const updates = convertRulesToUpdates(rules, 'replaceRules')1470 return applyPermissionUpdates(context, updates)1471}14721473/**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 permissionResult1483 ? permissionResult.updatedInput1484 : undefined) ?? fallback1485 )1486}1487