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

View File

@@ -75,15 +75,46 @@ describe('handleAtCommand', () => {
getFileSystemService: () => new StandardFileSystemService(),
getEnableRecursiveFileSearch: vi.fn(() => true),
getWorkspaceContext: () => ({
isPathWithinWorkspace: () => true,
isPathWithinWorkspace: (p: string) =>
p.startsWith(testRootDir) || p.startsWith('/private' + testRootDir),
getDirectories: () => [testRootDir],
}),
storage: {
getProjectTempDir: () => path.join(os.tmpdir(), 'gemini-cli-temp'),
},
isPathAllowed(this: Config, absolutePath: string): boolean {
if (this.interactive && path.isAbsolute(absolutePath)) {
return true;
}
const workspaceContext = this.getWorkspaceContext();
if (workspaceContext.isPathWithinWorkspace(absolutePath)) {
return true;
}
const projectTempDir = this.storage.getProjectTempDir();
const resolvedProjectTempDir = path.resolve(projectTempDir);
return (
absolutePath.startsWith(resolvedProjectTempDir + path.sep) ||
absolutePath === resolvedProjectTempDir
);
},
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 validation failed: Attempted path "${absolutePath}" resolves outside the allowed workspace directories: ${workspaceDirs.join(', ')} or the project temp directory: ${projectTempDir}`;
},
getMcpServers: () => ({}),
getMcpServerCommand: () => undefined,
getPromptRegistry: () => ({
getPromptsByServer: () => [],
}),
getDebugMode: () => false,
getWorkingDir: () => '/working/dir',
getFileExclusions: () => ({
getCoreIgnorePatterns: () => COMMON_IGNORE_PATTERNS,
getDefaultExcludePatterns: () => [],

View File

@@ -230,8 +230,11 @@ export async function handleAtCommand({
continue;
}
const workspaceContext = config.getWorkspaceContext();
if (!workspaceContext.isPathWithinWorkspace(pathName)) {
const resolvedPathName = path.isAbsolute(pathName)
? pathName
: path.resolve(config.getTargetDir(), pathName);
if (!config.isPathAllowed(resolvedPathName)) {
onDebugMessage(
`Path ${pathName} is not in the workspace and will be skipped.`,
);

View File

@@ -77,15 +77,46 @@ describe('handleAtCommand with Agents', () => {
getFileSystemService: () => new StandardFileSystemService(),
getEnableRecursiveFileSearch: vi.fn(() => true),
getWorkspaceContext: () => ({
isPathWithinWorkspace: () => true,
isPathWithinWorkspace: (p: string) =>
p.startsWith(testRootDir) || p.startsWith('/private' + testRootDir),
getDirectories: () => [testRootDir],
}),
storage: {
getProjectTempDir: () => path.join(os.tmpdir(), 'gemini-cli-temp'),
},
isPathAllowed(this: Config, absolutePath: string): boolean {
if (this.interactive && path.isAbsolute(absolutePath)) {
return true;
}
const workspaceContext = this.getWorkspaceContext();
if (workspaceContext.isPathWithinWorkspace(absolutePath)) {
return true;
}
const projectTempDir = this.storage.getProjectTempDir();
const resolvedProjectTempDir = path.resolve(projectTempDir);
return (
absolutePath.startsWith(resolvedProjectTempDir + path.sep) ||
absolutePath === resolvedProjectTempDir
);
},
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 validation failed: Attempted path "${absolutePath}" resolves outside the allowed workspace directories: ${workspaceDirs.join(', ')} or the project temp directory: ${projectTempDir}`;
},
getMcpServers: () => ({}),
getMcpServerCommand: () => undefined,
getPromptRegistry: () => ({
getPromptsByServer: () => [],
}),
getDebugMode: () => false,
getWorkingDir: () => '/working/dir',
getFileExclusions: () => ({
getCoreIgnorePatterns: () => COMMON_IGNORE_PATTERNS,
getDefaultExcludePatterns: () => [],
@@ -102,8 +133,9 @@ describe('handleAtCommand with Agents', () => {
getMcpClientManager: () => ({
getClient: () => undefined,
}),
getAgentRegistry: () => mockAgentRegistry,
getMessageBus: () => mockMessageBus,
interactive: true,
getAgentRegistry: () => mockAgentRegistry,
} as unknown as Config;
const registry = new ToolRegistry(mockConfig, mockMessageBus);

View File

@@ -56,6 +56,7 @@ const MockedGeminiClientClass = vi.hoisted(() =>
this.startChat = mockStartChat;
this.sendMessageStream = mockSendMessageStream;
this.addHistory = vi.fn();
this.getCurrentSequenceModel = vi.fn();
this.getChat = vi.fn().mockReturnValue({
recordCompletedToolCalls: vi.fn(),
});
@@ -75,6 +76,13 @@ const MockedUserPromptEvent = vi.hoisted(() =>
);
const mockParseAndFormatApiError = vi.hoisted(() => vi.fn());
const MockValidationRequiredError = vi.hoisted(
() =>
class extends Error {
userHandled = false;
},
);
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actualCoreModule = (await importOriginal()) as any;
return {
@@ -82,6 +90,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
GitService: vi.fn(),
GeminiClient: MockedGeminiClientClass,
UserPromptEvent: MockedUserPromptEvent,
ValidationRequiredError: MockValidationRequiredError,
parseAndFormatApiError: mockParseAndFormatApiError,
tokenLimit: vi.fn().mockReturnValue(100), // Mock tokenLimit
};
@@ -221,6 +230,7 @@ describe('useGeminiStream', () => {
getApprovalMode: () => ApprovalMode.DEFAULT,
getUsageStatisticsEnabled: () => true,
getDebugMode: () => false,
getWorkingDir: () => '/working/dir',
addHistory: vi.fn(),
getSessionId() {
return 'test-session-id';
@@ -228,6 +238,7 @@ describe('useGeminiStream', () => {
setQuotaErrorOccurred: vi.fn(),
getQuotaErrorOccurred: vi.fn(() => false),
getModel: vi.fn(() => 'gemini-2.5-pro'),
getContentGenerator: vi.fn(),
getContentGeneratorConfig: vi
.fn()
.mockReturnValue(contentGeneratorConfig),
@@ -1671,6 +1682,7 @@ describe('useGeminiStream', () => {
const testConfig = {
...mockConfig,
getContentGenerator: vi.fn(),
getContentGeneratorConfig: vi.fn(() => ({
authType: mockAuthType,
})),

View File

@@ -65,6 +65,7 @@ const mockConfig = {
getSessionId: () => 'test-session-id',
getUsageStatisticsEnabled: () => true,
getDebugMode: () => false,
getWorkingDir: () => '/working/dir',
storage: {
getProjectTempDir: () => '/tmp',
},