fix(cli): escape @ symbols on paste to prevent unintended file expansion (#21239)

This commit is contained in:
krishdef7
2026-03-13 03:35:12 +05:30
committed by GitHub
parent 4d393f9dca
commit 19e0b1ff7d
10 changed files with 137 additions and 11 deletions

View File

@@ -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 Compatibility Warnings | `ui.showCompatibilityWarnings` | Show warnings about terminal or OS compatibility issues. | `true` |
| 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` |
| 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` |

View File

@@ -245,6 +245,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Hide helpful tips in the UI
- **Default:** `false`
- **`ui.escapePastedAtSymbols`** (boolean):
- **Description:** When enabled, @ symbols in pasted text are escaped to
prevent unintended @path expansion.
- **Default:** `false`
- **`ui.showShortcutsHint`** (boolean):
- **Description:** Show the "? for shortcuts" hint above the input.
- **Default:** `true`

View File

@@ -540,6 +540,16 @@ const SETTINGS_SCHEMA = {
description: 'Hide helpful tips in the UI',
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: {
type: 'boolean',
label: 'Show Shortcuts Hint',

View File

@@ -263,8 +263,8 @@ export async function runNonInteractive({
onDebugMessage: () => {},
messageId: Date.now(),
signal: abortController.signal,
escapePastedAtSymbols: false,
});
if (error || !processedQuery) {
// An error occurred during @include processing (e.g., file not found).
// The error message is already logged by handleAtCommand.

View File

@@ -11,6 +11,7 @@ import { Box, Text, useStdout, type DOMElement } from 'ink';
import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
import { theme } from '../semantic-colors.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
import { escapeAtSymbols } from '../hooks/atCommandProcessor.js';
import { HalfLinePaddedBox } from './shared/HalfLinePaddedBox.js';
import {
type TextBuffer,
@@ -515,7 +516,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
stdout.write('\x1b]52;c;?\x07');
} else {
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)) {
appEvents.emit(AppEvent.TransientMessage, {
message: `Press ${formatCommand(Command.EXPAND_PASTE)} to expand pasted text`,
@@ -750,8 +755,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
pasteTimeoutRef.current = null;
}, 40);
}
// Ensure we never accidentally interpret paste as regular input.
buffer.handleInput(key);
if (settings.ui?.escapePastedAtSymbols) {
buffer.handleInput({
...key,
sequence: escapeAtSymbols(key.sequence || ''),
});
} else {
buffer.handleInput(key);
}
if (key.sequence && isLargePaste(key.sequence)) {
appEvents.emit(AppEvent.TransientMessage, {
message: `Press ${formatCommand(Command.EXPAND_PASTE)} to expand pasted text`,
@@ -1291,6 +1303,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
forceShowShellSuggestions,
keyMatchers,
isHelpDismissKey,
settings,
],
);

View File

@@ -13,7 +13,11 @@ import {
afterEach,
type Mock,
} from 'vitest';
import { handleAtCommand } from './atCommandProcessor.js';
import {
handleAtCommand,
escapeAtSymbols,
unescapeLiteralAt,
} from './atCommandProcessor.js';
import {
FileDiscoveryService,
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);
});
});

View File

@@ -30,6 +30,26 @@ import type { UseHistoryManagerReturn } from './useHistoryManager.js';
const REF_CONTENT_HEADER = `\n${REFERENCE_CONTENT_START}`;
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.
* It uses strict ASCII whitespace delimiters to allow Unicode characters like NNBSP in filenames.
@@ -49,6 +69,7 @@ interface HandleAtCommandParams {
onDebugMessage: (message: string) => void;
messageId: number;
signal: AbortSignal;
escapePastedAtSymbols?: boolean;
}
interface HandleAtCommandResult {
@@ -65,7 +86,10 @@ interface AtCommandPart {
* Parses a query string to find all '@<path>' commands and text segments.
* Handles \ escaped spaces within paths.
*/
function parseAllAtCommands(query: string): AtCommandPart[] {
function parseAllAtCommands(
query: string,
escapePastedAtSymbols = false,
): AtCommandPart[] {
const parts: AtCommandPart[] = [];
let lastIndex = 0;
@@ -85,7 +109,9 @@ function parseAllAtCommands(query: string): AtCommandPart[] {
if (matchIndex > lastIndex) {
parts.push({
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
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
@@ -635,8 +666,9 @@ export async function handleAtCommand({
onDebugMessage,
messageId: userMessageTimestamp,
signal,
escapePastedAtSymbols = false,
}: HandleAtCommandParams): Promise<HandleAtCommandResult> {
const commandParts = parseAllAtCommands(query);
const commandParts = parseAllAtCommands(query, escapePastedAtSymbols);
const { agentParts, resourceParts, fileParts } = categorizeAtCommands(
commandParts,

View File

@@ -837,8 +837,8 @@ export const useGeminiStream = (
onDebugMessage,
messageId: userMessageTimestamp,
signal: abortSignal,
escapePastedAtSymbols: settings.merged.ui?.escapePastedAtSymbols,
});
if (atCommandResult.error) {
onDebugMessage(atCommandResult.error);
return { queryToSend: null, shouldProceed: false };
@@ -874,6 +874,7 @@ export const useGeminiStream = (
logger,
shellModeActive,
scheduleToolCalls,
settings,
],
);

View File

@@ -25,7 +25,7 @@ export type HighlightToken = {
// 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).
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',
);

View File

@@ -303,6 +303,13 @@
"default": false,
"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": {
"title": "Show Shortcuts Hint",
"description": "Show the \"? for shortcuts\" hint above the input.",