Compare commits

...

2 Commits

Author SHA1 Message Date
Coco Sheng ba5abc7488 test: add unit tests for resolveAtCommandPath and path validator edge cases 2026-05-18 12:49:39 -04:00
Coco Sheng 5f37142016 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
2026-05-18 12:16:31 -04:00
9 changed files with 628 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.`,
);
}
}
}
@@ -0,0 +1,178 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import { resolveAtCommandPath } from './atCommandUtils.js';
import { type Config } from '@google/gemini-cli-core';
vi.mock('node:fs/promises');
describe('atCommandUtils', () => {
let mockConfig: Record<string, unknown>;
let mockWorkspaceContext: Record<string, unknown>;
beforeEach(() => {
vi.resetAllMocks();
mockWorkspaceContext = {
getDirectories: vi.fn().mockReturnValue(['/mock/root']),
isPathReadable: vi.fn().mockReturnValue(true),
};
mockConfig = {
getTargetDir: vi.fn().mockReturnValue('/mock/root'),
getWorkspaceContext: vi.fn().mockReturnValue(mockWorkspaceContext),
validatePathAccess: vi.fn().mockReturnValue(null),
};
});
it('should resolve a valid path', async () => {
const mockStats = {
isDirectory: () => false,
isFile: () => true,
};
vi.mocked(fs.stat).mockResolvedValue(mockStats as unknown as fs.Stats);
const result = await resolveAtCommandPath(
'file.ts',
mockConfig as unknown as Config,
);
expect(result).not.toBeNull();
expect(result?.absolutePath).toBe(path.resolve('/mock/root', 'file.ts'));
expect(result?.relativePath).toBe('file.ts');
});
it('should resolve an absolute path', async () => {
const mockStats = {
isDirectory: () => false,
isFile: () => true,
};
vi.mocked(fs.stat).mockResolvedValue(mockStats as unknown as fs.Stats);
const absolutePath = path.resolve('/mock/root', 'src/index.ts');
const result = await resolveAtCommandPath(
absolutePath,
mockConfig as unknown as Config,
);
expect(result).not.toBeNull();
expect(result?.absolutePath).toBe(absolutePath);
expect(result?.relativePath).toBe('src/index.ts');
});
it('should handle multiple directories in workspace context', async () => {
(mockWorkspaceContext.getDirectories as Mock).mockReturnValue([
'/dir1',
'/dir2',
]);
const mockStats = {
isDirectory: () => false,
isFile: () => true,
};
vi.mocked(fs.stat).mockImplementation(async (p) => {
if (p === path.resolve('/dir2', 'file.txt')) {
return mockStats as unknown as fs.Stats;
}
throw new Error('ENOENT');
});
const result = await resolveAtCommandPath(
'file.txt',
mockConfig as unknown as Config,
);
expect(result?.absolutePath).toBe(path.resolve('/dir2', 'file.txt'));
expect(result?.relativePath).toBe('file.txt');
});
it('should return null for invalid path (too long)', async () => {
const longPath = 'a'.repeat(5000);
const result = await resolveAtCommandPath(
longPath,
mockConfig as unknown as Config,
);
expect(result).toBeNull();
});
it('should return null for path with log markers', async () => {
const onDebug = vi.fn();
const result = await resolveAtCommandPath(
'FAIL tests/my.test.ts',
mockConfig as unknown as Config,
onDebug,
);
expect(result).toBeNull();
expect(onDebug).toHaveBeenCalledWith(
expect.stringContaining('Skipping invalid path'),
);
});
it('should return null if path does not exist in any workspace directory', async () => {
vi.mocked(fs.stat).mockRejectedValue(new Error('ENOENT'));
const result = await resolveAtCommandPath(
'nonexistent.ts',
mockConfig as unknown as Config,
);
expect(result).toBeNull();
});
it('should resolve directory paths correctly', async () => {
const mockStats = {
isDirectory: () => true,
isFile: () => false,
};
vi.mocked(fs.stat).mockResolvedValue(mockStats as unknown as fs.Stats);
const result = await resolveAtCommandPath(
'src',
mockConfig as unknown as Config,
);
expect(result?.stats.isDirectory()).toBe(true);
});
it('should respect validatePathAccess for paths within root', async () => {
(mockConfig.validatePathAccess as Mock).mockReturnValue(
'Unauthorized access',
);
// Mock getTargetDir to match the resolved path so it's considered "within root"
(mockConfig.getTargetDir as Mock).mockReturnValue('/mock/root');
const result = await resolveAtCommandPath(
'secret.txt',
mockConfig as unknown as Config,
);
expect(result).toBeNull();
});
it('should return null for unauthorized paths (letting calling site handle it, e.g. acpSession permission dialog)', async () => {
(mockConfig.validatePathAccess as Mock).mockReturnValue(
'Outside workspace',
);
(mockConfig.getTargetDir as Mock).mockReturnValue('/mock/workspace');
const mockStats = {
isDirectory: () => false,
isFile: () => true,
};
vi.mocked(fs.stat).mockResolvedValue(mockStats as unknown as fs.Stats);
// Path resolve will use /mock/root as base from mockWorkspaceContext
const result = await resolveAtCommandPath(
'outside.txt',
mockConfig as unknown as Config,
);
// Should now return null so acpSession can trigger its own permission flow
expect(result).toBeNull();
});
});
+73
View File
@@ -0,0 +1,73 @@
/**
* @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, 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) {
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;
}
+6
View File
@@ -177,6 +177,7 @@ import { startupProfiler } from '../telemetry/startupProfiler.js';
import type { AgentDefinition } from '../agents/types.js';
import { fetchAdminControls } from '../code_assist/admin/admin_controls.js';
import { isSubpath, resolveToRealPath } from '../utils/paths.js';
import { validatePath } from '../utils/path-validator.js';
import { InjectionService } from './injectionService.js';
import { ExecutionLifecycleService } from '../services/executionLifecycleService.js';
import { WORKSPACE_POLICY_TIER } from '../policy/config.js';
@@ -3289,6 +3290,11 @@ export class Config implements McpContext, AgentLoopContext {
absolutePath: string,
checkType: 'read' | 'write' = 'write',
): string | null {
const pathValidation = validatePath(absolutePath);
if (!pathValidation.isValid) {
return `Invalid path: ${pathValidation.error}`;
}
if (checkType === 'write' && hasScopedAutoMemoryExtractionWriteAccess()) {
const resolvedPath = resolveToRealPath(absolutePath);
if (
@@ -0,0 +1,61 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach } from 'vitest';
import path from 'node:path';
import { Config, type ConfigParameters } from './config.js';
describe('Config Path Validation Integration', () => {
let config: Config;
const projectRoot = process.cwd();
beforeEach(() => {
const params: ConfigParameters = {
sessionId: 'test-session',
targetDir: projectRoot,
debugMode: false,
model: 'test-model',
cwd: projectRoot,
};
config = new Config(params);
});
it('should reject pathologically long paths in validatePathAccess', () => {
const longPath = path.join(projectRoot, 'a'.repeat(5000));
const result = config.validatePathAccess(longPath, 'read');
expect(result).toContain('Invalid path: Path is too long');
});
it('should reject paths with log markers in validatePathAccess', () => {
const logPath = path.join(
projectRoot,
'AssertionError: expected true to be false',
);
const result = config.validatePathAccess(logPath, 'read');
expect(result).toContain(
'Invalid path: Path appears to be a misinterpreted log fragment',
);
});
it('should reject paths with control characters in validatePathAccess', () => {
const malformedPath = path.join(projectRoot, 'file\nwith\nnewline.txt');
const result = config.validatePathAccess(malformedPath, 'read');
expect(result).toContain('Invalid path: Path contains invalid characters');
});
it('should allow normal paths in validatePathAccess', () => {
const normalPath = path.resolve(projectRoot, 'src/index.ts');
const result = config.validatePathAccess(normalPath, 'read');
// It might return "outside workspace" if not fully initialized,
// but it should NOT return the "Invalid path" prefix from PathValidator.
if (result) {
expect(result).not.toContain('Invalid path:');
} else {
expect(result).toBeNull();
}
});
});
+1
View File
@@ -92,6 +92,7 @@ export * from './utils/sessionOperations.js';
export * from './utils/planUtils.js';
export * from './utils/approvalModeUtils.js';
export * from './utils/fileDiffUtils.js';
export * from './utils/path-validator.js';
export * from './utils/retry.js';
export * from './utils/shell-utils.js';
export {
@@ -0,0 +1,92 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { validatePath } from './path-validator.js';
describe('PathValidator', () => {
it('should validate normal paths', () => {
expect(validatePath('src/index.ts').isValid).toBe(true);
expect(validatePath('/usr/local/bin').isValid).toBe(true);
expect(validatePath('C:\\Users\\name\\Documents').isValid).toBe(true);
expect(validatePath('relative/path/to/file.js').isValid).toBe(true);
});
it('should reject empty or non-string paths', () => {
expect(validatePath('').isValid).toBe(false);
expect(validatePath(null as unknown as string).isValid).toBe(false);
});
it('should reject paths with newlines or control characters', () => {
expect(validatePath('path/with\nnewline').isValid).toBe(false);
expect(validatePath('path/with\rreturn').isValid).toBe(false);
expect(validatePath('path/with\0null').isValid).toBe(false);
expect(validatePath('path/with\ttab').isValid).toBe(false);
});
it('should reject excessively long paths', () => {
const longPath = 'a'.repeat(4097);
const result = validatePath(longPath);
expect(result.isValid).toBe(false);
expect(result.error).toContain('Path is too long');
});
it('should reject paths with excessively long components', () => {
const longComponent = 'a'.repeat(256);
const result = validatePath(`path/to/${longComponent}/file`);
expect(result.isValid).toBe(false);
expect(result.error).toContain(
'component "aaaaaaaaaaaaaaaaaaaa..." is too long',
);
});
it('should reject misinterpreted log fragments with quotes or ellipses', () => {
const logFragment =
'Error: No "formatTimeRange" export is defined on the lib/formatTimeRange mock.';
const result = validatePath(logFragment);
expect(result.isValid).toBe(false);
expect(result.error).toContain('suspicious characters');
});
it('should reject code snippets with braces/parens', () => {
const codeSnippet =
'vi.mock(import("@/lib/formatTimeRange"), async (importOriginal) => { return { ...actual }; })';
const result = validatePath(codeSnippet);
expect(result.isValid).toBe(false);
expect(result.error).toContain(
'misinterpreted log fragment or code snippet',
);
});
it('should reject misinterpreted log fragments with log markers', () => {
expect(validatePath('FAIL tests/int/my.test.ts').isValid).toBe(false);
expect(
validatePath('AssertionError: expected true to be false').isValid,
).toBe(false);
expect(validatePath('✓ test passed').isValid).toBe(false);
expect(validatePath('× test failed').isValid).toBe(false);
expect(
validatePath('TestingLibraryElementError: Unable to find an element')
.isValid,
).toBe(false);
});
it('should allow short paths with quotes (even if unusual)', () => {
// Some systems might technically allow this, and we only want to block long/obvious log fragments
expect(validatePath('file"with"quote.txt').isValid).toBe(true);
});
it('should reject long paths with ellipses', () => {
expect(
validatePath('this/is/a/very/long/path/with/ellipses/.../and/more')
.isValid,
).toBe(false);
});
it('should allow short paths with braces', () => {
expect(validatePath('{a,b}.ts').isValid).toBe(true);
});
});
+103
View File
@@ -0,0 +1,103 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Result of a path validation check.
*/
export interface PathValidationResult {
isValid: boolean;
error?: string;
}
/**
* Common path limits.
* While some OSs support longer, 4096 is a safe cross-platform limit for absolute paths.
* Individual components are usually limited to 255.
*/
const MAX_PATH_LENGTH = 4096;
const MAX_COMPONENT_LENGTH = 255;
/**
* Validates a path string for common issues that lead to system-level crashes (like ENAMETOOLONG).
* This is intended as a "pre-flight" check for paths derived from untrusted model output.
*/
export function validatePath(pathStr: string): PathValidationResult {
if (!pathStr || typeof pathStr !== 'string') {
return { isValid: false, error: 'Path must be a non-empty string.' };
}
// Check for obviously invalid characters (newlines, control characters, null bytes)
// These often appear when the model misinterprets logs as paths.
if (/[\n\r\0\t]/.test(pathStr)) {
return {
isValid: false,
error:
'Path contains invalid characters (newlines or control characters).',
};
}
// Check for common log/error patterns that are definitely not paths
const logMarkers = [
'AssertionError',
'FAIL',
'✓',
'×',
'TestingLibraryElementError',
];
for (const marker of logMarkers) {
if (pathStr.includes(marker)) {
return {
isValid: false,
error: 'Path appears to be a misinterpreted log fragment.',
};
}
}
// If it contains characters like '{', '}', '(', ')', '[', ']' and it's long, it's likely a log
if (pathStr.length > 50 && /[{}[\]()]/.test(pathStr)) {
return {
isValid: false,
error:
'Path appears to be a misinterpreted log fragment or code snippet.',
};
}
// Check for quotes or ellipses in "paths" - almost always a misinterpretation if not a very short name
if (
pathStr.includes('"') ||
pathStr.includes("'") ||
pathStr.includes('...')
) {
if (pathStr.length > 20) {
return {
isValid: false,
error:
'Path contains suspicious characters (quotes or ellipses) and is too long to be a simple filename.',
};
}
}
// Check total length
if (pathStr.length > MAX_PATH_LENGTH) {
return {
isValid: false,
error: `Path is too long (maximum ${MAX_PATH_LENGTH} characters).`,
};
}
// Check individual component lengths
const components = pathStr.split(/[/\\]/);
for (const component of components) {
if (component.length > MAX_COMPONENT_LENGTH) {
return {
isValid: false,
error: `Path component "${component.substring(0, 20)}..." is too long (maximum ${MAX_COMPONENT_LENGTH} characters).`,
};
}
}
return { isValid: true };
}