From 19e0b1ff7d579ce3a056a823964feb2c2611d321 Mon Sep 17 00:00:00 2001 From: krishdef7 <157892833+krishdef7@users.noreply.github.com> Date: Fri, 13 Mar 2026 03:35:12 +0530 Subject: [PATCH] fix(cli): escape @ symbols on paste to prevent unintended file expansion (#21239) --- docs/cli/settings.md | 1 + docs/reference/configuration.md | 5 ++ packages/cli/src/config/settingsSchema.ts | 10 ++++ packages/cli/src/nonInteractiveCli.ts | 2 +- .../cli/src/ui/components/InputPrompt.tsx | 19 +++++- .../src/ui/hooks/atCommandProcessor.test.ts | 59 ++++++++++++++++++- .../cli/src/ui/hooks/atCommandProcessor.ts | 40 +++++++++++-- packages/cli/src/ui/hooks/useGeminiStream.ts | 3 +- packages/cli/src/ui/utils/highlight.ts | 2 +- schemas/settings.schema.json | 7 +++ 10 files changed, 137 insertions(+), 11 deletions(-) diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 337fa30cb9..35a09a99ab 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -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` | diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index f3194c39f9..4e0e9856d9 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -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` diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 0646ff2582..7d47d66e32 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -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', diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index c25e452ee0..891e3d0ee9 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -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. diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index fd6f091af8..0deb0c40d2 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -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 = ({ 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 = ({ 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 = ({ forceShowShellSuggestions, keyMatchers, isHelpDismissKey, + settings, ], ); diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts index 8908cf5fc0..b30e9675cd 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts @@ -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); + }); +}); diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index c23c9fa2db..477f9bb02a 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -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(/(? { + 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 '@' 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 { - const commandParts = parseAllAtCommands(query); + const commandParts = parseAllAtCommands(query, escapePastedAtSymbols); const { agentParts, resourceParts, fileParts } = categorizeAtCommands( commandParts, diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 321be6e38e..c394b866ad 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -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, ], ); diff --git a/packages/cli/src/ui/utils/highlight.ts b/packages/cli/src/ui/utils/highlight.ts index d294b422f1..e67977c4a2 100644 --- a/packages/cli/src/ui/utils/highlight.ts +++ b/packages/cli/src/ui/utils/highlight.ts @@ -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_-]+|(?