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

This change consolidates path validation into the central Config.validatePathAccess method. It introduces a PathValidator utility that performs pre-flight checks for length, invalid characters, and log markers. This automatically protects all tools using workspace boundary checks. Additionally, CLI-level at-command resolution is consolidated into a shared utility.

Fixes #25972
This commit is contained in:
Coco Sheng
2026-05-18 12:16:16 -04:00
parent b213fd68ec
commit 5f37142016
7 changed files with 384 additions and 87 deletions
+25 -5
View File
@@ -49,6 +49,7 @@ import {
buildAvailableModes,
RequestPermissionResponseSchema,
} from './acpUtils.js';
import { resolveAtCommandPath } from '../utils/atCommandUtils.js';
import { z } from 'zod';
import { getAcpErrorMessage } from './acpErrors.js';
@@ -928,13 +929,24 @@ export class Session {
let currentPathSpec = pathName;
let resolvedSuccessfully = false;
let readDirectly = false;
try {
const absolutePath = path.resolve(
const resolved = await resolveAtCommandPath(
pathName,
this.context.config,
(msg) => this.debug(msg),
);
let validationError: string | null = null;
let absolutePath: string;
if (!resolved) {
// If not resolved, we still check if it's an unauthorized absolute path that we can ask permission for.
absolutePath = path.resolve(
this.context.config.getTargetDir(),
pathName,
);
let validationError = this.context.config.validatePathAccess(
validationError = this.context.config.validatePathAccess(
absolutePath,
'read',
);
@@ -1020,7 +1032,11 @@ export class Session {
});
}
}
} else {
absolutePath = resolved.absolutePath;
}
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.
@@ -1033,7 +1049,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,
@@ -1092,7 +1110,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}**`
+89 -82
View File
@@ -25,6 +25,7 @@ import type {
HistoryItemToolGroup,
IndividualToolCallDisplay,
} from '../types.js';
import { resolveAtCommandPath } from '../../utils/atCommandUtils.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
const REF_CONTENT_HEADER = `\n${REFERENCE_CONTENT_START}`;
@@ -271,103 +272,109 @@ 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 relativePath = path.isAbsolute(pathName)
? path.relative(dir, absolutePath)
: pathName;
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.`,
);
try {
const globResult = await globTool.buildAndExecute(
{
pattern: `**/*${pathName}*`,
path: dir,
},
signal,
const resolved = await resolveAtCommandPath(
pathName,
config,
onDebugMessage,
);
if (resolved) {
const absolutePath = resolved.absolutePath;
const relativePath = resolved.relativePath;
const stats = resolved.stats;
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 direct resolution fails, we keep the original loop for glob fallback if enabled
for (const dir of config.getWorkspaceContext().getDirectories()) {
try {
const absolutePath = path.resolve(dir, pathName);
await fs.stat(absolutePath);
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
if (config.getEnableRecursiveFileSearch() && globTool) {
onDebugMessage(
`Path ${pathName} not found directly, attempting glob search.`,
);
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;
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.`,
);
}
} else {
onDebugMessage(
`Glob search for '**/*${pathName}*' did not return a usable path. Path ${pathName} will be skipped.`,
`Glob search for '**/*${pathName}*' found no files or an error. Path ${pathName} will be skipped.`,
);
}
} else {
} catch (globError) {
debugLogger.warn(
`Error during glob search for ${pathName}: ${getErrorMessage(globError)}`,
);
onDebugMessage(
`Glob search for '**/*${pathName}*' found no files or an error. Path ${pathName} will be skipped.`,
`Error during glob search for ${pathName}. 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 tool not found. Path ${pathName} will be skipped.`,
);
}
} else {
debugLogger.warn(
`Error stating path ${pathName}: ${getErrorMessage(error)}`,
);
onDebugMessage(
`Glob tool not found. Path ${pathName} will be skipped.`,
`Error stating path ${pathName}. Path ${pathName} will be skipped.`,
);
}
} else {
debugLogger.warn(
`Error stating path ${pathName}: ${getErrorMessage(error)}`,
);
onDebugMessage(
`Error stating path ${pathName}. Path ${pathName} will be skipped.`,
);
}
}
}
+83
View File
@@ -0,0 +1,83 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import {
validatePath,
isWithinRoot,
type Config,
} from '@google/gemini-cli-core';
export interface ResolvedAtCommandPath {
absolutePath: string;
relativePath: string;
stats: {
isDirectory(): boolean;
isFile(): boolean;
};
}
/**
* Resolves a path from an @-command, ensuring it is valid and within workspace boundaries.
*/
export async function resolveAtCommandPath(
pathName: string,
config: Config,
onDebugMessage: (msg: string) => void = () => {},
): Promise<ResolvedAtCommandPath | null> {
const pathValidation = validatePath(pathName);
if (!pathValidation.isValid) {
onDebugMessage(
`Skipping invalid path in @-command: ${pathName}. Reason: ${pathValidation.error}`,
);
return null;
}
for (const dir of config.getWorkspaceContext().getDirectories()) {
try {
const absolutePath = path.resolve(dir, pathName);
const resolvedValidation = validatePath(absolutePath);
if (!resolvedValidation.isValid) {
onDebugMessage(
`Skipping invalid resolved path in @-command: ${absolutePath}. Reason: ${resolvedValidation.error}`,
);
continue;
}
// Final workspace boundary check using centralized logic
const validationError = config.validatePathAccess(absolutePath, 'read');
if (validationError) {
// If it's outside root, we might still allow it with explicit user permission in acpSession,
// but for now, we follow the general rule.
if (!isWithinRoot(absolutePath, config.getTargetDir())) {
// Proceed to stat check, calling sites will handle permission dialogs if needed
} else {
onDebugMessage(
`Skipping unauthorized path: ${absolutePath}. Reason: ${validationError}`,
);
continue;
}
}
const stats = await fs.stat(absolutePath);
const relativePath = path.isAbsolute(pathName)
? path.relative(dir, absolutePath)
: pathName;
return {
absolutePath,
relativePath,
stats,
};
} catch {
// Ignore errors for this specific directory, try next
}
}
return null;
}