feat(core): improve shell redirection transparency and security (#16486)

This commit is contained in:
N. Taylor Mullen
2026-01-19 20:07:28 -08:00
committed by GitHub
parent 451e0b49cb
commit ec7413456e
16 changed files with 497 additions and 137 deletions

View File

@@ -0,0 +1,54 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeAll } from 'vitest';
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
import type {
ToolCallConfirmationDetails,
Config,
} from '@google/gemini-cli-core';
import { initializeShellParsers } from '@google/gemini-cli-core';
import { renderWithProviders } from '../../../test-utils/render.js';
describe('ToolConfirmationMessage Redirection', () => {
beforeAll(async () => {
await initializeShellParsers();
});
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
} as unknown as Config;
it('should display redirection warning and tip for redirected commands', () => {
const confirmationDetails: ToolCallConfirmationDetails = {
type: 'exec',
title: 'Confirm Shell Command',
command: 'echo "hello" > test.txt',
rootCommand: 'echo, redirection (>)',
rootCommands: ['echo'],
onConfirm: vi.fn(),
};
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
confirmationDetails={confirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={100}
/>,
);
const output = lastFrame();
expect(output).toContain('echo "hello" > test.txt');
expect(output).toContain(
'Note: Command contains redirection which can be undesirable.',
);
expect(output).toContain(
'Tip: Toggle auto-edit (Shift+Tab) to allow redirection in the future.',
);
});
});

View File

@@ -13,13 +13,23 @@ import type {
ToolCallConfirmationDetails,
Config,
} from '@google/gemini-cli-core';
import { IdeClient, ToolConfirmationOutcome } from '@google/gemini-cli-core';
import {
IdeClient,
ToolConfirmationOutcome,
hasRedirection,
} from '@google/gemini-cli-core';
import type { RadioSelectItem } from '../shared/RadioButtonSelect.js';
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { MaxSizedBox, MINIMUM_MAX_HEIGHT } from '../shared/MaxSizedBox.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { theme } from '../../semantic-colors.js';
import { useSettings } from '../../contexts/SettingsContext.js';
import {
REDIRECTION_WARNING_NOTE_LABEL,
REDIRECTION_WARNING_NOTE_TEXT,
REDIRECTION_WARNING_TIP_LABEL,
REDIRECTION_WARNING_TIP_TEXT,
} from '../../textConstants.js';
export interface ToolConfirmationMessageProps {
confirmationDetails: ToolCallConfirmationDetails;
@@ -270,30 +280,79 @@ export const ToolConfirmationMessage: React.FC<
}
} 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 tipLength =
REDIRECTION_WARNING_TIP_LABEL.length +
REDIRECTION_WARNING_TIP_TEXT.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 = (
<>
<Box height={1} />
<Box>
<Text color={theme.text.primary}>
<Text bold>{REDIRECTION_WARNING_NOTE_LABEL}</Text>
{REDIRECTION_WARNING_NOTE_TEXT}
</Text>
</Box>
<Box>
<Text color={theme.border.default}>
<Text bold>{REDIRECTION_WARNING_TIP_LABEL}</Text>
{REDIRECTION_WARNING_TIP_TEXT}
</Text>
</Box>
</>
);
}
bodyContent = (
<MaxSizedBox
maxHeight={bodyContentHeight}
maxWidth={Math.max(terminalWidth, 1)}
>
<Box flexDirection="column">
{executionProps.commands && executionProps.commands.length > 1 ? (
executionProps.commands.map((cmd, idx) => (
<Box flexDirection="column">
<MaxSizedBox
maxHeight={bodyContentHeight}
maxWidth={Math.max(terminalWidth, 1)}
>
<Box flexDirection="column">
{commandsToDisplay.map((cmd, idx) => (
<Text key={idx} color={theme.text.link}>
{cmd}
</Text>
))
) : (
<Box>
<Text color={theme.text.link}>{executionProps.command}</Text>
</Box>
)}
</Box>
</MaxSizedBox>
))}
</Box>
</MaxSizedBox>
{warnings}
</Box>
);
} else if (confirmationDetails.type === 'info') {
const infoProps = confirmationDetails;

View File

@@ -11,3 +11,10 @@ export const SCREEN_READER_MODEL_PREFIX = 'Model: ';
export const SCREEN_READER_LOADING = 'loading';
export const SCREEN_READER_RESPONDING = 'responding';
export const REDIRECTION_WARNING_NOTE_LABEL = 'Note: ';
export const REDIRECTION_WARNING_NOTE_TEXT =
'Command contains redirection which can be undesirable.';
export const REDIRECTION_WARNING_TIP_LABEL = 'Tip: '; // Padded to align with "Note: "
export const REDIRECTION_WARNING_TIP_TEXT =
'Toggle auto-edit (Shift+Tab) to allow redirection in the future.';