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
+10
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',
+1 -1
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.
+16 -3
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,
],
);
@@ -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);
});
});
@@ -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,
+2 -1
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,
],
);
+1 -1
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',
);