mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-14 13:27:38 -07:00
fix(core): centralize path validation to prevent crashes from malformed prompts (#27211)
This commit is contained in:
@@ -77,6 +77,12 @@ describe('handleAtCommand', () => {
|
||||
unsubscribe: vi.fn(),
|
||||
} as unknown as core.MessageBus;
|
||||
|
||||
const mockWorkspaceContext = {
|
||||
isPathWithinWorkspace: (p: string) =>
|
||||
p.startsWith(testRootDir) || p.startsWith('/private' + testRootDir),
|
||||
getDirectories: () => [testRootDir],
|
||||
};
|
||||
|
||||
mockConfig = {
|
||||
getToolRegistry,
|
||||
getTargetDir: () => testRootDir,
|
||||
@@ -91,11 +97,7 @@ describe('handleAtCommand', () => {
|
||||
}),
|
||||
getFileSystemService: () => new StandardFileSystemService(),
|
||||
getEnableRecursiveFileSearch: vi.fn(() => true),
|
||||
getWorkspaceContext: () => ({
|
||||
isPathWithinWorkspace: (p: string) =>
|
||||
p.startsWith(testRootDir) || p.startsWith('/private' + testRootDir),
|
||||
getDirectories: () => [testRootDir],
|
||||
}),
|
||||
getWorkspaceContext: () => mockWorkspaceContext,
|
||||
getMemoryContextManager: () => undefined,
|
||||
storage: {
|
||||
getProjectTempDir: () => path.join(os.tmpdir(), 'gemini-cli-temp'),
|
||||
@@ -106,7 +108,8 @@ describe('handleAtCommand', () => {
|
||||
}
|
||||
|
||||
const workspaceContext = this.getWorkspaceContext();
|
||||
if (workspaceContext.isPathWithinWorkspace(absolutePath)) {
|
||||
const directories = workspaceContext.getDirectories();
|
||||
if (directories.some((dir) => absolutePath.startsWith(dir))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1462,31 +1465,126 @@ describe('handleAtCommand', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should include agent nudge when agents are found', async () => {
|
||||
const agentName = 'my-agent';
|
||||
const otherAgent = 'other-agent';
|
||||
it('should resolve files in multiple workspace directories', async () => {
|
||||
const secondRootDir = await fsPromises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'second-root-'),
|
||||
);
|
||||
try {
|
||||
const fileContent = 'Second root content';
|
||||
const filePath = path.join(secondRootDir, 'second-file.txt');
|
||||
await fsPromises.writeFile(filePath, fileContent);
|
||||
|
||||
// Mock getAgentRegistry on the config
|
||||
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
|
||||
getDefinition: (name: string) =>
|
||||
name === agentName || name === otherAgent ? { name } : undefined,
|
||||
vi.spyOn(
|
||||
mockConfig.getWorkspaceContext(),
|
||||
'getDirectories',
|
||||
).mockReturnValue([testRootDir, secondRootDir]);
|
||||
|
||||
const query = '@second-file.txt';
|
||||
|
||||
const result = await handleAtCommand({
|
||||
query,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 700,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(result.processedQuery).toContainEqual(
|
||||
expect.objectContaining({ text: fileContent }),
|
||||
);
|
||||
expect(mockOnDebugMessage).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`resolved to file: ${filePath}`),
|
||||
);
|
||||
} finally {
|
||||
await fsPromises.rm(secondRootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should attempt glob fallback if direct resolution is unauthorized', async () => {
|
||||
const fileContent = 'Globbed content';
|
||||
const filePath = await createTestFile(
|
||||
path.join(testRootDir, 'secret', 'file.txt'),
|
||||
fileContent,
|
||||
);
|
||||
|
||||
// Mock validatePathAccess to deny direct access but allow it via glob (just for test purposes)
|
||||
vi.spyOn(mockConfig, 'validatePathAccess').mockImplementation((p) => {
|
||||
if (p.includes('secret') && !p.includes('file.txt'))
|
||||
return 'Unauthorized';
|
||||
// Let's say the direct path 'secret/file.txt' is unauthorized
|
||||
if (p === filePath) return 'Access Denied';
|
||||
return null;
|
||||
});
|
||||
|
||||
const query = `@${agentName} @${otherAgent}`;
|
||||
const query = '@secret/file.txt';
|
||||
|
||||
await handleAtCommand({
|
||||
query,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 701,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
// In this case, resolveAtCommandPath returns status: 'unauthorized'.
|
||||
// resolveFilePaths should then try glob fallback.
|
||||
expect(mockOnDebugMessage).toHaveBeenCalledWith(
|
||||
expect.stringContaining('not found directly, attempting glob search.'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip malformed paths (the original crash scenario)', async () => {
|
||||
// We use a quoted path so the parser treats the whole thing as one @path token
|
||||
const malformedPath =
|
||||
'"FAIL tests/int/my.test.ts ... AssertionError: expected true to be false"';
|
||||
const query = `@${malformedPath}`;
|
||||
|
||||
const result = await handleAtCommand({
|
||||
query,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 600,
|
||||
messageId: 702,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
const expectedNudge = `\n<system_note>\nThe user has explicitly selected the following agent(s): ${agentName}, ${otherAgent}. Please use the following tool(s) to delegate the task: '${agentName}', '${otherAgent}'.\n</system_note>\n`;
|
||||
// Malformed path should be skipped and original query part preserved as text
|
||||
expect(result.processedQuery).toEqual([{ text: query }]);
|
||||
expect(mockOnDebugMessage).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Identified invalid path fragment, attempting to extract path',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should recover a buried path from a malformed fragment during handleAtCommand', async () => {
|
||||
const buriedFile = 'src/recovered.ts';
|
||||
await createTestFile(
|
||||
path.join(testRootDir, buriedFile),
|
||||
'Recovered content',
|
||||
);
|
||||
const malformedFragment = `"FAIL ${buriedFile}:10:5 (AssertionError)"`;
|
||||
const query = `@${malformedFragment}`;
|
||||
|
||||
const result = await handleAtCommand({
|
||||
query,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 703,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
// It should extract src/recovered.ts and attach its content
|
||||
expect(result.processedQuery).toContainEqual(
|
||||
expect.objectContaining({ text: expectedNudge }),
|
||||
expect.objectContaining({ text: 'Recovered content' }),
|
||||
);
|
||||
expect(mockOnDebugMessage).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Identified invalid path fragment, attempting to extract path',
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,14 +4,12 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import type { PartListUnion, PartUnion } from '@google/genai';
|
||||
import type { AnyToolInvocation, Config } from '@google/gemini-cli-core';
|
||||
import {
|
||||
debugLogger,
|
||||
getErrorMessage,
|
||||
isNodeError,
|
||||
unescapePath,
|
||||
resolveToRealPath,
|
||||
fileExists,
|
||||
@@ -19,6 +17,7 @@ import {
|
||||
REFERENCE_CONTENT_START,
|
||||
REFERENCE_CONTENT_END,
|
||||
CoreToolCallStatus,
|
||||
resolveAtCommandPath,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import type {
|
||||
@@ -271,102 +270,100 @@ async function resolveFilePaths(
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const dir of config.getWorkspaceContext().getDirectories()) {
|
||||
try {
|
||||
const absolutePath = path.resolve(dir, pathName);
|
||||
const stats = await fs.stat(absolutePath);
|
||||
const result = await resolveAtCommandPath(pathName, config, onDebugMessage);
|
||||
|
||||
const relativePath = path.isAbsolute(pathName)
|
||||
? path.relative(dir, absolutePath)
|
||||
: pathName;
|
||||
if (result.status === 'resolved') {
|
||||
const { absolutePath, relativePath, stats } = result.resolved;
|
||||
if (stats.isDirectory()) {
|
||||
const pathSpec = path.join(relativePath, '**');
|
||||
resolvedFiles.push({
|
||||
part,
|
||||
pathSpec,
|
||||
displayLabel: path.isAbsolute(pathName) ? relativePath : pathName,
|
||||
absolutePath,
|
||||
});
|
||||
onDebugMessage(
|
||||
`Path ${pathName} resolved to directory, using glob: ${pathSpec}`,
|
||||
);
|
||||
} else {
|
||||
resolvedFiles.push({
|
||||
part,
|
||||
pathSpec: relativePath,
|
||||
displayLabel: path.isAbsolute(pathName) ? relativePath : pathName,
|
||||
absolutePath,
|
||||
});
|
||||
onDebugMessage(
|
||||
`Path ${pathName} resolved to file: ${absolutePath}, using relative path: ${relativePath}`,
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
result.status === 'not_found' ||
|
||||
result.status === 'unauthorized'
|
||||
) {
|
||||
// If direct resolution fails, we attempt glob search if enabled.
|
||||
// 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) {
|
||||
onDebugMessage(
|
||||
`Path ${pathName} not found directly, attempting glob search.`,
|
||||
);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
const pathSpec = path.join(relativePath, '**');
|
||||
resolvedFiles.push({
|
||||
part,
|
||||
pathSpec,
|
||||
displayLabel: path.isAbsolute(pathName) ? relativePath : pathName,
|
||||
absolutePath,
|
||||
});
|
||||
onDebugMessage(
|
||||
`Path ${pathName} resolved to directory, using glob: ${pathSpec}`,
|
||||
);
|
||||
} else {
|
||||
resolvedFiles.push({
|
||||
part,
|
||||
pathSpec: relativePath,
|
||||
displayLabel: path.isAbsolute(pathName) ? relativePath : pathName,
|
||||
absolutePath,
|
||||
});
|
||||
onDebugMessage(
|
||||
`Path ${pathName} resolved to file: ${absolutePath}, using relative path: ${relativePath}`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
if (config.getEnableRecursiveFileSearch() && globTool) {
|
||||
onDebugMessage(
|
||||
`Path ${pathName} not found directly, attempting glob search.`,
|
||||
for (const dir of config.getWorkspaceContext().getDirectories()) {
|
||||
try {
|
||||
const globResult = await globTool.buildAndExecute(
|
||||
{
|
||||
pattern: `**/*${pathName}*`,
|
||||
path: dir,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
try {
|
||||
const globResult = await globTool.buildAndExecute(
|
||||
{
|
||||
pattern: `**/*${pathName}*`,
|
||||
path: dir,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
if (
|
||||
globResult.llmContent &&
|
||||
typeof globResult.llmContent === 'string' &&
|
||||
!globResult.llmContent.startsWith('No files found') &&
|
||||
!globResult.llmContent.startsWith('Error:')
|
||||
) {
|
||||
const lines = globResult.llmContent.split('\n');
|
||||
if (lines.length > 1 && lines[1]) {
|
||||
const firstMatchAbsolute = lines[1].trim();
|
||||
const pathSpec = path.relative(dir, firstMatchAbsolute);
|
||||
resolvedFiles.push({
|
||||
part,
|
||||
pathSpec,
|
||||
displayLabel: path.isAbsolute(pathName)
|
||||
? pathSpec
|
||||
: pathName,
|
||||
});
|
||||
onDebugMessage(
|
||||
`Glob search for ${pathName} found ${firstMatchAbsolute}, using relative path: ${pathSpec}`,
|
||||
);
|
||||
break;
|
||||
} else {
|
||||
onDebugMessage(
|
||||
`Glob search for '**/*${pathName}*' did not return a usable path. Path ${pathName} will be skipped.`,
|
||||
);
|
||||
if (
|
||||
globResult.llmContent &&
|
||||
typeof globResult.llmContent === 'string' &&
|
||||
!globResult.llmContent.startsWith('No files found') &&
|
||||
!globResult.llmContent.startsWith('Error:')
|
||||
) {
|
||||
const lines = globResult.llmContent.split('\n');
|
||||
if (lines.length > 1 && lines[1]) {
|
||||
const rawMatch = lines[1].trim();
|
||||
let firstMatchAbsolute: string;
|
||||
try {
|
||||
firstMatchAbsolute = resolveToRealPath(rawMatch);
|
||||
} catch {
|
||||
firstMatchAbsolute = rawMatch;
|
||||
}
|
||||
const pathSpec = path.relative(dir, firstMatchAbsolute);
|
||||
resolvedFiles.push({
|
||||
part,
|
||||
pathSpec,
|
||||
displayLabel: path.isAbsolute(pathName) ? pathSpec : pathName,
|
||||
absolutePath: firstMatchAbsolute,
|
||||
});
|
||||
onDebugMessage(
|
||||
`Glob search for ${pathName} found ${firstMatchAbsolute}, using relative path: ${pathSpec}`,
|
||||
);
|
||||
break;
|
||||
} else {
|
||||
onDebugMessage(
|
||||
`Glob search for '**/*${pathName}*' found no files or an error. Path ${pathName} will be skipped.`,
|
||||
`Glob search for '**/*${pathName}*' did not return a usable path. Path ${pathName} will be skipped.`,
|
||||
);
|
||||
}
|
||||
} catch (globError) {
|
||||
debugLogger.warn(
|
||||
`Error during glob search for ${pathName}: ${getErrorMessage(globError)}`,
|
||||
);
|
||||
} else {
|
||||
onDebugMessage(
|
||||
`Error during glob search for ${pathName}. Path ${pathName} will be skipped.`,
|
||||
`Glob search for '**/*${pathName}*' found no files or an error. Path ${pathName} will be skipped.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
onDebugMessage(
|
||||
`Glob tool not found. Path ${pathName} will be skipped.`,
|
||||
} catch (globError) {
|
||||
debugLogger.warn(
|
||||
`Error during glob search for ${pathName}: ${getErrorMessage(globError)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
debugLogger.warn(
|
||||
`Error stating path ${pathName}: ${getErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (!config.getEnableRecursiveFileSearch() || !globTool) {
|
||||
onDebugMessage(
|
||||
`Error stating path ${pathName}. Path ${pathName} will be skipped.`,
|
||||
`Glob tool not found. Path ${pathName} will be skipped.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -524,7 +521,14 @@ async function readLocalFiles(
|
||||
config.getMessageBus(),
|
||||
);
|
||||
|
||||
const pathSpecsToRead = resolvedFiles.map((rf) => rf.pathSpec);
|
||||
const pathSpecsToRead = resolvedFiles.map((rf) => {
|
||||
if (rf.absolutePath) {
|
||||
return rf.pathSpec.endsWith('**')
|
||||
? path.join(rf.absolutePath, '**')
|
||||
: rf.absolutePath;
|
||||
}
|
||||
return rf.pathSpec;
|
||||
});
|
||||
const fileLabelsForDisplay = resolvedFiles.map((rf) => rf.displayLabel);
|
||||
const respectFileIgnore = config.getFileFilteringOptions();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user