/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type React from 'react'; import { useMemo, useCallback, useState } from 'react'; import { Box, Text } from 'ink'; import { DiffRenderer } from './DiffRenderer.js'; import { RenderInline } from '../../utils/InlineMarkdownRenderer.js'; import { type SerializableConfirmationDetails, type Config, type ToolConfirmationPayload, ToolConfirmationOutcome, type EditorType, hasRedirection, debugLogger, } from '@google/gemini-cli-core'; import { useToolActions } from '../../contexts/ToolActionsContext.js'; import { RadioButtonSelect, type RadioSelectItem, } from '../shared/RadioButtonSelect.js'; import { MaxSizedBox, MINIMUM_MAX_HEIGHT } from '../shared/MaxSizedBox.js'; import { sanitizeForDisplay, stripUnsafeCharacters, } from '../../utils/textUtils.js'; import { useKeypress } from '../../hooks/useKeypress.js'; import { theme } from '../../semantic-colors.js'; import { useSettings } from '../../contexts/SettingsContext.js'; import { Command } from '../../key/keyMatchers.js'; import { formatCommand } from '../../key/keybindingUtils.js'; import { AskUserDialog } from '../AskUserDialog.js'; import { ExitPlanModeDialog } from '../ExitPlanModeDialog.js'; import { WarningMessage } from './WarningMessage.js'; import { colorizeCode } from '../../utils/CodeColorizer.js'; import { DialogFooter } from '../shared/DialogFooter.js'; import { getDeceptiveUrlDetails, toUnicodeUrl, type DeceptiveUrlDetails, } from '../../utils/urlSecurityUtils.js'; import { useKeyMatchers } from '../../hooks/useKeyMatchers.js'; export interface ToolConfirmationMessageProps { callId: string; confirmationDetails: SerializableConfirmationDetails; config: Config; getPreferredEditor: () => EditorType | undefined; isFocused?: boolean; availableTerminalHeight?: number; terminalWidth: number; } const REDIRECTION_WARNING_NOTE_LABEL = 'Note: '; const REDIRECTION_WARNING_NOTE_TEXT = 'Command contains redirection which can be undesirable.'; const REDIRECTION_WARNING_TIP_LABEL = 'Tip: '; // Padded to align with "Note: " export const ToolConfirmationMessage: React.FC< ToolConfirmationMessageProps > = ({ callId, confirmationDetails, config, getPreferredEditor, isFocused = true, availableTerminalHeight, terminalWidth, }) => { const keyMatchers = useKeyMatchers(); const { confirm, isDiffingEnabled } = useToolActions(); const [mcpDetailsExpansionState, setMcpDetailsExpansionState] = useState<{ callId: string; expanded: boolean; }>({ callId, expanded: false, }); const isMcpToolDetailsExpanded = mcpDetailsExpansionState.callId === callId ? mcpDetailsExpansionState.expanded : false; const settings = useSettings(); const allowPermanentApproval = settings.merged.security.enablePermanentToolApproval; const handlesOwnUI = confirmationDetails.type === 'ask_user' || confirmationDetails.type === 'exit_plan_mode'; const isTrustedFolder = config.isTrustedFolder(); const handleConfirm = useCallback( (outcome: ToolConfirmationOutcome, payload?: ToolConfirmationPayload) => { void confirm(callId, outcome, payload).catch((error: unknown) => { debugLogger.error( `Failed to handle tool confirmation for ${callId}:`, error, ); }); }, [confirm, callId], ); const mcpToolDetailsText = useMemo(() => { if (confirmationDetails.type !== 'mcp') { return null; } const detailsLines: string[] = []; const hasNonEmptyToolArgs = confirmationDetails.toolArgs !== undefined && !( typeof confirmationDetails.toolArgs === 'object' && confirmationDetails.toolArgs !== null && Object.keys(confirmationDetails.toolArgs).length === 0 ); if (hasNonEmptyToolArgs) { let argsText: string; try { argsText = stripUnsafeCharacters( JSON.stringify(confirmationDetails.toolArgs, null, 2), ); } catch { argsText = '[unserializable arguments]'; } detailsLines.push('Invocation Arguments:'); detailsLines.push(argsText); } const description = confirmationDetails.toolDescription?.trim(); if (description) { if (detailsLines.length > 0) { detailsLines.push(''); } detailsLines.push('Description:'); detailsLines.push(stripUnsafeCharacters(description)); } if (confirmationDetails.toolParameterSchema !== undefined) { let schemaText: string; try { schemaText = stripUnsafeCharacters( JSON.stringify(confirmationDetails.toolParameterSchema, null, 2), ); } catch { schemaText = '[unserializable schema]'; } if (detailsLines.length > 0) { detailsLines.push(''); } detailsLines.push('Input Schema:'); detailsLines.push(schemaText); } if (detailsLines.length === 0) { return null; } return detailsLines.join('\n'); }, [confirmationDetails]); const hasMcpToolDetails = !!mcpToolDetailsText; const expandDetailsHintKey = formatCommand(Command.SHOW_MORE_LINES); useKeypress( (key) => { if (!isFocused) return false; if ( confirmationDetails.type === 'mcp' && hasMcpToolDetails && keyMatchers[Command.SHOW_MORE_LINES](key) ) { setMcpDetailsExpansionState({ callId, expanded: !isMcpToolDetailsExpanded, }); return true; } if (keyMatchers[Command.ESCAPE](key)) { handleConfirm(ToolConfirmationOutcome.Cancel); return true; } if (keyMatchers[Command.QUIT](key)) { // Return false to let ctrl-C bubble up to AppContainer for exit flow. // AppContainer will call cancelOngoingRequest which will cancel the tool. return false; } return false; }, { isActive: isFocused, priority: true }, ); const handleSelect = useCallback( (item: ToolConfirmationOutcome) => handleConfirm(item), [handleConfirm], ); const deceptiveUrlWarnings = useMemo(() => { const urls: string[] = []; if (confirmationDetails.type === 'info' && confirmationDetails.urls) { urls.push(...confirmationDetails.urls); } else if (confirmationDetails.type === 'exec') { const commands = confirmationDetails.commands && confirmationDetails.commands.length > 0 ? confirmationDetails.commands : [confirmationDetails.command]; for (const cmd of commands) { const matches = cmd.match(/https?:\/\/[^\s"'`<>;&|()]+/g); if (matches) urls.push(...matches); } } const uniqueUrls = Array.from(new Set(urls)); return uniqueUrls .map(getDeceptiveUrlDetails) .filter((d): d is DeceptiveUrlDetails => d !== null); }, [confirmationDetails]); const deceptiveUrlWarningText = useMemo(() => { if (deceptiveUrlWarnings.length === 0) return null; return `**Warning:** Deceptive URL(s) detected:\n\n${deceptiveUrlWarnings .map( (w) => ` **Original:** ${w.originalUrl}\n **Actual Host (Punycode):** ${w.punycodeUrl}`, ) .join('\n\n')}`; }, [deceptiveUrlWarnings]); const getOptions = useCallback(() => { const options: Array> = []; if (confirmationDetails.type === 'edit') { if (!confirmationDetails.isModifying) { options.push({ label: 'Allow once', value: ToolConfirmationOutcome.ProceedOnce, key: 'Allow once', }); if (isTrustedFolder) { options.push({ label: 'Allow for this session', value: ToolConfirmationOutcome.ProceedAlways, key: 'Allow for this session', }); if (allowPermanentApproval) { options.push({ label: 'Allow for this file in all future sessions', value: ToolConfirmationOutcome.ProceedAlwaysAndSave, key: 'Allow for this file in all future sessions', }); } } // We hide "Modify with external editor" if IDE mode is active AND // the IDE is actually capable of showing a diff (connected). if (!config.getIdeMode() || !isDiffingEnabled) { options.push({ label: 'Modify with external editor', value: ToolConfirmationOutcome.ModifyWithEditor, key: 'Modify with external editor', }); } options.push({ label: 'No, suggest changes (esc)', value: ToolConfirmationOutcome.Cancel, key: 'No, suggest changes (esc)', }); } } else if (confirmationDetails.type === 'exec') { options.push({ label: 'Allow once', value: ToolConfirmationOutcome.ProceedOnce, key: 'Allow once', }); if (isTrustedFolder) { options.push({ label: `Allow for this session`, value: ToolConfirmationOutcome.ProceedAlways, key: `Allow for this session`, }); if (allowPermanentApproval) { options.push({ label: `Allow this command for all future sessions`, value: ToolConfirmationOutcome.ProceedAlwaysAndSave, key: `Allow for all future sessions`, }); } } options.push({ label: 'No, suggest changes (esc)', value: ToolConfirmationOutcome.Cancel, key: 'No, suggest changes (esc)', }); } else if (confirmationDetails.type === 'info') { options.push({ label: 'Allow once', value: ToolConfirmationOutcome.ProceedOnce, key: 'Allow once', }); if (isTrustedFolder) { options.push({ label: 'Allow for this session', value: ToolConfirmationOutcome.ProceedAlways, key: 'Allow for this session', }); if (allowPermanentApproval) { options.push({ label: 'Allow for all future sessions', value: ToolConfirmationOutcome.ProceedAlwaysAndSave, key: 'Allow for all future sessions', }); } } options.push({ label: 'No, suggest changes (esc)', value: ToolConfirmationOutcome.Cancel, key: 'No, suggest changes (esc)', }); } else if (confirmationDetails.type === 'mcp') { // mcp tool confirmation options.push({ label: 'Allow once', value: ToolConfirmationOutcome.ProceedOnce, key: 'Allow once', }); if (isTrustedFolder) { options.push({ label: 'Allow tool for this session', value: ToolConfirmationOutcome.ProceedAlwaysTool, key: 'Allow tool for this session', }); options.push({ label: 'Allow all server tools for this session', value: ToolConfirmationOutcome.ProceedAlwaysServer, key: 'Allow all server tools for this session', }); if (allowPermanentApproval) { options.push({ label: 'Allow tool for all future sessions', value: ToolConfirmationOutcome.ProceedAlwaysAndSave, key: 'Allow tool for all future sessions', }); } } options.push({ label: 'No, suggest changes (esc)', value: ToolConfirmationOutcome.Cancel, key: 'No, suggest changes (esc)', }); } return options; }, [ confirmationDetails, isTrustedFolder, allowPermanentApproval, config, isDiffingEnabled, ]); const availableBodyContentHeight = useCallback(() => { if (availableTerminalHeight === undefined) { return undefined; } if (handlesOwnUI) { return availableTerminalHeight; } // Calculate the vertical space (in lines) consumed by UI elements // surrounding the main body content. const PADDING_OUTER_Y = 2; // Main container has `padding={1}` (top & bottom). const MARGIN_BODY_BOTTOM = 1; // margin on the body container. const HEIGHT_QUESTION = 1; // The question text is one line. const MARGIN_QUESTION_BOTTOM = 1; // Margin on the question container. const optionsCount = getOptions().length; const surroundingElementsHeight = PADDING_OUTER_Y + MARGIN_BODY_BOTTOM + HEIGHT_QUESTION + MARGIN_QUESTION_BOTTOM + optionsCount + 1; // Reserve one line for 'ShowMoreLines' hint return Math.max(availableTerminalHeight - surroundingElementsHeight, 1); }, [availableTerminalHeight, getOptions, handlesOwnUI]); const { question, bodyContent, options, securityWarnings, initialIndex } = useMemo<{ question: string; bodyContent: React.ReactNode; options: Array>; securityWarnings: React.ReactNode; initialIndex: number; }>(() => { let bodyContent: React.ReactNode | null = null; let securityWarnings: React.ReactNode | null = null; let question = ''; const options = getOptions(); let initialIndex = 0; if (isTrustedFolder && allowPermanentApproval) { // It is safe to allow permanent approval for info, edit, and mcp tools // in trusted folders because the generated policy rules are narrowed // to specific files, patterns, or tools (rather than allowing all access). const isSafeToPersist = confirmationDetails.type === 'info' || confirmationDetails.type === 'edit' || confirmationDetails.type === 'mcp'; if ( isSafeToPersist && settings.merged.security.autoAddToPolicyByDefault ) { const alwaysAndSaveIndex = options.findIndex( (o) => o.value === ToolConfirmationOutcome.ProceedAlwaysAndSave, ); if (alwaysAndSaveIndex !== -1) { initialIndex = alwaysAndSaveIndex; } } } if (deceptiveUrlWarningText) { securityWarnings = ; } if (confirmationDetails.type === 'ask_user') { bodyContent = ( { handleConfirm(ToolConfirmationOutcome.ProceedOnce, { answers }); }} onCancel={() => { handleConfirm(ToolConfirmationOutcome.Cancel); }} width={terminalWidth} availableHeight={availableBodyContentHeight()} /> ); return { question: '', bodyContent, options: [], securityWarnings: null, initialIndex: 0, }; } if (confirmationDetails.type === 'exit_plan_mode') { bodyContent = ( { handleConfirm(ToolConfirmationOutcome.ProceedOnce, { approved: true, approvalMode, }); }} onFeedback={(feedback) => { handleConfirm(ToolConfirmationOutcome.ProceedOnce, { approved: false, feedback, }); }} onCancel={() => { handleConfirm(ToolConfirmationOutcome.Cancel); }} width={terminalWidth} availableHeight={availableBodyContentHeight()} /> ); return { question: '', bodyContent, options: [], securityWarnings: null, initialIndex: 0, }; } if (confirmationDetails.type === 'edit') { if (!confirmationDetails.isModifying) { question = `Apply this change?`; } } else if (confirmationDetails.type === 'exec') { const executionProps = confirmationDetails; if (executionProps.commands && executionProps.commands.length > 1) { question = `Allow execution of ${executionProps.commands.length} commands?`; } else { question = `Allow execution of: '${sanitizeForDisplay(executionProps.rootCommand)}'?`; } } else if (confirmationDetails.type === 'info') { question = `Do you want to proceed?`; } else if (confirmationDetails.type === 'mcp') { // mcp tool confirmation const mcpProps = confirmationDetails; question = `Allow execution of MCP tool "${sanitizeForDisplay(mcpProps.toolName)}" from server "${sanitizeForDisplay(mcpProps.serverName)}"?`; } if (confirmationDetails.type === 'edit') { if (!confirmationDetails.isModifying) { bodyContent = ( ); } } else if (confirmationDetails.type === 'exec') { const executionProps = confirmationDetails; const commandsToDisplay = executionProps.commands && executionProps.commands.length > 1 ? executionProps.commands : [executionProps.command]; const containsRedirection = commandsToDisplay.some((cmd) => hasRedirection(cmd), ); let bodyContentHeight = availableBodyContentHeight(); let warnings: React.ReactNode = null; if (bodyContentHeight !== undefined) { bodyContentHeight -= 2; // Account for padding; } if (containsRedirection) { // Calculate lines needed for Note and Tip const safeWidth = Math.max(terminalWidth, 1); const noteLength = REDIRECTION_WARNING_NOTE_LABEL.length + REDIRECTION_WARNING_NOTE_TEXT.length; const tipText = `Toggle auto-edit (${formatCommand(Command.CYCLE_APPROVAL_MODE)}) to allow redirection in the future.`; const tipLength = REDIRECTION_WARNING_TIP_LABEL.length + tipText.length; const noteLines = Math.ceil(noteLength / safeWidth); const tipLines = Math.ceil(tipLength / safeWidth); const spacerLines = 1; const warningHeight = noteLines + tipLines + spacerLines; if (bodyContentHeight !== undefined) { bodyContentHeight = Math.max( bodyContentHeight - warningHeight, MINIMUM_MAX_HEIGHT, ); } warnings = ( <> {REDIRECTION_WARNING_NOTE_LABEL} {REDIRECTION_WARNING_NOTE_TEXT} {REDIRECTION_WARNING_TIP_LABEL} {tipText} ); } bodyContent = ( {commandsToDisplay.map((cmd, idx) => ( {colorizeCode({ code: cmd, language: 'bash', maxWidth: Math.max(terminalWidth, 1), settings, hideLineNumbers: true, })} ))} {warnings} ); } else if (confirmationDetails.type === 'info') { const infoProps = confirmationDetails; const displayUrls = infoProps.urls && !( infoProps.urls.length === 1 && infoProps.urls[0] === infoProps.prompt ); bodyContent = ( {displayUrls && infoProps.urls && infoProps.urls.length > 0 && ( URLs to fetch: {infoProps.urls.map((urlString) => ( {' '} - ))} )} ); } else if (confirmationDetails.type === 'mcp') { // mcp tool confirmation const mcpProps = confirmationDetails; bodyContent = ( <> MCP Server: {sanitizeForDisplay(mcpProps.serverName)} Tool: {sanitizeForDisplay(mcpProps.toolName)} {hasMcpToolDetails && ( MCP Tool Details: {isMcpToolDetailsExpanded ? ( <> (press {expandDetailsHintKey} to collapse MCP tool details) {mcpToolDetailsText} ) : ( (press {expandDetailsHintKey} to expand MCP tool details) )} )} ); } return { question, bodyContent, options, securityWarnings, initialIndex }; }, [ confirmationDetails, getOptions, availableBodyContentHeight, terminalWidth, handleConfirm, deceptiveUrlWarningText, isMcpToolDetailsExpanded, hasMcpToolDetails, mcpToolDetailsText, expandDetailsHintKey, getPreferredEditor, isTrustedFolder, allowPermanentApproval, settings, ]); const bodyOverflowDirection: 'top' | 'bottom' = confirmationDetails.type === 'mcp' && isMcpToolDetailsExpanded ? 'bottom' : 'top'; if (confirmationDetails.type === 'edit') { if (confirmationDetails.isModifying) { return ( Modify in progress: Save and close external editor to continue ); } } return ( {handlesOwnUI ? ( bodyContent ) : ( <> {bodyContent} {securityWarnings && ( {securityWarnings} )} {question} )} ); };