mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
fix(cli): escape @ symbols on paste to prevent unintended file expansion (#21239)
This commit is contained in:
@@ -55,6 +55,7 @@ they appear in the UI.
|
|||||||
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
|
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
|
||||||
| Show Compatibility Warnings | `ui.showCompatibilityWarnings` | Show warnings about terminal or OS compatibility issues. | `true` |
|
| Show Compatibility Warnings | `ui.showCompatibilityWarnings` | Show warnings about terminal or OS compatibility issues. | `true` |
|
||||||
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
|
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
|
||||||
|
| Escape Pasted @ Symbols | `ui.escapePastedAtSymbols` | When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion. | `false` |
|
||||||
| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` |
|
| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` |
|
||||||
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
|
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
|
||||||
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` |
|
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` |
|
||||||
|
|||||||
@@ -245,6 +245,11 @@ their corresponding top-level category object in your `settings.json` file.
|
|||||||
- **Description:** Hide helpful tips in the UI
|
- **Description:** Hide helpful tips in the UI
|
||||||
- **Default:** `false`
|
- **Default:** `false`
|
||||||
|
|
||||||
|
- **`ui.escapePastedAtSymbols`** (boolean):
|
||||||
|
- **Description:** When enabled, @ symbols in pasted text are escaped to
|
||||||
|
prevent unintended @path expansion.
|
||||||
|
- **Default:** `false`
|
||||||
|
|
||||||
- **`ui.showShortcutsHint`** (boolean):
|
- **`ui.showShortcutsHint`** (boolean):
|
||||||
- **Description:** Show the "? for shortcuts" hint above the input.
|
- **Description:** Show the "? for shortcuts" hint above the input.
|
||||||
- **Default:** `true`
|
- **Default:** `true`
|
||||||
|
|||||||
@@ -540,6 +540,16 @@ const SETTINGS_SCHEMA = {
|
|||||||
description: 'Hide helpful tips in the UI',
|
description: 'Hide helpful tips in the UI',
|
||||||
showInDialog: true,
|
showInDialog: true,
|
||||||
},
|
},
|
||||||
|
escapePastedAtSymbols: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Escape Pasted @ Symbols',
|
||||||
|
category: 'UI',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: false,
|
||||||
|
description:
|
||||||
|
'When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion.',
|
||||||
|
showInDialog: true,
|
||||||
|
},
|
||||||
showShortcutsHint: {
|
showShortcutsHint: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
label: 'Show Shortcuts Hint',
|
label: 'Show Shortcuts Hint',
|
||||||
|
|||||||
@@ -263,8 +263,8 @@ export async function runNonInteractive({
|
|||||||
onDebugMessage: () => {},
|
onDebugMessage: () => {},
|
||||||
messageId: Date.now(),
|
messageId: Date.now(),
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
|
escapePastedAtSymbols: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error || !processedQuery) {
|
if (error || !processedQuery) {
|
||||||
// An error occurred during @include processing (e.g., file not found).
|
// An error occurred during @include processing (e.g., file not found).
|
||||||
// The error message is already logged by handleAtCommand.
|
// The error message is already logged by handleAtCommand.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Box, Text, useStdout, type DOMElement } from 'ink';
|
|||||||
import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
|
import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
|
||||||
import { theme } from '../semantic-colors.js';
|
import { theme } from '../semantic-colors.js';
|
||||||
import { useInputHistory } from '../hooks/useInputHistory.js';
|
import { useInputHistory } from '../hooks/useInputHistory.js';
|
||||||
|
import { escapeAtSymbols } from '../hooks/atCommandProcessor.js';
|
||||||
import { HalfLinePaddedBox } from './shared/HalfLinePaddedBox.js';
|
import { HalfLinePaddedBox } from './shared/HalfLinePaddedBox.js';
|
||||||
import {
|
import {
|
||||||
type TextBuffer,
|
type TextBuffer,
|
||||||
@@ -515,7 +516,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
stdout.write('\x1b]52;c;?\x07');
|
stdout.write('\x1b]52;c;?\x07');
|
||||||
} else {
|
} else {
|
||||||
const textToInsert = await clipboardy.read();
|
const textToInsert = await clipboardy.read();
|
||||||
buffer.insert(textToInsert, { paste: true });
|
const escapedText = settings.ui?.escapePastedAtSymbols
|
||||||
|
? escapeAtSymbols(textToInsert)
|
||||||
|
: textToInsert;
|
||||||
|
buffer.insert(escapedText, { paste: true });
|
||||||
|
|
||||||
if (isLargePaste(textToInsert)) {
|
if (isLargePaste(textToInsert)) {
|
||||||
appEvents.emit(AppEvent.TransientMessage, {
|
appEvents.emit(AppEvent.TransientMessage, {
|
||||||
message: `Press ${formatCommand(Command.EXPAND_PASTE)} to expand pasted text`,
|
message: `Press ${formatCommand(Command.EXPAND_PASTE)} to expand pasted text`,
|
||||||
@@ -750,8 +755,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
pasteTimeoutRef.current = null;
|
pasteTimeoutRef.current = null;
|
||||||
}, 40);
|
}, 40);
|
||||||
}
|
}
|
||||||
// Ensure we never accidentally interpret paste as regular input.
|
if (settings.ui?.escapePastedAtSymbols) {
|
||||||
buffer.handleInput(key);
|
buffer.handleInput({
|
||||||
|
...key,
|
||||||
|
sequence: escapeAtSymbols(key.sequence || ''),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
buffer.handleInput(key);
|
||||||
|
}
|
||||||
|
|
||||||
if (key.sequence && isLargePaste(key.sequence)) {
|
if (key.sequence && isLargePaste(key.sequence)) {
|
||||||
appEvents.emit(AppEvent.TransientMessage, {
|
appEvents.emit(AppEvent.TransientMessage, {
|
||||||
message: `Press ${formatCommand(Command.EXPAND_PASTE)} to expand pasted text`,
|
message: `Press ${formatCommand(Command.EXPAND_PASTE)} to expand pasted text`,
|
||||||
@@ -1291,6 +1303,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
forceShowShellSuggestions,
|
forceShowShellSuggestions,
|
||||||
keyMatchers,
|
keyMatchers,
|
||||||
isHelpDismissKey,
|
isHelpDismissKey,
|
||||||
|
settings,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ import {
|
|||||||
afterEach,
|
afterEach,
|
||||||
type Mock,
|
type Mock,
|
||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
import { handleAtCommand } from './atCommandProcessor.js';
|
import {
|
||||||
|
handleAtCommand,
|
||||||
|
escapeAtSymbols,
|
||||||
|
unescapeLiteralAt,
|
||||||
|
} from './atCommandProcessor.js';
|
||||||
import {
|
import {
|
||||||
FileDiscoveryService,
|
FileDiscoveryService,
|
||||||
GlobTool,
|
GlobTool,
|
||||||
@@ -1481,3 +1485,56 @@ describe('handleAtCommand', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('escapeAtSymbols', () => {
|
||||||
|
it('escapes a bare @ symbol', () => {
|
||||||
|
expect(escapeAtSymbols('test@domain.com')).toBe('test\\@domain.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes a leading @ symbol', () => {
|
||||||
|
expect(escapeAtSymbols('@scope/pkg')).toBe('\\@scope/pkg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes multiple @ symbols', () => {
|
||||||
|
expect(escapeAtSymbols('a@b and c@d')).toBe('a\\@b and c\\@d');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not double-escape an already escaped @', () => {
|
||||||
|
expect(escapeAtSymbols('test\\@domain.com')).toBe('test\\@domain.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns text with no @ unchanged', () => {
|
||||||
|
expect(escapeAtSymbols('hello world')).toBe('hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string unchanged', () => {
|
||||||
|
expect(escapeAtSymbols('')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unescapeLiteralAt', () => {
|
||||||
|
it('unescapes \\@ to @', () => {
|
||||||
|
expect(unescapeLiteralAt('test\\@domain.com')).toBe('test@domain.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unescapes a leading \\@', () => {
|
||||||
|
expect(unescapeLiteralAt('\\@scope/pkg')).toBe('@scope/pkg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unescapes multiple \\@ sequences', () => {
|
||||||
|
expect(unescapeLiteralAt('a\\@b and c\\@d')).toBe('a@b and c@d');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns text with no \\@ unchanged', () => {
|
||||||
|
expect(unescapeLiteralAt('hello world')).toBe('hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string unchanged', () => {
|
||||||
|
expect(unescapeLiteralAt('')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('roundtrips correctly with escapeAtSymbols', () => {
|
||||||
|
const input = 'user@example.com and @scope/pkg';
|
||||||
|
expect(unescapeLiteralAt(escapeAtSymbols(input))).toBe(input);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -30,6 +30,26 @@ import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
|||||||
const REF_CONTENT_HEADER = `\n${REFERENCE_CONTENT_START}`;
|
const REF_CONTENT_HEADER = `\n${REFERENCE_CONTENT_START}`;
|
||||||
const REF_CONTENT_FOOTER = `\n${REFERENCE_CONTENT_END}`;
|
const REF_CONTENT_FOOTER = `\n${REFERENCE_CONTENT_END}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escapes unescaped @ symbols so they are not interpreted as @path commands.
|
||||||
|
*/
|
||||||
|
export function escapeAtSymbols(text: string): string {
|
||||||
|
return text.replace(/(?<!\\)@/g, '\\@');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unescapes \@ back to @ correctly, preserving \\@ sequences.
|
||||||
|
*/
|
||||||
|
export function unescapeLiteralAt(text: string): string {
|
||||||
|
return text.replace(/\\@/g, (match, offset, full) => {
|
||||||
|
let backslashCount = 0;
|
||||||
|
for (let i = offset - 1; i >= 0 && full[i] === '\\'; i--) {
|
||||||
|
backslashCount++;
|
||||||
|
}
|
||||||
|
return backslashCount % 2 === 0 ? '@' : '\\@';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Regex source for the path/command part of an @ reference.
|
* Regex source for the path/command part of an @ reference.
|
||||||
* It uses strict ASCII whitespace delimiters to allow Unicode characters like NNBSP in filenames.
|
* It uses strict ASCII whitespace delimiters to allow Unicode characters like NNBSP in filenames.
|
||||||
@@ -49,6 +69,7 @@ interface HandleAtCommandParams {
|
|||||||
onDebugMessage: (message: string) => void;
|
onDebugMessage: (message: string) => void;
|
||||||
messageId: number;
|
messageId: number;
|
||||||
signal: AbortSignal;
|
signal: AbortSignal;
|
||||||
|
escapePastedAtSymbols?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HandleAtCommandResult {
|
interface HandleAtCommandResult {
|
||||||
@@ -65,7 +86,10 @@ interface AtCommandPart {
|
|||||||
* Parses a query string to find all '@<path>' commands and text segments.
|
* Parses a query string to find all '@<path>' commands and text segments.
|
||||||
* Handles \ escaped spaces within paths.
|
* Handles \ escaped spaces within paths.
|
||||||
*/
|
*/
|
||||||
function parseAllAtCommands(query: string): AtCommandPart[] {
|
function parseAllAtCommands(
|
||||||
|
query: string,
|
||||||
|
escapePastedAtSymbols = false,
|
||||||
|
): AtCommandPart[] {
|
||||||
const parts: AtCommandPart[] = [];
|
const parts: AtCommandPart[] = [];
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
|
|
||||||
@@ -85,7 +109,9 @@ function parseAllAtCommands(query: string): AtCommandPart[] {
|
|||||||
if (matchIndex > lastIndex) {
|
if (matchIndex > lastIndex) {
|
||||||
parts.push({
|
parts.push({
|
||||||
type: 'text',
|
type: 'text',
|
||||||
content: query.substring(lastIndex, matchIndex),
|
content: escapePastedAtSymbols
|
||||||
|
? unescapeLiteralAt(query.substring(lastIndex, matchIndex))
|
||||||
|
: query.substring(lastIndex, matchIndex),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +124,12 @@ function parseAllAtCommands(query: string): AtCommandPart[] {
|
|||||||
|
|
||||||
// Add remaining text
|
// Add remaining text
|
||||||
if (lastIndex < query.length) {
|
if (lastIndex < query.length) {
|
||||||
parts.push({ type: 'text', content: query.substring(lastIndex) });
|
parts.push({
|
||||||
|
type: 'text',
|
||||||
|
content: escapePastedAtSymbols
|
||||||
|
? unescapeLiteralAt(query.substring(lastIndex))
|
||||||
|
: query.substring(lastIndex),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter out empty text parts that might result from consecutive @paths or leading/trailing spaces
|
// Filter out empty text parts that might result from consecutive @paths or leading/trailing spaces
|
||||||
@@ -635,8 +666,9 @@ export async function handleAtCommand({
|
|||||||
onDebugMessage,
|
onDebugMessage,
|
||||||
messageId: userMessageTimestamp,
|
messageId: userMessageTimestamp,
|
||||||
signal,
|
signal,
|
||||||
|
escapePastedAtSymbols = false,
|
||||||
}: HandleAtCommandParams): Promise<HandleAtCommandResult> {
|
}: HandleAtCommandParams): Promise<HandleAtCommandResult> {
|
||||||
const commandParts = parseAllAtCommands(query);
|
const commandParts = parseAllAtCommands(query, escapePastedAtSymbols);
|
||||||
|
|
||||||
const { agentParts, resourceParts, fileParts } = categorizeAtCommands(
|
const { agentParts, resourceParts, fileParts } = categorizeAtCommands(
|
||||||
commandParts,
|
commandParts,
|
||||||
|
|||||||
@@ -837,8 +837,8 @@ export const useGeminiStream = (
|
|||||||
onDebugMessage,
|
onDebugMessage,
|
||||||
messageId: userMessageTimestamp,
|
messageId: userMessageTimestamp,
|
||||||
signal: abortSignal,
|
signal: abortSignal,
|
||||||
|
escapePastedAtSymbols: settings.merged.ui?.escapePastedAtSymbols,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (atCommandResult.error) {
|
if (atCommandResult.error) {
|
||||||
onDebugMessage(atCommandResult.error);
|
onDebugMessage(atCommandResult.error);
|
||||||
return { queryToSend: null, shouldProceed: false };
|
return { queryToSend: null, shouldProceed: false };
|
||||||
@@ -874,6 +874,7 @@ export const useGeminiStream = (
|
|||||||
logger,
|
logger,
|
||||||
shellModeActive,
|
shellModeActive,
|
||||||
scheduleToolCalls,
|
scheduleToolCalls,
|
||||||
|
settings,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export type HighlightToken = {
|
|||||||
// It matches any character except strict delimiters (ASCII whitespace, comma, etc.).
|
// It matches any character except strict delimiters (ASCII whitespace, comma, etc.).
|
||||||
// This supports URIs like `@file:///example.txt` and filenames with Unicode spaces (like NNBSP).
|
// This supports URIs like `@file:///example.txt` and filenames with Unicode spaces (like NNBSP).
|
||||||
const HIGHLIGHT_REGEX = new RegExp(
|
const HIGHLIGHT_REGEX = new RegExp(
|
||||||
`(^/[a-zA-Z0-9_-]+|@${AT_COMMAND_PATH_REGEX_SOURCE}|${PASTED_TEXT_PLACEHOLDER_REGEX.source})`,
|
`(^/[a-zA-Z0-9_-]+|(?<!\\\\)@${AT_COMMAND_PATH_REGEX_SOURCE}|${PASTED_TEXT_PLACEHOLDER_REGEX.source})`,
|
||||||
'g',
|
'g',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -303,6 +303,13 @@
|
|||||||
"default": false,
|
"default": false,
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"escapePastedAtSymbols": {
|
||||||
|
"title": "Escape Pasted @ Symbols",
|
||||||
|
"description": "When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion.",
|
||||||
|
"markdownDescription": "When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`",
|
||||||
|
"default": false,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"showShortcutsHint": {
|
"showShortcutsHint": {
|
||||||
"title": "Show Shortcuts Hint",
|
"title": "Show Shortcuts Hint",
|
||||||
"description": "Show the \"? for shortcuts\" hint above the input.",
|
"description": "Show the \"? for shortcuts\" hint above the input.",
|
||||||
|
|||||||
Reference in New Issue
Block a user