mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-28 14:04:41 -07:00
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:
@@ -16,6 +16,7 @@ import {
|
||||
import type { RipGrepToolParams } from './ripGrep.js';
|
||||
import { canUseRipgrep, RipGrepTool, ensureRgPath } from './ripGrep.js';
|
||||
import path from 'node:path';
|
||||
import { isSubpath } from '../utils/paths.js';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import type { Config } from '../config/config.js';
|
||||
@@ -246,13 +247,7 @@ describe('RipGrepTool', () => {
|
||||
let ripgrepBinaryPath: string;
|
||||
let grepTool: RipGrepTool;
|
||||
const abortSignal = new AbortController().signal;
|
||||
|
||||
const mockConfig = {
|
||||
getTargetDir: () => tempRootDir,
|
||||
getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir),
|
||||
getDebugMode: () => false,
|
||||
getFileFilteringRespectGeminiIgnore: () => true,
|
||||
} as unknown as Config;
|
||||
let mockConfig: Config;
|
||||
|
||||
beforeEach(async () => {
|
||||
downloadRipGrepMock.mockReset();
|
||||
@@ -266,6 +261,35 @@ describe('RipGrepTool', () => {
|
||||
await fs.writeFile(ripgrepBinaryPath, '');
|
||||
storageSpy.mockImplementation(() => binDir);
|
||||
tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grep-tool-root-'));
|
||||
|
||||
mockConfig = {
|
||||
getTargetDir: () => tempRootDir,
|
||||
getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir),
|
||||
getDebugMode: () => false,
|
||||
getFileFilteringRespectGeminiIgnore: () => true,
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
|
||||
},
|
||||
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;
|
||||
|
||||
grepTool = new RipGrepTool(mockConfig, createMockMessageBus());
|
||||
|
||||
// Create some test files and directories
|
||||
@@ -311,11 +335,6 @@ describe('RipGrepTool', () => {
|
||||
params: { pattern: 'hello', dir_path: '.', include: '*.txt' },
|
||||
expected: null,
|
||||
},
|
||||
{
|
||||
name: 'invalid regex pattern',
|
||||
params: { pattern: '[[' },
|
||||
expected: null,
|
||||
},
|
||||
])(
|
||||
'should return null for valid params ($name)',
|
||||
({ params, expected }) => {
|
||||
@@ -323,6 +342,13 @@ describe('RipGrepTool', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('should throw error for invalid regex pattern', () => {
|
||||
const params: RipGrepToolParams = { pattern: '[[' };
|
||||
expect(grepTool.validateToolParams(params)).toMatch(
|
||||
/Invalid regular expression pattern provided/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error if pattern is missing', () => {
|
||||
const params = { dir_path: '.' } as unknown as RipGrepToolParams;
|
||||
expect(grepTool.validateToolParams(params)).toBe(
|
||||
@@ -336,10 +362,9 @@ describe('RipGrepTool', () => {
|
||||
dir_path: 'nonexistent',
|
||||
};
|
||||
// Check for the core error message, as the full path might vary
|
||||
expect(grepTool.validateToolParams(params)).toContain(
|
||||
'Path does not exist',
|
||||
);
|
||||
expect(grepTool.validateToolParams(params)).toContain('nonexistent');
|
||||
const result = grepTool.validateToolParams(params);
|
||||
expect(result).toMatch(/Path does not exist/);
|
||||
expect(result).toMatch(/nonexistent/);
|
||||
});
|
||||
|
||||
it('should allow path to be a file', async () => {
|
||||
@@ -550,19 +575,10 @@ describe('RipGrepTool', () => {
|
||||
expect(result.returnDisplay).toBe('No matches found');
|
||||
});
|
||||
|
||||
it('should return an error from ripgrep for invalid regex pattern', async () => {
|
||||
mockSpawn.mockImplementationOnce(
|
||||
createMockSpawn({
|
||||
exitCode: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
it('should throw error for invalid regex pattern during build', async () => {
|
||||
const params: RipGrepToolParams = { pattern: '[[' };
|
||||
const invocation = grepTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
expect(result.llmContent).toContain('Process exited with code 2');
|
||||
expect(result.returnDisplay).toContain(
|
||||
'Error: Process exited with code 2',
|
||||
expect(() => grepTool.build(params)).toThrow(
|
||||
/Invalid regular expression pattern provided/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -763,6 +779,27 @@ describe('RipGrepTool', () => {
|
||||
createMockWorkspaceContext(tempRootDir, [secondDir]),
|
||||
getDebugMode: () => false,
|
||||
getFileFilteringRespectGeminiIgnore: () => true,
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
|
||||
},
|
||||
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 specific mock for this test - multi-directory search for 'world'
|
||||
@@ -850,6 +887,27 @@ describe('RipGrepTool', () => {
|
||||
createMockWorkspaceContext(tempRootDir, [secondDir]),
|
||||
getDebugMode: () => false,
|
||||
getFileFilteringRespectGeminiIgnore: () => true,
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
|
||||
},
|
||||
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 specific mock for this test - searching in 'sub' should only return matches from that directory
|
||||
@@ -931,7 +989,7 @@ describe('RipGrepTool', () => {
|
||||
pattern: 'test',
|
||||
dir_path: '../outside',
|
||||
};
|
||||
expect(() => grepTool.build(params)).toThrow(/Path validation failed/);
|
||||
expect(() => grepTool.build(params)).toThrow(/Path not in workspace/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -1353,6 +1411,27 @@ describe('RipGrepTool', () => {
|
||||
getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir),
|
||||
getDebugMode: () => false,
|
||||
getFileFilteringRespectGeminiIgnore: () => true,
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
|
||||
},
|
||||
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;
|
||||
const geminiIgnoreTool = new RipGrepTool(
|
||||
configWithGeminiIgnore,
|
||||
@@ -1393,6 +1472,27 @@ describe('RipGrepTool', () => {
|
||||
getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir),
|
||||
getDebugMode: () => false,
|
||||
getFileFilteringRespectGeminiIgnore: () => false,
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
|
||||
},
|
||||
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;
|
||||
const geminiIgnoreTool = new RipGrepTool(
|
||||
configWithoutGeminiIgnore,
|
||||
@@ -1518,6 +1618,27 @@ describe('RipGrepTool', () => {
|
||||
getWorkspaceContext: () =>
|
||||
createMockWorkspaceContext(tempRootDir, ['/another/dir']),
|
||||
getDebugMode: () => false,
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
|
||||
},
|
||||
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;
|
||||
|
||||
const multiDirGrepTool = new RipGrepTool(
|
||||
|
||||
Reference in New Issue
Block a user