/** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { ToolErrorType } from '../tools/tool-error.js'; import { ApprovalMode, PolicyDecision, type CheckResult, type PolicyRule, } from '../policy/types.js'; import type { Config } from '../config/config.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; import { MessageBusType, type SerializableConfirmationDetails, } from '../confirmation-bus/types.js'; import { ToolConfirmationOutcome, type AnyDeclarativeTool, type AnyToolInvocation, type PolicyUpdateOptions, } from '../tools/tools.js'; import { buildFilePathArgsPattern } from '../policy/utils.js'; import { makeRelative } from '../utils/paths.js'; import { DiscoveredMCPTool, formatMcpToolName } from '../tools/mcp-tool.js'; import { EDIT_TOOL_NAMES } from '../tools/tool-names.js'; import type { ValidatingToolCall } from './types.js'; import type { AgentLoopContext } from '../config/agent-loop-context.js'; /** * Helper to format the policy denial error. */ export function getPolicyDenialError( config: Config, rule?: PolicyRule, ): { errorMessage: string; errorType: ToolErrorType } { const denyMessage = rule?.denyMessage ? ` ${rule.denyMessage}` : ''; return { errorMessage: `Tool execution denied by policy.${denyMessage}`, errorType: ToolErrorType.POLICY_VIOLATION, }; } /** * Queries the system PolicyEngine to determine tool allowance. * @returns The PolicyDecision. * @throws Error if policy requires ASK_USER but the CLI is non-interactive. */ export async function checkPolicy( toolCall: ValidatingToolCall, config: Config, subagent?: string, ): Promise { const serverName = toolCall.tool instanceof DiscoveredMCPTool ? toolCall.tool.serverName : undefined; const toolAnnotations = toolCall.tool.toolAnnotations; const result = await config .getPolicyEngine() .check( { name: toolCall.request.name, args: toolCall.request.args }, serverName, toolAnnotations, subagent, ); const { decision } = result; // If the tool call was initiated by the client (e.g. via a slash command), // we treat it as implicitly confirmed by the user and bypass the // confirmation prompt if the policy engine's decision is 'ASK_USER'. if ( decision === PolicyDecision.ASK_USER && toolCall.request.isClientInitiated ) { return { decision: PolicyDecision.ALLOW, rule: result.rule, }; } /* * Return the full check result including the rule that matched. * This is necessary to access metadata like custom deny messages. */ if (decision === PolicyDecision.ASK_USER) { if (!config.isInteractive()) { throw new Error( `Tool execution for "${ toolCall.tool.displayName || toolCall.tool.name }" requires user confirmation, which is not supported in non-interactive mode.`, ); } } return { decision, rule: result.rule, }; } /** * Evaluates the outcome of a user confirmation and dispatches * policy config updates. */ export async function updatePolicy( tool: AnyDeclarativeTool, outcome: ToolConfirmationOutcome, confirmationDetails: SerializableConfirmationDetails | undefined, context: AgentLoopContext, messageBus: MessageBus, toolInvocation?: AnyToolInvocation, ): Promise { // Mode Transitions (AUTO_EDIT) if (isAutoEditTransition(tool, outcome)) { context.config.setApprovalMode(ApprovalMode.AUTO_EDIT); return; } // Determine persist scope if we are persisting. let persistScope: 'workspace' | 'user' | undefined; if (outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave) { // If folder is trusted and workspace policies are enabled, we prefer workspace scope. if ( context.config && context.config.isTrustedFolder() && context.config.getWorkspacePoliciesDir() !== undefined ) { persistScope = 'workspace'; } else { persistScope = 'user'; } } // Specialized Tools (MCP) if (confirmationDetails?.type === 'mcp') { await handleMcpPolicyUpdate( tool, outcome, confirmationDetails, messageBus, persistScope, ); return; } // Generic Fallback (Shell, Info, etc.) await handleStandardPolicyUpdate( tool, outcome, confirmationDetails, messageBus, persistScope, toolInvocation, context.config, ); } /** * Returns true if the user's 'Always Allow' selection for a specific tool * should trigger a session-wide transition to AUTO_EDIT mode. */ function isAutoEditTransition( tool: AnyDeclarativeTool, outcome: ToolConfirmationOutcome, ): boolean { // TODO: This is a temporary fix to enable AUTO_EDIT mode for specific // tools. We should refactor this so that callbacks can be removed from // tools. return ( outcome === ToolConfirmationOutcome.ProceedAlways && EDIT_TOOL_NAMES.has(tool.name) ); } /** * Handles policy updates for standard tools (Shell, Info, etc.), including * session-level and persistent approvals. */ async function handleStandardPolicyUpdate( tool: AnyDeclarativeTool, outcome: ToolConfirmationOutcome, confirmationDetails: SerializableConfirmationDetails | undefined, messageBus: MessageBus, persistScope?: 'workspace' | 'user', toolInvocation?: AnyToolInvocation, config?: Config, ): Promise { if ( outcome === ToolConfirmationOutcome.ProceedAlways || outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave ) { const options: PolicyUpdateOptions = toolInvocation?.getPolicyUpdateOptions?.(outcome) || {}; if (!options.commandPrefix && confirmationDetails?.type === 'exec') { options.commandPrefix = confirmationDetails.rootCommands; } else if (!options.argsPattern && confirmationDetails?.type === 'edit') { const filePath = config ? makeRelative(confirmationDetails.filePath, config.getTargetDir()) : confirmationDetails.filePath; options.argsPattern = buildFilePathArgsPattern(filePath); } await messageBus.publish({ type: MessageBusType.UPDATE_POLICY, toolName: tool.name, persist: outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave, persistScope, ...options, }); } } /** * Handles policy updates specifically for MCP tools, including session-level * and persistent approvals. */ async function handleMcpPolicyUpdate( tool: AnyDeclarativeTool, outcome: ToolConfirmationOutcome, confirmationDetails: Extract< SerializableConfirmationDetails, { type: 'mcp' } >, messageBus: MessageBus, persistScope?: 'workspace' | 'user', ): Promise { const isMcpAlways = outcome === ToolConfirmationOutcome.ProceedAlways || outcome === ToolConfirmationOutcome.ProceedAlwaysTool || outcome === ToolConfirmationOutcome.ProceedAlwaysServer || outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave; if (!isMcpAlways) { return; } let toolName = tool.name; const persist = outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave; // If "Always allow all tools from this server", use the wildcard pattern if (outcome === ToolConfirmationOutcome.ProceedAlwaysServer) { toolName = formatMcpToolName(confirmationDetails.serverName, '*'); } await messageBus.publish({ type: MessageBusType.UPDATE_POLICY, toolName, mcpName: confirmationDetails.serverName, persist, persistScope, }); }