Avoid broad at-command recursive searches

This commit is contained in:
Sandy Tao
2026-06-09 13:32:24 -07:00
parent c40d26c72b
commit 61387291c9
4 changed files with 95 additions and 7 deletions
@@ -551,6 +551,65 @@ describe('handleAtCommand', () => {
});
});
it('should skip recursive glob fallback for bare @ names', async () => {
const buildAndExecute = vi.fn().mockResolvedValue({
llmContent: 'No files found',
returnDisplay: 'No files found',
});
vi.mocked(mockConfig.getToolRegistry).mockReturnValue({
getTool: vi.fn((name: string) =>
name === 'glob' ? { buildAndExecute } : undefined,
),
} as unknown as ToolRegistry);
const query = 'Ask @teammate for review';
const result = await handleAtCommand({
query,
config: mockConfig,
addItem: mockAddItem,
onDebugMessage: mockOnDebugMessage,
messageId: 134,
signal: abortController.signal,
});
expect(buildAndExecute).not.toHaveBeenCalled();
expect(mockOnDebugMessage).toHaveBeenCalledWith(
'Path teammate not found directly and does not look like a path. Skipping recursive glob search.',
);
expect(result).toEqual({
processedQuery: [{ text: query }],
});
});
it('should not treat @ in the middle of a word as an @ command', async () => {
const buildAndExecute = vi.fn().mockResolvedValue({
llmContent: 'No files found',
returnDisplay: 'No files found',
});
vi.mocked(mockConfig.getToolRegistry).mockReturnValue({
getTool: vi.fn((name: string) =>
name === 'glob' ? { buildAndExecute } : undefined,
),
} as unknown as ToolRegistry);
const query = 'Connect to user@host before continuing.';
const result = await handleAtCommand({
query,
config: mockConfig,
addItem: mockAddItem,
onDebugMessage: mockOnDebugMessage,
messageId: 135,
signal: abortController.signal,
});
expect(buildAndExecute).not.toHaveBeenCalled();
expect(result).toEqual({
processedQuery: [{ text: query }],
});
});
describe('git-aware filtering', () => {
beforeEach(async () => {
await fsPromises.mkdir(path.join(testRootDir, '.git'), {
@@ -61,6 +61,12 @@ export function unescapeLiteralAt(text: string): string {
export const AT_COMMAND_PATH_REGEX_SOURCE =
'(?:(?:"(?:[^"]*)")|(?:\\\\.|[^ \\t\\n\\r,;!?()\\[\\]{}.]|\\.(?!$|[ \\t\\n\\r])))+';
/**
* An @ reference can start at the beginning of input or after punctuation/space,
* but not in the middle of a word such as user@host or hello@file.
*/
export const AT_COMMAND_START_REGEX_SOURCE = '(?<![\\\\A-Za-z0-9_])@';
interface HandleAtCommandParams {
query: string;
config: Config;
@@ -94,7 +100,7 @@ function parseAllAtCommands(
// Create a new RegExp instance for each call to avoid shared state/lastIndex issues.
const atCommandRegex = new RegExp(
`(?<!\\\\)@${AT_COMMAND_PATH_REGEX_SOURCE}`,
`${AT_COMMAND_START_REGEX_SOURCE}${AT_COMMAND_PATH_REGEX_SOURCE}`,
'g',
);
@@ -218,6 +224,19 @@ interface IgnoredFile {
reason: 'git' | 'gemini' | 'both';
}
function shouldAttemptGlobFallback(pathName: string): boolean {
return (
pathName.includes('/') ||
pathName.includes('\\') ||
pathName.includes('.') ||
pathName.includes('*') ||
pathName.includes('?') ||
pathName.includes('[') ||
pathName.includes(']') ||
pathName.startsWith('~')
);
}
/**
* Resolves file paths from @ commands, handling globs, recursion, and ignores.
*/
@@ -304,7 +323,11 @@ async function resolveFilePaths(
// We also allow glob fallback for "unauthorized" results from resolveAtCommandPath,
// as they might represent a relative path that matched an unauthorized file in one directory
// but might have a valid match (via glob) in another.
if (config.getEnableRecursiveFileSearch() && globTool) {
if (!shouldAttemptGlobFallback(pathName)) {
onDebugMessage(
`Path ${pathName} not found directly and does not look like a path. Skipping recursive glob search.`,
);
} else if (config.getEnableRecursiveFileSearch() && globTool) {
onDebugMessage(
`Path ${pathName} not found directly, attempting glob search.`,
);
@@ -177,9 +177,12 @@ describe('commandUtils', () => {
// referenced files are pre-loaded before the query is sent to the model.
expect(isAtCommand('check:@file.py')).toBe(true);
expect(isAtCommand('analyze(@file.py)')).toBe(true);
expect(isAtCommand('hello@file')).toBe(true);
expect(isAtCommand('text@path/to/file')).toBe(true);
expect(isAtCommand('user@host')).toBe(true);
});
it('should return false when @ is in the middle of a word', () => {
expect(isAtCommand('hello@file')).toBe(false);
expect(isAtCommand('text@path/to/file')).toBe(false);
expect(isAtCommand('user@host')).toBe(false);
});
it('should return false when query does not contain any @<path> pattern', () => {
+5 -2
View File
@@ -10,13 +10,16 @@ import type { SlashCommand } from '../commands/types.js';
import fs from 'node:fs';
import type { Writable } from 'node:stream';
import type { Settings } from '../../config/settingsSchema.js';
import { AT_COMMAND_PATH_REGEX_SOURCE } from '../hooks/atCommandProcessor.js';
import {
AT_COMMAND_PATH_REGEX_SOURCE,
AT_COMMAND_START_REGEX_SOURCE,
} from '../hooks/atCommandProcessor.js';
// Pre-compiled regex for detecting @<path> patterns consistent with parseAllAtCommands.
// Uses the same AT_COMMAND_PATH_REGEX_SOURCE so that isAtCommand is true whenever
// parseAllAtCommands would find at least one atPath part.
const AT_COMMAND_DETECT_REGEX = new RegExp(
`(?<!\\\\)@${AT_COMMAND_PATH_REGEX_SOURCE}`,
`${AT_COMMAND_START_REGEX_SOURCE}${AT_COMMAND_PATH_REGEX_SOURCE}`,
);
/**