refactor(core): centralize path validation and allow temp dir access for tools (#17185)

Co-authored-by: Your Name <joshualitt@google.com>
This commit is contained in:
N. Taylor Mullen
2026-01-27 13:17:40 -08:00
committed by GitHub
parent c9340a9c6f
commit 5f569fa103
26 changed files with 1149 additions and 609 deletions
+44 -21
View File
@@ -28,6 +28,7 @@ import type { Config } from '../config/config.js';
import { ApprovalMode } from '../policy/types.js';
import type { ToolRegistry } from './tool-registry.js';
import path from 'node:path';
import { isSubpath } from '../utils/paths.js';
import fs from 'node:fs';
import os from 'node:os';
import { GeminiClient } from '../core/client.js';
@@ -59,6 +60,7 @@ vi.mock('../ide/ide-client.js', () => ({
}));
let mockGeminiClientInstance: Mocked<GeminiClient>;
let mockBaseLlmClientInstance: Mocked<BaseLlmClient>;
let mockConfig: Config;
const mockEnsureCorrectEdit = vi.fn<typeof ensureCorrectEdit>();
const mockEnsureCorrectFileContent = vi.fn<typeof ensureCorrectFileContent>();
const mockIdeClient = {
@@ -108,8 +110,10 @@ const mockConfigInternal = {
}) as unknown as ToolRegistry,
isInteractive: () => false,
getDisableLLMCorrection: vi.fn(() => true),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
},
};
const mockConfig = mockConfigInternal as unknown as Config;
vi.mock('../telemetry/loggers.js', () => ({
logFileOperation: vi.fn(),
@@ -135,6 +139,35 @@ describe('WriteFileTool', () => {
fs.mkdirSync(plansDir, { recursive: true });
}
const workspaceContext = new WorkspaceContext(rootDir, [plansDir]);
const mockStorage = {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
};
mockConfig = {
...mockConfigInternal,
getWorkspaceContext: () => workspaceContext,
storage: mockStorage,
isPathAllowed(this: Config, absolutePath: string): boolean {
const workspaceContext = this.getWorkspaceContext();
if (workspaceContext.isPathWithinWorkspace(absolutePath)) {
return true;
}
const projectTempDir = this.storage.getProjectTempDir();
return isSubpath(path.resolve(projectTempDir), absolutePath);
},
validatePathAccess(this: Config, absolutePath: string): string | null {
if (this.isPathAllowed(absolutePath)) {
return null;
}
const workspaceDirs = this.getWorkspaceContext().getDirectories();
const projectTempDir = this.storage.getProjectTempDir();
return `Path not in workspace: Attempted path "${absolutePath}" resolves outside the allowed workspace directories: ${workspaceDirs.join(', ')} or the project temp directory: ${projectTempDir}`;
},
} as unknown as Config;
// Setup GeminiClient mock
mockGeminiClientInstance = new (vi.mocked(GeminiClient))(
mockConfig,
@@ -243,9 +276,7 @@ describe('WriteFileTool', () => {
file_path: outsidePath,
content: 'hello',
};
expect(() => tool.build(params)).toThrow(
/File path must be within one of the workspace directories/,
);
expect(() => tool.build(params)).toThrow(/Path not in workspace/);
});
it('should throw an error if path is a directory', () => {
@@ -816,9 +847,7 @@ describe('WriteFileTool', () => {
file_path: '/etc/passwd',
content: 'malicious',
};
expect(() => tool.build(params)).toThrow(
/File path must be within one of the workspace directories/,
);
expect(() => tool.build(params)).toThrow(/Path not in workspace/);
});
it('should allow paths within the plans directory', () => {
@@ -834,9 +863,7 @@ describe('WriteFileTool', () => {
file_path: path.join(plansDir, '..', 'escaped.txt'),
content: 'malicious',
};
expect(() => tool.build(params)).toThrow(
/File path must be within one of the workspace directories/,
);
expect(() => tool.build(params)).toThrow(/Path not in workspace/);
});
});
@@ -874,7 +901,6 @@ describe('WriteFileTool', () => {
errorMessage: 'Generic write error',
expectedMessagePrefix: 'Error writing to file',
mockFsExistsSync: false,
restoreAllMocks: true,
},
])(
'should return $errorType error when write fails with $errorCode',
@@ -884,25 +910,22 @@ describe('WriteFileTool', () => {
errorMessage,
expectedMessagePrefix,
mockFsExistsSync,
restoreAllMocks,
}) => {
const filePath = path.join(rootDir, `${errorType}_file.txt`);
const content = 'test content';
if (restoreAllMocks) {
vi.restoreAllMocks();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let existsSyncSpy: any;
let existsSyncSpy: // eslint-disable-next-line @typescript-eslint/no-explicit-any
ReturnType<typeof vi.spyOn<any, 'existsSync'>> | undefined = undefined;
try {
if (mockFsExistsSync) {
const originalExistsSync = fs.existsSync;
existsSyncSpy = vi
.spyOn(fs, 'existsSync')
.mockImplementation((path) =>
path === filePath ? false : originalExistsSync(path as string),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.spyOn(fs as any, 'existsSync')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.mockImplementation((path: any) =>
path === filePath ? false : originalExistsSync(path),
);
}