mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
fix(core): resolve nested plan directory duplication and relative path policies (#25138)
This commit is contained in:
@@ -107,6 +107,7 @@ describe('EditTool', () => {
|
||||
getGeminiClient: vi.fn().mockReturnValue(geminiClient),
|
||||
getBaseLlmClient: vi.fn().mockReturnValue(baseLlmClient),
|
||||
getTargetDir: () => rootDir,
|
||||
getProjectRoot: () => rootDir,
|
||||
getApprovalMode: vi.fn(),
|
||||
setApprovalMode: vi.fn(),
|
||||
getWorkspaceContext: () => createMockWorkspaceContext(rootDir),
|
||||
@@ -1336,8 +1337,8 @@ function doIt() {
|
||||
vi.mocked(mockConfig.isPlanMode).mockReturnValue(true);
|
||||
vi.mocked(mockConfig.storage.getPlansDir).mockReturnValue(plansDir);
|
||||
|
||||
const filePath = path.join(rootDir, 'test-file.txt');
|
||||
const planFilePath = path.join(plansDir, 'test-file.txt');
|
||||
const filePath = 'test-file.txt';
|
||||
const planFilePath = path.join(plansDir, filePath);
|
||||
const initialContent = 'some initial content';
|
||||
fs.writeFileSync(planFilePath, initialContent, 'utf8');
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ import { EDIT_DEFINITION } from './definitions/coreTools.js';
|
||||
import { resolveToolDeclaration } from './definitions/resolver.js';
|
||||
import { detectOmissionPlaceholders } from './omissionPlaceholderDetector.js';
|
||||
import { discoverJitContext, appendJitContext } from './jit-context.js';
|
||||
import { resolveAndValidatePlanPath } from '../utils/planUtils.js';
|
||||
|
||||
const ENABLE_FUZZY_MATCH_RECOVERY = true;
|
||||
const FUZZY_MATCH_THRESHOLD = 0.1; // Allow up to 10% weighted difference
|
||||
@@ -465,11 +466,21 @@ class EditToolInvocation
|
||||
() => this.config.getApprovalMode(),
|
||||
);
|
||||
if (this.config.isPlanMode()) {
|
||||
const safeFilename = path.basename(this.params.file_path);
|
||||
this.resolvedPath = path.join(
|
||||
this.config.storage.getPlansDir(),
|
||||
safeFilename,
|
||||
);
|
||||
try {
|
||||
this.resolvedPath = resolveAndValidatePlanPath(
|
||||
this.params.file_path,
|
||||
this.config.storage.getPlansDir(),
|
||||
this.config.getProjectRoot(),
|
||||
);
|
||||
} catch (e) {
|
||||
debugLogger.error(
|
||||
'Failed to resolve plan path during EditTool invocation setup',
|
||||
e,
|
||||
);
|
||||
// Validation fails, set resolvedPath to something that will fail validation downstream or just the raw path.
|
||||
// It's safer to store it so validation in execute() or getConfirmationDetails() catches it.
|
||||
this.resolvedPath = this.params.file_path;
|
||||
}
|
||||
} else if (!path.isAbsolute(this.params.file_path)) {
|
||||
const result = correctPath(this.params.file_path, this.config);
|
||||
if (result.success) {
|
||||
@@ -1054,7 +1065,17 @@ export class EditTool
|
||||
}
|
||||
|
||||
let resolvedPath: string;
|
||||
if (!path.isAbsolute(params.file_path)) {
|
||||
if (this.config.isPlanMode()) {
|
||||
try {
|
||||
resolvedPath = resolveAndValidatePlanPath(
|
||||
params.file_path,
|
||||
this.config.storage.getPlansDir(),
|
||||
this.config.getProjectRoot(),
|
||||
);
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
} else if (!path.isAbsolute(params.file_path)) {
|
||||
const result = correctPath(params.file_path, this.config);
|
||||
if (result.success) {
|
||||
resolvedPath = result.correctedPath;
|
||||
|
||||
@@ -42,6 +42,7 @@ describe('ExitPlanModeTool', () => {
|
||||
|
||||
mockConfig = {
|
||||
getTargetDir: vi.fn().mockReturnValue(tempRootDir),
|
||||
getProjectRoot: vi.fn().mockReturnValue(tempRootDir),
|
||||
setApprovalMode: vi.fn(),
|
||||
setApprovedPlanPath: vi.fn(),
|
||||
storage: {
|
||||
@@ -72,8 +73,10 @@ describe('ExitPlanModeTool', () => {
|
||||
|
||||
const createPlanFile = (name: string, content: string) => {
|
||||
const filePath = path.join(mockPlansDir, name);
|
||||
// Ensure parent directory exists for nested tests
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, content);
|
||||
return path.join('plans', name);
|
||||
return name;
|
||||
};
|
||||
|
||||
describe('shouldConfirmExecute', () => {
|
||||
@@ -482,7 +485,11 @@ Ask the user for specific feedback on how to improve the plan.`,
|
||||
});
|
||||
|
||||
it('should reject non-existent plan file', async () => {
|
||||
const result = await validatePlanPath('ghost.md', mockPlansDir);
|
||||
const result = await validatePlanPath(
|
||||
'ghost.md',
|
||||
mockPlansDir,
|
||||
tempRootDir,
|
||||
);
|
||||
expect(result).toContain('Plan file does not exist');
|
||||
});
|
||||
|
||||
@@ -497,7 +504,7 @@ Ask the user for specific feedback on how to improve the plan.`,
|
||||
});
|
||||
|
||||
expect(result).toBe(
|
||||
`Access denied: plan path (${path.join(mockPlansDir, 'malicious.md')}) must be within the designated plans directory (${mockPlansDir}).`,
|
||||
`Access denied: plan path (malicious.md) must be within the designated plans directory (${mockPlansDir}).`,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -19,9 +19,12 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import path from 'node:path';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { EXIT_PLAN_MODE_TOOL_NAME } from './tool-names.js';
|
||||
import { validatePlanPath, validatePlanContent } from '../utils/planUtils.js';
|
||||
import {
|
||||
validatePlanPath,
|
||||
validatePlanContent,
|
||||
resolveAndValidatePlanPath,
|
||||
} from '../utils/planUtils.js';
|
||||
import { ApprovalMode } from '../policy/types.js';
|
||||
import { resolveToRealPath, isSubpath } from '../utils/paths.js';
|
||||
import { logPlanExecution } from '../telemetry/loggers.js';
|
||||
import { PlanExecutionEvent } from '../telemetry/types.js';
|
||||
import { getExitPlanModeDefinition } from './definitions/coreTools.js';
|
||||
@@ -59,18 +62,14 @@ export class ExitPlanModeTool extends BaseDeclarativeTool<
|
||||
if (!params.plan_filename || params.plan_filename.trim() === '') {
|
||||
return 'plan_filename is required.';
|
||||
}
|
||||
|
||||
const safeFilename = path.basename(params.plan_filename);
|
||||
const plansDir = resolveToRealPath(this.config.storage.getPlansDir());
|
||||
const resolvedPath = path.join(
|
||||
this.config.storage.getPlansDir(),
|
||||
safeFilename,
|
||||
);
|
||||
|
||||
const realPath = resolveToRealPath(resolvedPath);
|
||||
|
||||
if (!isSubpath(plansDir, realPath)) {
|
||||
return `Access denied: plan path (${resolvedPath}) must be within the designated plans directory (${plansDir}).`;
|
||||
try {
|
||||
resolveAndValidatePlanPath(
|
||||
params.plan_filename,
|
||||
this.config.storage.getPlansDir(),
|
||||
this.config.getProjectRoot(),
|
||||
);
|
||||
} catch (e) {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -122,6 +121,7 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
|
||||
const pathError = await validatePlanPath(
|
||||
this.params.plan_filename,
|
||||
this.config.storage.getPlansDir(),
|
||||
this.config.getProjectRoot(),
|
||||
);
|
||||
if (pathError) {
|
||||
this.planValidationError = pathError;
|
||||
@@ -179,8 +179,11 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
|
||||
* Note: Validation is done in validateToolParamValues, so this assumes the path is valid.
|
||||
*/
|
||||
private getResolvedPlanPath(): string {
|
||||
const safeFilename = path.basename(this.params.plan_filename);
|
||||
return path.join(this.config.storage.getPlansDir(), safeFilename);
|
||||
return resolveAndValidatePlanPath(
|
||||
this.params.plan_filename,
|
||||
this.config.storage.getPlansDir(),
|
||||
this.config.getProjectRoot(),
|
||||
);
|
||||
}
|
||||
|
||||
async execute({ abortSignal: _signal }: ExecuteOptions): Promise<ToolResult> {
|
||||
|
||||
@@ -76,6 +76,7 @@ vi.mocked(IdeClient.getInstance).mockResolvedValue(
|
||||
const fsService = new StandardFileSystemService();
|
||||
const mockConfigInternal = {
|
||||
getTargetDir: () => rootDir,
|
||||
getProjectRoot: () => rootDir,
|
||||
getApprovalMode: vi.fn(() => ApprovalMode.DEFAULT),
|
||||
setApprovalMode: vi.fn(),
|
||||
getGeminiClient: vi.fn(), // Initialize as a plain mock function
|
||||
@@ -1113,4 +1114,26 @@ describe('WriteFileTool', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('plan mode path handling', () => {
|
||||
const abortSignal = new AbortController().signal;
|
||||
|
||||
it('should correctly resolve nested paths in plan mode', async () => {
|
||||
vi.mocked(mockConfig.isPlanMode).mockReturnValue(true);
|
||||
// Extend storage mock with getPlansDir
|
||||
mockConfig.storage.getPlansDir = vi.fn().mockReturnValue(plansDir);
|
||||
|
||||
const nestedFilePath = 'conductor/tracks/test.md';
|
||||
const invocation = tool.build({
|
||||
file_path: nestedFilePath,
|
||||
content: 'nested content',
|
||||
});
|
||||
|
||||
await invocation.execute({ abortSignal });
|
||||
|
||||
const expectedWritePath = path.join(plansDir, 'conductor/tracks/test.md');
|
||||
expect(fs.existsSync(expectedWritePath)).toBe(true);
|
||||
expect(fs.readFileSync(expectedWritePath, 'utf8')).toBe('nested content');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,6 +49,7 @@ import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { WRITE_FILE_DEFINITION } from './definitions/coreTools.js';
|
||||
import { resolveToolDeclaration } from './definitions/resolver.js';
|
||||
import { detectOmissionPlaceholders } from './omissionPlaceholderDetector.js';
|
||||
import { resolveAndValidatePlanPath } from '../utils/planUtils.js';
|
||||
import { isGemini3Model } from '../config/models.js';
|
||||
import { discoverJitContext, appendJitContext } from './jit-context.js';
|
||||
|
||||
@@ -168,11 +169,20 @@ class WriteFileToolInvocation extends BaseToolInvocation<
|
||||
);
|
||||
|
||||
if (this.config.isPlanMode()) {
|
||||
const safeFilename = path.basename(this.params.file_path);
|
||||
this.resolvedPath = path.join(
|
||||
this.config.storage.getPlansDir(),
|
||||
safeFilename,
|
||||
);
|
||||
try {
|
||||
this.resolvedPath = resolveAndValidatePlanPath(
|
||||
this.params.file_path,
|
||||
this.config.storage.getPlansDir(),
|
||||
this.config.getProjectRoot(),
|
||||
);
|
||||
} catch (e) {
|
||||
debugLogger.error(
|
||||
'Failed to resolve plan path during WriteFileTool invocation setup',
|
||||
e,
|
||||
);
|
||||
// Validation fails, set resolvedPath to something that will fail validation downstream or just the raw path.
|
||||
this.resolvedPath = this.params.file_path;
|
||||
}
|
||||
} else {
|
||||
this.resolvedPath = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
@@ -499,7 +509,20 @@ export class WriteFileTool
|
||||
return `Missing or empty "file_path"`;
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(this.config.getTargetDir(), filePath);
|
||||
let resolvedPath: string;
|
||||
if (this.config.isPlanMode()) {
|
||||
try {
|
||||
resolvedPath = resolveAndValidatePlanPath(
|
||||
filePath,
|
||||
this.config.storage.getPlansDir(),
|
||||
this.config.getProjectRoot(),
|
||||
);
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
} else {
|
||||
resolvedPath = path.resolve(this.config.getTargetDir(), filePath);
|
||||
}
|
||||
|
||||
const validationError = this.config.validatePathAccess(resolvedPath);
|
||||
if (validationError) {
|
||||
|
||||
Reference in New Issue
Block a user