fix(core): centralize path validation to prevent crashes from malformed prompts (#27211)

This commit is contained in:
Coco Sheng
2026-05-20 15:55:57 -04:00
committed by GitHub
parent e440e02866
commit d7384c446f
13 changed files with 1261 additions and 190 deletions
@@ -71,6 +71,7 @@ describe('GeminiAgent - RPC Dispatcher', () => {
validatePathAccess: vi.fn().mockReturnValue(null),
getWorkspaceContext: vi.fn().mockReturnValue({
addReadOnlyPath: vi.fn(),
getDirectories: vi.fn().mockReturnValue(['/tmp']),
}),
getPolicyEngine: vi.fn().mockReturnValue({
addRule: vi.fn(),
+1
View File
@@ -149,6 +149,7 @@ describe('Session', () => {
validatePathAccess: vi.fn().mockReturnValue(null),
getWorkspaceContext: vi.fn().mockReturnValue({
addReadOnlyPath: vi.fn(),
getDirectories: vi.fn().mockReturnValue(['/tmp']),
}),
waitForMcpInit: vi.fn(),
getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
+115 -88
View File
@@ -37,6 +37,8 @@ import {
MessageBusType,
PolicyDecision,
type ToolConfirmationRequest,
resolveAtCommandPath,
type ResolvedAtCommandPath,
} from '@google/gemini-cli-core';
import * as acp from '@agentclientprotocol/sdk';
import type { Part, FunctionCall } from '@google/genai';
@@ -1023,99 +1025,120 @@ export class Session {
let currentPathSpec = pathName;
let resolvedSuccessfully = false;
let readDirectly = false;
try {
const absolutePath = path.resolve(
const result = await resolveAtCommandPath(
pathName,
this.context.config,
(msg) => this.debug(msg),
);
let validationError: string | null = null;
let absolutePath: string;
let resolved: ResolvedAtCommandPath | undefined;
if (result.status === 'resolved') {
resolved = result.resolved;
absolutePath = resolved.absolutePath;
} else if (result.status === 'unauthorized') {
absolutePath = result.absolutePath;
validationError = result.error;
} else if (result.status === 'invalid') {
// Already logged in resolveAtCommandPath
continue;
} else {
// Result is not_found.
// We still check if it's an unauthorized absolute path that we can ask permission for,
// specifically for paths that are completely outside the root and not even in any workspace directory.
// For relative paths not found anywhere, we resolve relative to targetDir for permission check.
absolutePath = path.resolve(
this.context.config.getTargetDir(),
pathName,
);
}
let validationError = this.context.config.validatePathAccess(
absolutePath,
'read',
);
// We ask the user for explicit permission to read them if outside sandboxed workspace boundaries (and not already authorized).
if (
validationError &&
!isWithinRoot(absolutePath, this.context.config.getTargetDir())
) {
try {
const stats = await fs.stat(absolutePath);
if (stats.isFile()) {
const syntheticCallId = `resolve-prompt-${pathName}-${randomUUID()}`;
const params = {
sessionId: this.id,
options: [
{
optionId: ToolConfirmationOutcome.ProceedOnce,
name: 'Allow once',
kind: 'allow_once',
},
{
optionId: ToolConfirmationOutcome.Cancel,
name: 'Deny',
kind: 'reject_once',
},
] as acp.PermissionOption[],
toolCall: {
toolCallId: syntheticCallId,
status: 'pending',
title: `Allow access to absolute path: ${pathName}`,
content: [
{
type: 'content',
content: {
type: 'text',
text: `The Agent needs access to read an attached file outside your workspace: ${pathName}`,
},
},
],
locations: [],
kind: 'read',
if (
!resolved &&
validationError &&
!isWithinRoot(absolutePath, this.context.config.getTargetDir())
) {
try {
const stats = await fs.stat(absolutePath);
if (stats.isFile()) {
const syntheticCallId = `resolve-prompt-${pathName}-${randomUUID()}`;
const params = {
sessionId: this.id,
options: [
{
optionId: ToolConfirmationOutcome.ProceedOnce,
name: 'Allow once',
kind: 'allow_once',
},
};
const output = RequestPermissionResponseSchema.parse(
await this.connection.requestPermission(params),
);
const outcome =
output.outcome.outcome === 'cancelled'
? ToolConfirmationOutcome.Cancel
: z
.nativeEnum(ToolConfirmationOutcome)
.parse(output.outcome.optionId);
if (outcome === ToolConfirmationOutcome.ProceedOnce) {
this.context.config
.getWorkspaceContext()
.addReadOnlyPath(absolutePath);
validationError = null;
} else {
this.debug(
`Direct read authorization denied for absolute path ${pathName}`,
);
directContents.push({
spec: pathName,
content: `[Warning: Access to absolute path \`${pathName}\` denied by user.]`,
});
continue;
}
}
} catch (error) {
this.debug(
`Failed to request permission for absolute attachment ${pathName}: ${getErrorMessage(error)}`,
);
await this.sendUpdate({
sessionUpdate: 'agent_thought_chunk',
content: {
type: 'text',
text: `Warning: Failed to display permission dialog for \`${absolutePath}\`. Error: ${getErrorMessage(error)}`,
{
optionId: ToolConfirmationOutcome.Cancel,
name: 'Deny',
kind: 'reject_once',
},
] as acp.PermissionOption[],
toolCall: {
toolCallId: syntheticCallId,
status: 'pending',
title: `Allow access to absolute path: ${pathName}`,
content: [
{
type: 'content',
content: {
type: 'text',
text: `The Agent needs access to read an attached file outside your workspace: ${pathName}`,
},
},
],
locations: [],
kind: 'read',
},
});
}
}
};
const output = RequestPermissionResponseSchema.parse(
await this.connection.requestPermission(params),
);
const outcome =
output.outcome.outcome === 'cancelled'
? ToolConfirmationOutcome.Cancel
: z
.nativeEnum(ToolConfirmationOutcome)
.parse(output.outcome.optionId);
if (outcome === ToolConfirmationOutcome.ProceedOnce) {
this.context.config
.getWorkspaceContext()
.addReadOnlyPath(absolutePath);
validationError = null;
} else {
this.debug(
`Direct read authorization denied for absolute path ${pathName}`,
);
directContents.push({
spec: pathName,
content: `[Warning: Access to absolute path \`${pathName}\` denied by user.]`,
});
continue;
}
}
} catch (error) {
this.debug(
`Failed to request permission for absolute attachment ${pathName}: ${getErrorMessage(error)}`,
);
await this.sendUpdate({
sessionUpdate: 'agent_thought_chunk',
content: {
type: 'text',
text: `Warning: Failed to display permission dialog for \`${absolutePath}\`. Error: ${getErrorMessage(error)}`,
},
});
}
}
try {
if (!validationError) {
// If it's an absolute path that is authorized (e.g. added via readOnlyPaths),
// read it directly to avoid ReadManyFilesTool absolute path resolution issues.
@@ -1128,7 +1151,9 @@ export class Session {
!readDirectly
) {
try {
const stats = await fs.stat(absolutePath);
const stats = resolved
? resolved.stats
: await fs.stat(absolutePath);
if (stats.isFile()) {
const fileReadResult = await processSingleFileContent(
absolutePath,
@@ -1187,7 +1212,9 @@ export class Session {
}
if (!readDirectly) {
const stats = await fs.stat(absolutePath);
const stats = resolved
? resolved.stats
: await fs.stat(absolutePath);
if (stats.isDirectory()) {
currentPathSpec = pathName.endsWith('/')
? `${pathName}**`
@@ -79,6 +79,7 @@ describe('AcpSessionManager', () => {
validatePathAccess: vi.fn().mockReturnValue(null),
getWorkspaceContext: vi.fn().mockReturnValue({
addReadOnlyPath: vi.fn(),
getDirectories: vi.fn().mockReturnValue(['/tmp']),
}),
getPolicyEngine: vi.fn().mockReturnValue({
addRule: vi.fn(),
@@ -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',
),
);
});
});
+89 -85
View File
@@ -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();