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
@@ -0,0 +1,452 @@
/**
* @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 fsPromises from 'node:fs/promises';
import type { Stats } from 'node:fs';
import { resolveAtCommandPath } from './atCommandUtils.js';
import { type Config } from '../config/config.js';
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(fsPromises.stat).mockResolvedValue(mockStats as unknown as Stats);
const result = await resolveAtCommandPath(
'file.ts',
mockConfig as unknown as Config,
);
expect(result.status).toBe('resolved');
if (result.status === 'resolved') {
expect(result.resolved.absolutePath).toBe(
path.resolve('/mock/root', 'file.ts'),
);
expect(result.resolved.relativePath).toBe('file.ts');
}
});
it('should resolve an absolute path', async () => {
const mockStats = {
isDirectory: () => false,
isFile: () => true,
};
vi.mocked(fsPromises.stat).mockResolvedValue(mockStats as unknown as Stats);
const absolutePath = path.resolve('/mock/root', 'src/index.ts');
const result = await resolveAtCommandPath(
absolutePath,
mockConfig as unknown as Config,
);
expect(result.status).toBe('resolved');
if (result.status === 'resolved') {
expect(result.resolved.absolutePath).toBe(absolutePath);
expect(result.resolved.relativePath).toBe(path.join('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(fsPromises.stat).mockImplementation(async (p) => {
if (p === path.resolve('/dir2', 'file.txt')) {
return mockStats as unknown as Stats;
}
throw new Error('ENOENT');
});
const result = await resolveAtCommandPath(
'file.txt',
mockConfig as unknown as Config,
);
expect(result.status).toBe('resolved');
if (result.status === 'resolved') {
expect(result.resolved.absolutePath).toBe(
path.resolve('/dir2', 'file.txt'),
);
expect(result.resolved.relativePath).toBe('file.txt');
}
});
it('should return invalid for invalid path (too long)', async () => {
const longPath = 'a'.repeat(5000);
const result = await resolveAtCommandPath(
longPath,
mockConfig as unknown as Config,
);
expect(result.status).toBe('invalid');
});
it('should return invalid for path with log markers (and no valid subpath)', async () => {
const onDebug = vi.fn();
const result = await resolveAtCommandPath(
'FAIL AssertionError: expected true to be false',
mockConfig as unknown as Config,
onDebug,
);
expect(result.status).toBe('invalid');
expect(onDebug).toHaveBeenCalledWith(
expect.stringContaining('Skipping invalid path'),
);
});
it('should return not_found if path does not exist in any workspace directory', async () => {
vi.mocked(fsPromises.stat).mockRejectedValue(new Error('ENOENT'));
const result = await resolveAtCommandPath(
'nonexistent.ts',
mockConfig as unknown as Config,
);
expect(result.status).toBe('not_found');
});
it('should resolve directory paths correctly', async () => {
const mockStats = {
isDirectory: () => true,
isFile: () => false,
};
vi.mocked(fsPromises.stat).mockResolvedValue(mockStats as unknown as Stats);
const result = await resolveAtCommandPath(
'src',
mockConfig as unknown as Config,
);
expect(result.status).toBe('resolved');
if (result.status === 'resolved') {
expect(result.resolved.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.status).toBe('unauthorized');
});
it('should return unauthorized for paths outside root', async () => {
(mockConfig['validatePathAccess'] as Mock).mockReturnValue(
'Outside workspace',
);
(mockConfig['getTargetDir'] as Mock).mockReturnValue('/mock/workspace');
const mockStats = {
isDirectory: () => false,
isFile: () => true,
};
vi.mocked(fsPromises.stat).mockResolvedValue(mockStats as unknown as Stats);
// Path resolve will use /mock/root as base from mockWorkspaceContext
const result = await resolveAtCommandPath(
'outside.txt',
mockConfig as unknown as Config,
);
expect(result.status).toBe('unauthorized');
if (result.status === 'unauthorized') {
expect(result.absolutePath).toBe(
path.resolve('/mock/root', 'outside.txt'),
);
}
});
it('should not treat paths with shared prefixes as subpaths if not actually inside', async () => {
// /mock/root-backup/file.txt starts with /mock/root but is not inside it.
const dir = '/mock/root';
const otherPath = '/mock/root-backup/file.txt';
(mockWorkspaceContext['getDirectories'] as Mock).mockReturnValue([dir]);
const mockStats = {
isDirectory: () => false,
isFile: () => true,
};
vi.mocked(fsPromises.stat).mockResolvedValue(mockStats as unknown as Stats);
const result = await resolveAtCommandPath(
otherPath,
mockConfig as unknown as Config,
);
expect(result.status).toBe('resolved');
if (result.status === 'resolved') {
expect(result.resolved.absolutePath).toBe(otherPath);
// It should NOT be relative to /mock/root because it's not actually inside it.
// path.relative('/mock/root', '/mock/root-backup/file.txt') -> '../root-backup/file.txt'
// Our fix should prevent this from being used as a relative path.
expect(result.resolved.relativePath).toBe(otherPath);
}
});
it('should resolve paths in deeply nested workspace directories', async () => {
const dir = path.join('/mock', 'root', 'nested', 'project');
const relFile = path.join('src', 'index.ts');
const absFile = path.join(dir, relFile);
(mockWorkspaceContext['getDirectories'] as Mock).mockReturnValue([dir]);
const mockStats = {
isDirectory: () => false,
isFile: () => true,
};
vi.mocked(fsPromises.stat).mockResolvedValue(mockStats as unknown as Stats);
const result = await resolveAtCommandPath(
absFile,
mockConfig as unknown as Config,
);
expect(result.status).toBe('resolved');
if (result.status === 'resolved') {
expect(result.resolved.absolutePath).toBe(absFile);
expect(result.resolved.relativePath).toBe(relFile);
}
});
it('should extract and resolve a buried path from a log fragment', async () => {
const buriedFile = 'src/utils/math.ts';
const logFragment = `FAIL ${buriedFile}:42:1 (AssertionError)`;
const mockStats = {
isDirectory: () => false,
isFile: () => true,
};
vi.mocked(fsPromises.stat).mockImplementation(async (p) => {
if (p === path.resolve('/mock/root', buriedFile)) {
return mockStats as unknown as Stats;
}
throw new Error('ENOENT');
});
const result = await resolveAtCommandPath(
logFragment,
mockConfig as unknown as Config,
);
expect(result.status).toBe('resolved');
if (result.status === 'resolved') {
expect(result.resolved.absolutePath).toBe(
path.resolve('/mock/root', buriedFile),
);
expect(result.resolved.relativePath).toBe(buriedFile);
}
});
describe('Best-Effort Path Extraction (tryExtractPath)', () => {
const mockFile = 'src/index.ts';
const absMockFile = path.resolve('/mock/root', mockFile);
const mockStats = { isDirectory: () => false, isFile: () => true };
beforeEach(() => {
vi.mocked(fsPromises.stat).mockImplementation(async (p) => {
if (p === absMockFile) return mockStats as unknown as Stats;
throw new Error('ENOENT');
});
});
it('should extract path from "AssertionError: ..." format', async () => {
const result = await resolveAtCommandPath(
`AssertionError: expected something but got something else at ${mockFile}:10:5`,
mockConfig as unknown as Config,
);
expect(result.status).toBe('resolved');
if (result.status === 'resolved') {
expect(result.resolved.absolutePath).toBe(absMockFile);
}
});
it('should extract path wrapped in parentheses', async () => {
const result = await resolveAtCommandPath(
`FAIL (${mockFile})`,
mockConfig as unknown as Config,
);
expect(result.status).toBe('resolved');
if (result.status === 'resolved') {
expect(result.resolved.absolutePath).toBe(absMockFile);
}
});
it('should extract path wrapped in square brackets', async () => {
const result = await resolveAtCommandPath(
`FAIL [${mockFile}]`,
mockConfig as unknown as Config,
);
expect(result.status).toBe('resolved');
if (result.status === 'resolved') {
expect(result.resolved.absolutePath).toBe(absMockFile);
}
});
it('should extract path from "✓" pass marker', async () => {
const result = await resolveAtCommandPath(
`${mockFile}`,
mockConfig as unknown as Config,
);
expect(result.status).toBe('resolved');
});
it('should extract path from "×" fail marker', async () => {
const result = await resolveAtCommandPath(
`× ${mockFile}`,
mockConfig as unknown as Config,
);
expect(result.status).toBe('resolved');
});
it('should handle multiple trailing punctuation marks like file.txt...', async () => {
const result = await resolveAtCommandPath(
`FAIL ${mockFile}...`,
mockConfig as unknown as Config,
);
expect(result.status).toBe('resolved');
if (result.status === 'resolved') {
expect(result.resolved.absolutePath).toBe(absMockFile);
}
});
it('should handle nested wrappers like ("path/to/file.ts")', async () => {
const result = await resolveAtCommandPath(
`FAIL ("${mockFile}")`,
mockConfig as unknown as Config,
);
expect(result.status).toBe('resolved');
if (result.status === 'resolved') {
expect(result.resolved.absolutePath).toBe(absMockFile);
}
});
it('should NOT strip traversal (..), but let central validation handle it', async () => {
const traversalPath = 'src/../../etc/passwd';
const absPath = path.resolve('/mock/root', traversalPath);
(mockConfig['validatePathAccess'] as Mock).mockImplementation((p) => {
if (p === absPath) return 'Outside workspace';
return null;
});
const result = await resolveAtCommandPath(
`FAIL ${traversalPath}`,
mockConfig as unknown as Config,
);
// It should NOT be stripped. It should resolve to the absolute path and fail authorization.
expect(result.status).toBe('unauthorized');
if (result.status === 'unauthorized') {
expect(result.absolutePath).toBe(absPath);
}
});
it('should reject paths with null bytes via validatePath', async () => {
const nullBytePath = 'src/index.ts\0.exe';
const result = await resolveAtCommandPath(
`FAIL ${nullBytePath}`,
mockConfig as unknown as Config,
);
// validatePath rejects strings with null bytes
expect(result.status).toBe('invalid');
});
it('should handle paths with slashes and extensions correctly', async () => {
const complexPath = 'packages/core/src/utils/deep.test.ts';
const absComplexPath = path.resolve('/mock/root', complexPath);
vi.mocked(fsPromises.stat).mockImplementation(async (p) => {
if (p === absComplexPath) return mockStats as unknown as Stats;
throw new Error('ENOENT');
});
const result = await resolveAtCommandPath(
`FAIL ${complexPath}:123`,
mockConfig as unknown as Config,
);
expect(result.status).toBe('resolved');
if (result.status === 'resolved') {
expect(result.resolved.relativePath).toBe(complexPath);
}
});
it('should fail gracefully if no valid path can be extracted', async () => {
const result = await resolveAtCommandPath(
'FAIL some random text with no slashes or dots',
mockConfig as unknown as Config,
);
expect(result.status).toBe('invalid');
});
it('should return unauthorized if the extracted path is not authorized', async () => {
const secretFile = '/etc/passwd';
(mockConfig['validatePathAccess'] as Mock).mockImplementation((p) =>
p === secretFile ? 'Unauthorized' : null,
);
vi.mocked(fsPromises.stat).mockResolvedValue(
mockStats as unknown as Stats,
);
const result = await resolveAtCommandPath(
`FAIL ${secretFile}`,
mockConfig as unknown as Config,
);
// It should try to resolve /etc/passwd, identify it as unauthorized, and return that status.
expect(result.status).toBe('unauthorized');
});
});
it('should include reason in debug message for unauthorized paths', async () => {
const onDebug = vi.fn();
(mockConfig['validatePathAccess'] as Mock).mockReturnValue(
'FORBIDDEN_ZONE',
);
await resolveAtCommandPath(
'secret.txt',
mockConfig as unknown as Config,
onDebug,
);
expect(onDebug).toHaveBeenCalledWith(
expect.stringContaining('Reason: FORBIDDEN_ZONE'),
);
});
});
+215
View File
@@ -0,0 +1,215 @@
/**
* @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 } from './path-validator.js';
import { type Config } from '../config/config.js';
import { isNodeError, getErrorMessage } from './errors.js';
export interface ResolvedAtCommandPath {
absolutePath: string;
relativePath: string;
stats: {
isDirectory(): boolean;
isFile(): boolean;
};
}
/**
* Result of a path resolution attempt.
*/
export type ResolveAtCommandPathResult =
| { status: 'resolved'; resolved: ResolvedAtCommandPath }
| { status: 'unauthorized'; absolutePath: string; error: string }
| { status: 'invalid'; error: string }
| { status: 'not_found' };
/**
* Resolves a path from an @-command, ensuring it is valid and within workspace boundaries.
* Performs best-effort extraction if the input appears to be a misinterpreted log fragment.
*/
export async function resolveAtCommandPath(
pathName: string,
config: Config,
onDebugMessage: (msg: string) => void = () => {},
): Promise<ResolveAtCommandPathResult> {
const pathValidation = validatePath(pathName);
if (!pathValidation.isValid) {
// Attempt to extract a real path from the invalid fragment
const extractedPath = tryExtractPath(pathName);
if (extractedPath && extractedPath !== pathName) {
onDebugMessage(
`Identified invalid path fragment, attempting to extract path: "${extractedPath}" from "${pathName}"`,
);
// Recurse once with the extracted path.
return resolveAtCommandPath(extractedPath, config, onDebugMessage);
}
onDebugMessage(
`Skipping invalid path in @-command: ${pathName}. Reason: ${pathValidation.error}`,
);
return { status: 'invalid', error: pathValidation.error! };
}
const workspaceDirs = config.getWorkspaceContext().getDirectories();
// If it's an absolute path, we only need to check it against authorization once.
if (path.isAbsolute(pathName)) {
const validationError = config.validatePathAccess(pathName, 'read');
if (validationError) {
onDebugMessage(
`Skipping unauthorized absolute path: ${pathName}. Reason: ${validationError}`,
);
return {
status: 'unauthorized',
absolutePath: pathName,
error: validationError,
};
}
try {
const stats = await fs.stat(pathName);
// Try to find if it's within one of the workspace directories to provide a nice relative path
let relativePath = pathName;
for (const dir of workspaceDirs) {
const rel = path.relative(dir, pathName);
if (!rel.startsWith('..') && !path.isAbsolute(rel)) {
relativePath = rel;
break;
}
}
return {
status: 'resolved',
resolved: {
absolutePath: pathName,
relativePath,
stats,
},
};
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
return { status: 'not_found' };
}
onDebugMessage(
`Unexpected error stating path ${pathName}: ${getErrorMessage(error)}`,
);
return { status: 'not_found' };
}
}
// For relative paths, try each workspace directory.
let lastUnauthorized: { absolutePath: string; error: string } | null = null;
for (const dir of workspaceDirs) {
const absolutePath = path.resolve(dir, pathName);
// Final workspace boundary check using centralized logic
const validationError = config.validatePathAccess(absolutePath, 'read');
if (validationError) {
onDebugMessage(
`Skipping unauthorized path: ${absolutePath}. Reason: ${validationError}`,
);
// We only care about unauthorized paths if we can't find a valid authorized one.
lastUnauthorized = { absolutePath, error: validationError };
continue;
}
try {
const stats = await fs.stat(absolutePath);
return {
status: 'resolved',
resolved: {
absolutePath,
relativePath: pathName,
stats,
},
};
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
// Expected if path is not in this directory, continue to next
continue;
}
onDebugMessage(
`Unexpected error stating path ${absolutePath}: ${getErrorMessage(error)}`,
);
}
}
if (lastUnauthorized) {
return { status: 'unauthorized', ...lastUnauthorized };
}
return { status: 'not_found' };
}
/**
* Attempts to extract a valid-looking path from a noisy string (like a log fragment).
*/
function tryExtractPath(noisyString: string): string | null {
// Split by whitespace to find individual segments
const segments = noisyString.split(/\s+/);
for (const segment of segments) {
// 1. Strip leading/trailing punctuation and quotes commonly found in logs
// We handle nested wrappers like ("path/to/file.txt") or (at src/index.ts)
let segmentToClean = segment;
const wrappers = [
'(',
')',
'[',
']',
'{',
'}',
'"',
"'",
',',
';',
'!',
'.',
];
let wasStripped = true;
while (wasStripped && segmentToClean.length > 0) {
wasStripped = false;
const firstChar = segmentToClean[0];
const lastChar = segmentToClean[segmentToClean.length - 1];
// Strip known punctuation from the start or end
if (wrappers.includes(firstChar)) {
segmentToClean = segmentToClean.slice(1);
wasStripped = true;
} else if (wrappers.includes(lastChar)) {
segmentToClean = segmentToClean.slice(0, -1);
wasStripped = true;
}
}
if (segmentToClean.length === 0) continue;
// 2. Strip trailing line/column numbers (e.g. src/main.ts:10:5)
// We handle the case where it might be wrapped in more text, e.g. at (src/index.ts:123)
const lineMatch = segmentToClean.match(/^(.+?):(\d+)(?::\d+)?/);
const pathOnly = lineMatch ? lineMatch[1] : segmentToClean;
// 3. Validate the extracted segment using centralized heuristics.
// We rely on validatePath and Config.validatePathAccess for robust checking
// rather than naive string stripping which can be bypassed or corrupt valid names.
if (validatePath(pathOnly).isValid) {
// Prioritize segments that actually look like paths (have slashes or dots)
if (
pathOnly.includes('/') ||
pathOnly.includes('\\') ||
pathOnly.includes('.')
) {
return pathOnly;
}
}
}
return null;
}
@@ -0,0 +1,124 @@
/**
* @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 allow paths with single quotes (apostrophes)', () => {
// This was previously a false positive
expect(validatePath("/Users/john's_files/project/index.ts").isValid).toBe(
true,
);
});
it('should allow long paths with brackets or parentheses', () => {
// These were previously false positives (Next.js dynamic routes, Windows copies)
expect(
validatePath('packages/web/app/dashboard/[id]/settings/page.tsx').isValid,
).toBe(true);
expect(
validatePath('/Users/name/Documents/Project (Copy)/index.ts').isValid,
).toBe(true);
});
it('should only reject log markers at the start of a component', () => {
// Legitimate paths containing these strings should now be allowed
expect(validatePath('src/tests/FAIL_CASE.txt').isValid).toBe(true);
expect(validatePath('FAILURE_LOG.txt').isValid).toBe(true);
expect(validatePath('docs/AssertionError_details.md').isValid).toBe(true);
// But they should be rejected if they start a component
expect(validatePath('FAIL tests/int/my.test.ts').isValid).toBe(false);
expect(validatePath('/project/root/FAIL tests/my.test.ts').isValid).toBe(
false,
);
expect(
validatePath('AssertionError: expected true to be false').isValid,
).toBe(false);
expect(validatePath('✓ test passed').isValid).toBe(false);
});
it('should reject misinterpreted log fragments with double 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 allow short paths with double 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 paths with Unicode characters', () => {
expect(validatePath('src/文件.ts').isValid).toBe(true);
expect(validatePath('docs/🚀_launch.md').isValid).toBe(true);
});
it('should allow paths with multiple consecutive slashes (normalizing is handled by OS layer)', () => {
expect(validatePath('src//index.ts').isValid).toBe(true);
});
it('should allow paths with trailing slashes', () => {
expect(validatePath('src/utils/').isValid).toBe(true);
});
it('should allow paths with dots as components', () => {
expect(validatePath('./src/../index.ts').isValid).toBe(true);
});
it('should reject paths that are only dots if they exceed suspicious length (none currently do)', () => {
expect(validatePath('...').isValid).toBe(true); // Short ellipses are allowed as filenames
});
it('should reject paths with mixed invalid characters', () => {
expect(validatePath('path\nwith\0invalid').isValid).toBe(false);
});
});
+93
View File
@@ -0,0 +1,93 @@
/**
* @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.
// We check for these at the start of the string OR at the start of any path component.
// This ensures we catch them in both raw model output and resolved absolute paths.
const logMarkerRegexes = [
/(^|[/\\])AssertionError:/,
/(^|[/\\])FAIL /,
/(^|[/\\])✓ /,
/(^|[/\\])× /,
/(^|[/\\])TestingLibraryElementError:/,
];
for (const regex of logMarkerRegexes) {
if (regex.test(pathStr)) {
return {
isValid: false,
error: 'Path appears to be a misinterpreted log fragment.',
};
}
}
// Check for double quotes or ellipses in "paths" - almost always a misinterpretation if not a very short name.
// We removed single quotes from this list to support users with apostrophes in their home directories.
if (pathStr.includes('"') || pathStr.includes('...')) {
if (pathStr.length > 20) {
return {
isValid: false,
error:
'Path contains suspicious characters (double 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 };
}