fix(core): resolve nested plan directory duplication and relative path policies (#25138)

This commit is contained in:
Mahima Shanware
2026-04-21 14:20:57 -04:00
committed by GitHub
parent c260550146
commit a4e98c0a4c
17 changed files with 283 additions and 76 deletions
+3 -2
View File
@@ -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');
+27 -6
View File
@@ -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;
+10 -3
View File
@@ -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 -16
View File
@@ -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');
});
});
});
+29 -6
View File
@@ -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) {