From ed26ea49e94ad4e1d4ad67d09898ff691aa77bc8 Mon Sep 17 00:00:00 2001 From: Jerop Kipruto Date: Mon, 2 Feb 2026 22:30:03 -0500 Subject: [PATCH] feat(plan): add core logic and `exit_plan_mode` tool definition (#18110) --- .../cli/src/zed-integration/zedIntegration.ts | 3 + packages/core/src/config/config.ts | 4 + packages/core/src/confirmation-bus/types.ts | 5 + packages/core/src/index.ts | 1 + packages/core/src/policy/policies/plan.toml | 6 + .../core/src/tools/exit-plan-mode.test.ts | 421 ++++++++++++++++++ packages/core/src/tools/exit-plan-mode.ts | 258 +++++++++++ packages/core/src/tools/tool-names.ts | 2 + packages/core/src/tools/tools.ts | 27 +- packages/core/src/utils/fileUtils.test.ts | 43 ++ packages/core/src/utils/fileUtils.ts | 49 ++ packages/core/src/utils/planUtils.test.ts | 95 ++++ packages/core/src/utils/planUtils.ts | 69 +++ 13 files changed, 981 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/tools/exit-plan-mode.test.ts create mode 100644 packages/core/src/tools/exit-plan-mode.ts create mode 100644 packages/core/src/utils/planUtils.test.ts create mode 100644 packages/core/src/utils/planUtils.ts diff --git a/packages/cli/src/zed-integration/zedIntegration.ts b/packages/cli/src/zed-integration/zedIntegration.ts index 1de841a14d..ac33e50e96 100644 --- a/packages/cli/src/zed-integration/zedIntegration.ts +++ b/packages/cli/src/zed-integration/zedIntegration.ts @@ -1183,6 +1183,9 @@ function toPermissionOptions( case 'ask_user': // askuser doesn't need "always allow" options since it's asking questions return [...basicPermissionOptions]; + case 'exit_plan_mode': + // exit_plan_mode doesn't need "always allow" options since it's a plan approval flow + return [...basicPermissionOptions]; default: { const unreachable: never = confirmation; throw new Error(`Unexpected: ${unreachable}`); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index e64efce65d..7c2d34bfad 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -34,6 +34,7 @@ import { WebFetchTool } from '../tools/web-fetch.js'; import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js'; import { WebSearchTool } from '../tools/web-search.js'; import { AskUserTool } from '../tools/ask-user.js'; +import { ExitPlanModeTool } from '../tools/exit-plan-mode.js'; import { GeminiClient } from '../core/client.js'; import { BaseLlmClient } from '../core/baseLlmClient.js'; import type { HookDefinition, HookEventName } from '../hooks/types.js'; @@ -2140,6 +2141,9 @@ export class Config { if (this.getUseWriteTodos()) { registerCoreTool(WriteTodosTool); } + if (this.isPlanEnabled()) { + registerCoreTool(ExitPlanModeTool, this); + } // Register Subagents as Tools this.registerSubAgentTools(registry); diff --git a/packages/core/src/confirmation-bus/types.ts b/packages/core/src/confirmation-bus/types.ts index 7debbb85da..8aa21f8ca1 100644 --- a/packages/core/src/confirmation-bus/types.ts +++ b/packages/core/src/confirmation-bus/types.ts @@ -100,6 +100,11 @@ export type SerializableConfirmationDetails = type: 'ask_user'; title: string; questions: Question[]; + } + | { + type: 'exit_plan_mode'; + title: string; + planPath: string; }; export interface UpdatePolicy { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7fa7a5099e..f63c189014 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -70,6 +70,7 @@ export * from './utils/quotaErrorDetection.js'; export * from './utils/userAccountManager.js'; export * from './utils/googleQuotaErrors.js'; export * from './utils/fileUtils.js'; +export * from './utils/planUtils.js'; export * from './utils/fileDiffUtils.js'; export * from './utils/retry.js'; export * from './utils/shell-utils.js'; diff --git a/packages/core/src/policy/policies/plan.toml b/packages/core/src/policy/policies/plan.toml index 5b8b1d7882..8172bbfccf 100644 --- a/packages/core/src/policy/policies/plan.toml +++ b/packages/core/src/policy/policies/plan.toml @@ -70,6 +70,12 @@ decision = "ask_user" priority = 50 modes = ["plan"] +[[rule]] +toolName = "exit_plan_mode" +decision = "ask_user" +priority = 50 +modes = ["plan"] + # Allow write_file for .md files in plans directory [[rule]] toolName = "write_file" diff --git a/packages/core/src/tools/exit-plan-mode.test.ts b/packages/core/src/tools/exit-plan-mode.test.ts new file mode 100644 index 0000000000..ab1ffd6aad --- /dev/null +++ b/packages/core/src/tools/exit-plan-mode.test.ts @@ -0,0 +1,421 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ExitPlanModeTool, ExitPlanModeInvocation } from './exit-plan-mode.js'; +import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; +import path from 'node:path'; +import type { Config } from '../config/config.js'; +import type { MessageBus } from '../confirmation-bus/message-bus.js'; +import { ToolConfirmationOutcome } from './tools.js'; +import { ApprovalMode } from '../policy/types.js'; +import * as fs from 'node:fs'; +import os from 'node:os'; +import { validatePlanPath } from '../utils/planUtils.js'; + +describe('ExitPlanModeTool', () => { + let tool: ExitPlanModeTool; + let mockMessageBus: ReturnType; + let mockConfig: Partial; + let tempRootDir: string; + let mockPlansDir: string; + + beforeEach(() => { + vi.useFakeTimers(); + mockMessageBus = createMockMessageBus(); + vi.mocked(mockMessageBus.publish).mockResolvedValue(undefined); + + tempRootDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'exit-plan-test-')), + ); + const plansDirRaw = path.join(tempRootDir, 'plans'); + fs.mkdirSync(plansDirRaw, { recursive: true }); + mockPlansDir = fs.realpathSync(plansDirRaw); + + mockConfig = { + getTargetDir: vi.fn().mockReturnValue(tempRootDir), + setApprovalMode: vi.fn(), + storage: { + getProjectTempPlansDir: vi.fn().mockReturnValue(mockPlansDir), + } as unknown as Config['storage'], + }; + tool = new ExitPlanModeTool( + mockConfig as Config, + mockMessageBus as unknown as MessageBus, + ); + // Mock getMessageBusDecision on the invocation prototype + vi.spyOn( + ExitPlanModeInvocation.prototype as unknown as { + getMessageBusDecision: () => Promise; + }, + 'getMessageBusDecision', + ).mockResolvedValue('ASK_USER'); + }); + + afterEach(() => { + if (fs.existsSync(tempRootDir)) { + fs.rmSync(tempRootDir, { recursive: true, force: true }); + } + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + const createPlanFile = (name: string, content: string) => { + const filePath = path.join(mockPlansDir, name); + fs.writeFileSync(filePath, content); + return path.join('plans', name); + }; + + describe('shouldConfirmExecute', () => { + it('should return plan approval confirmation details when plan has content', async () => { + const planRelativePath = createPlanFile('test-plan.md', '# My Plan'); + const invocation = tool.build({ plan_path: planRelativePath }); + + const result = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + + expect(result).not.toBe(false); + if (result === false) return; + + expect(result.type).toBe('exit_plan_mode'); + expect(result.title).toBe('Plan Approval'); + if (result.type === 'exit_plan_mode') { + expect(result.planPath).toBe(path.join(mockPlansDir, 'test-plan.md')); + } + expect(typeof result.onConfirm).toBe('function'); + }); + + it('should return false when plan file is empty', async () => { + const planRelativePath = createPlanFile('empty.md', ' '); + const invocation = tool.build({ plan_path: planRelativePath }); + + const result = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + + expect(result).toBe(false); + }); + + it('should return false when plan file cannot be read', async () => { + const planRelativePath = path.join('plans', 'non-existent.md'); + const invocation = tool.build({ plan_path: planRelativePath }); + + const result = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + + expect(result).toBe(false); + }); + + it('should auto-approve when policy decision is ALLOW', async () => { + const planRelativePath = createPlanFile('test.md', '# Content'); + const invocation = tool.build({ plan_path: planRelativePath }); + + vi.spyOn( + invocation as unknown as { + getMessageBusDecision: () => Promise; + }, + 'getMessageBusDecision', + ).mockResolvedValue('ALLOW'); + + const result = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + + expect(result).toBe(false); + // Verify it auto-approved internally + const executeResult = await invocation.execute( + new AbortController().signal, + ); + expect(executeResult.llmContent).toContain('Plan approved'); + }); + + it('should throw error when policy decision is DENY', async () => { + const planRelativePath = createPlanFile('test.md', '# Content'); + const invocation = tool.build({ plan_path: planRelativePath }); + + vi.spyOn( + invocation as unknown as { + getMessageBusDecision: () => Promise; + }, + 'getMessageBusDecision', + ).mockResolvedValue('DENY'); + + await expect( + invocation.shouldConfirmExecute(new AbortController().signal), + ).rejects.toThrow(/denied by policy/); + }); + }); + + describe('execute with invalid plan', () => { + it('should return error when plan file is empty', async () => { + const planRelativePath = createPlanFile('empty.md', ''); + const invocation = tool.build({ plan_path: planRelativePath }); + + await invocation.shouldConfirmExecute(new AbortController().signal); + const result = await invocation.execute(new AbortController().signal); + + expect(result.llmContent).toContain('Plan file is empty'); + expect(result.llmContent).toContain('write content to the plan'); + }); + + it('should return error when plan file cannot be read', async () => { + const planRelativePath = 'plans/ghost.md'; + const invocation = tool.build({ plan_path: planRelativePath }); + + await invocation.shouldConfirmExecute(new AbortController().signal); + const result = await invocation.execute(new AbortController().signal); + + expect(result.llmContent).toContain('Plan file does not exist'); + }); + }); + + describe('execute', () => { + it('should return approval message when plan is approved with DEFAULT mode', async () => { + const planRelativePath = createPlanFile('test.md', '# Content'); + const invocation = tool.build({ plan_path: planRelativePath }); + + const confirmDetails = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + expect(confirmDetails).not.toBe(false); + if (confirmDetails === false) return; + + await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce, { + approved: true, + approvalMode: ApprovalMode.DEFAULT, + }); + + const result = await invocation.execute(new AbortController().signal); + const expectedPath = path.join(mockPlansDir, 'test.md'); + + expect(result).toEqual({ + llmContent: `Plan approved. Switching to Default mode (edits will require confirmation). + +The approved implementation plan is stored at: ${expectedPath} +Read and follow the plan strictly during implementation.`, + returnDisplay: `Plan approved: ${expectedPath}`, + }); + }); + + it('should return approval message when plan is approved with AUTO_EDIT mode', async () => { + const planRelativePath = createPlanFile('test.md', '# Content'); + const invocation = tool.build({ plan_path: planRelativePath }); + + const confirmDetails = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + expect(confirmDetails).not.toBe(false); + if (confirmDetails === false) return; + + await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce, { + approved: true, + approvalMode: ApprovalMode.AUTO_EDIT, + }); + + const result = await invocation.execute(new AbortController().signal); + const expectedPath = path.join(mockPlansDir, 'test.md'); + + expect(result).toEqual({ + llmContent: `Plan approved. Switching to Auto-Edit mode (edits will be applied automatically). + +The approved implementation plan is stored at: ${expectedPath} +Read and follow the plan strictly during implementation.`, + returnDisplay: `Plan approved: ${expectedPath}`, + }); + expect(mockConfig.setApprovalMode).toHaveBeenCalledWith( + ApprovalMode.AUTO_EDIT, + ); + }); + + it('should return feedback message when plan is rejected with feedback', async () => { + const planRelativePath = createPlanFile('test.md', '# Content'); + const invocation = tool.build({ plan_path: planRelativePath }); + + const confirmDetails = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + expect(confirmDetails).not.toBe(false); + if (confirmDetails === false) return; + + await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce, { + approved: false, + feedback: 'Please add more details.', + }); + + const result = await invocation.execute(new AbortController().signal); + const expectedPath = path.join(mockPlansDir, 'test.md'); + + expect(result).toEqual({ + llmContent: `Plan rejected. User feedback: Please add more details. + +The plan is stored at: ${expectedPath} +Revise the plan based on the feedback.`, + returnDisplay: 'Feedback: Please add more details.', + }); + }); + + it('should handle rejection without feedback gracefully', async () => { + const planRelativePath = createPlanFile('test.md', '# Content'); + const invocation = tool.build({ plan_path: planRelativePath }); + + const confirmDetails = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + expect(confirmDetails).not.toBe(false); + if (confirmDetails === false) return; + + await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce, { + approved: false, + }); + + const result = await invocation.execute(new AbortController().signal); + const expectedPath = path.join(mockPlansDir, 'test.md'); + + expect(result).toEqual({ + llmContent: `Plan rejected. No feedback provided. + +The plan is stored at: ${expectedPath} +Ask the user for specific feedback on how to improve the plan.`, + returnDisplay: 'Rejected (no feedback)', + }); + }); + + it('should return cancellation message when cancelled', async () => { + const planRelativePath = createPlanFile('test.md', '# Content'); + const invocation = tool.build({ plan_path: planRelativePath }); + + const confirmDetails = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + expect(confirmDetails).not.toBe(false); + if (confirmDetails === false) return; + + await confirmDetails.onConfirm(ToolConfirmationOutcome.Cancel); + + const result = await invocation.execute(new AbortController().signal); + + expect(result).toEqual({ + llmContent: + 'User cancelled the plan approval dialog. The plan was not approved and you are still in Plan Mode.', + returnDisplay: 'Cancelled', + }); + }); + }); + + describe('getApprovalModeDescription (internal)', () => { + it('should handle all valid approval modes', async () => { + const planRelativePath = createPlanFile('test.md', '# Content'); + const invocation = tool.build({ plan_path: planRelativePath }); + + const testMode = async (mode: ApprovalMode, expected: string) => { + const confirmDetails = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + if (confirmDetails === false) return; + + await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce, { + approved: true, + approvalMode: mode, + }); + + const result = await invocation.execute(new AbortController().signal); + expect(result.llmContent).toContain(expected); + }; + + await testMode( + ApprovalMode.AUTO_EDIT, + 'Auto-Edit mode (edits will be applied automatically)', + ); + await testMode( + ApprovalMode.DEFAULT, + 'Default mode (edits will require confirmation)', + ); + }); + + it('should throw for invalid post-planning modes', async () => { + const planRelativePath = createPlanFile('test.md', '# Content'); + const invocation = tool.build({ plan_path: planRelativePath }); + + const testInvalidMode = async (mode: ApprovalMode) => { + const confirmDetails = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + if (confirmDetails === false) return; + + await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce, { + approved: true, + approvalMode: mode, + }); + + await expect( + invocation.execute(new AbortController().signal), + ).rejects.toThrow(/Unexpected approval mode/); + }; + + await testInvalidMode(ApprovalMode.YOLO); + await testInvalidMode(ApprovalMode.PLAN); + }); + }); + + it('should throw error during build if plan path is outside plans directory', () => { + expect(() => tool.build({ plan_path: '../../../etc/passwd' })).toThrow( + /Access denied/, + ); + }); + + describe('validateToolParams', () => { + it('should reject empty plan_path', () => { + const result = tool.validateToolParams({ plan_path: '' }); + expect(result).toBe('plan_path is required.'); + }); + + it('should reject whitespace-only plan_path', () => { + const result = tool.validateToolParams({ plan_path: ' ' }); + expect(result).toBe('plan_path is required.'); + }); + + it('should reject path outside plans directory', () => { + const result = tool.validateToolParams({ + plan_path: '../../../etc/passwd', + }); + expect(result).toContain('Access denied'); + }); + + it('should reject non-existent plan file', async () => { + const result = await validatePlanPath( + 'plans/ghost.md', + mockPlansDir, + tempRootDir, + ); + expect(result).toContain('Plan file does not exist'); + }); + + it('should reject symbolic links pointing outside the plans directory', () => { + const outsideFile = path.join(tempRootDir, 'outside.txt'); + fs.writeFileSync(outsideFile, 'secret'); + const maliciousPath = path.join(mockPlansDir, 'malicious.md'); + fs.symlinkSync(outsideFile, maliciousPath); + + const result = tool.validateToolParams({ + plan_path: 'plans/malicious.md', + }); + + expect(result).toBe( + 'Access denied: plan path must be within the designated plans directory.', + ); + }); + + it('should accept valid path within plans directory', () => { + createPlanFile('valid.md', '# Content'); + const result = tool.validateToolParams({ + plan_path: 'plans/valid.md', + }); + expect(result).toBeNull(); + }); + }); +}); diff --git a/packages/core/src/tools/exit-plan-mode.ts b/packages/core/src/tools/exit-plan-mode.ts new file mode 100644 index 0000000000..d96eb00600 --- /dev/null +++ b/packages/core/src/tools/exit-plan-mode.ts @@ -0,0 +1,258 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + BaseDeclarativeTool, + BaseToolInvocation, + type ToolResult, + Kind, + type ToolExitPlanModeConfirmationDetails, + type ToolConfirmationPayload, + type ToolExitPlanModeConfirmationPayload, + ToolConfirmationOutcome, +} from './tools.js'; +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 { ApprovalMode } from '../policy/types.js'; +import { checkExhaustive } from '../utils/checks.js'; +import { resolveToRealPath, isSubpath } from '../utils/paths.js'; + +/** + * Returns a human-readable description for an approval mode. + */ +function getApprovalModeDescription(mode: ApprovalMode): string { + switch (mode) { + case ApprovalMode.AUTO_EDIT: + return 'Auto-Edit mode (edits will be applied automatically)'; + case ApprovalMode.DEFAULT: + return 'Default mode (edits will require confirmation)'; + case ApprovalMode.YOLO: + case ApprovalMode.PLAN: + // YOLO and PLAN are not valid modes to enter when exiting plan mode + throw new Error(`Unexpected approval mode: ${mode}`); + default: + checkExhaustive(mode); + } +} + +export interface ExitPlanModeParams { + plan_path: string; +} + +export class ExitPlanModeTool extends BaseDeclarativeTool< + ExitPlanModeParams, + ToolResult +> { + constructor( + private config: Config, + messageBus: MessageBus, + ) { + super( + EXIT_PLAN_MODE_TOOL_NAME, + 'Exit Plan Mode', + 'Signals that the planning phase is complete and requests user approval to start implementation.', + Kind.Plan, + { + type: 'object', + required: ['plan_path'], + properties: { + plan_path: { + type: 'string', + description: + 'The file path to the finalized plan (e.g., "plans/feature-x.md").', + }, + }, + }, + messageBus, + ); + } + + protected override validateToolParamValues( + params: ExitPlanModeParams, + ): string | null { + if (!params.plan_path || params.plan_path.trim() === '') { + return 'plan_path is required.'; + } + + // Since validateToolParamValues is synchronous, we use a basic synchronous check + // for path traversal safety. High-level async validation is deferred to shouldConfirmExecute. + const plansDir = resolveToRealPath( + this.config.storage.getProjectTempPlansDir(), + ); + const resolvedPath = path.resolve( + this.config.getTargetDir(), + params.plan_path, + ); + + const realPath = resolveToRealPath(resolvedPath); + + if (!isSubpath(plansDir, realPath)) { + return `Access denied: plan path must be within the designated plans directory.`; + } + + return null; + } + + protected createInvocation( + params: ExitPlanModeParams, + messageBus: MessageBus, + toolName: string, + toolDisplayName: string, + ): ExitPlanModeInvocation { + return new ExitPlanModeInvocation( + params, + messageBus, + toolName, + toolDisplayName, + this.config, + ); + } +} + +export class ExitPlanModeInvocation extends BaseToolInvocation< + ExitPlanModeParams, + ToolResult +> { + private confirmationOutcome: ToolConfirmationOutcome | null = null; + private approvalPayload: ToolExitPlanModeConfirmationPayload | null = null; + private planValidationError: string | null = null; + + constructor( + params: ExitPlanModeParams, + messageBus: MessageBus, + toolName: string, + toolDisplayName: string, + private config: Config, + ) { + super(params, messageBus, toolName, toolDisplayName); + } + + override async shouldConfirmExecute( + abortSignal: AbortSignal, + ): Promise { + const resolvedPlanPath = this.getResolvedPlanPath(); + + const pathError = await validatePlanPath( + this.params.plan_path, + this.config.storage.getProjectTempPlansDir(), + this.config.getTargetDir(), + ); + if (pathError) { + this.planValidationError = pathError; + return false; + } + + const contentError = await validatePlanContent(resolvedPlanPath); + if (contentError) { + this.planValidationError = contentError; + return false; + } + + const decision = await this.getMessageBusDecision(abortSignal); + if (decision === 'DENY') { + throw new Error( + `Tool execution for "${ + this._toolDisplayName || this._toolName + }" denied by policy.`, + ); + } + + if (decision === 'ALLOW') { + // If policy is allow, auto-approve with default settings and execute. + this.confirmationOutcome = ToolConfirmationOutcome.ProceedOnce; + this.approvalPayload = { + approved: true, + approvalMode: ApprovalMode.DEFAULT, + }; + return false; + } + + // decision is 'ASK_USER' + return { + type: 'exit_plan_mode', + title: 'Plan Approval', + planPath: resolvedPlanPath, + onConfirm: async ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => { + this.confirmationOutcome = outcome; + if (payload && 'approved' in payload) { + this.approvalPayload = payload; + } + }, + }; + } + + getDescription(): string { + return `Requesting plan approval for: ${this.params.plan_path}`; + } + + /** + * Returns the resolved plan path. + * Note: Validation is done in validateToolParamValues, so this assumes the path is valid. + */ + private getResolvedPlanPath(): string { + return path.resolve(this.config.getTargetDir(), this.params.plan_path); + } + + async execute(_signal: AbortSignal): Promise { + const resolvedPlanPath = this.getResolvedPlanPath(); + + if (this.planValidationError) { + return { + llmContent: this.planValidationError, + returnDisplay: 'Error: Invalid plan', + }; + } + + if (this.confirmationOutcome === ToolConfirmationOutcome.Cancel) { + return { + llmContent: + 'User cancelled the plan approval dialog. The plan was not approved and you are still in Plan Mode.', + returnDisplay: 'Cancelled', + }; + } + + const payload = this.approvalPayload; + if (payload?.approved) { + const newMode = payload.approvalMode ?? ApprovalMode.DEFAULT; + this.config.setApprovalMode(newMode); + + const description = getApprovalModeDescription(newMode); + + return { + llmContent: `Plan approved. Switching to ${description}. + +The approved implementation plan is stored at: ${resolvedPlanPath} +Read and follow the plan strictly during implementation.`, + returnDisplay: `Plan approved: ${resolvedPlanPath}`, + }; + } else { + const feedback = payload?.feedback?.trim(); + if (feedback) { + return { + llmContent: `Plan rejected. User feedback: ${feedback} + +The plan is stored at: ${resolvedPlanPath} +Revise the plan based on the feedback.`, + returnDisplay: `Feedback: ${feedback}`, + }; + } else { + return { + llmContent: `Plan rejected. No feedback provided. + +The plan is stored at: ${resolvedPlanPath} +Ask the user for specific feedback on how to improve the plan.`, + returnDisplay: 'Rejected (no feedback)', + }; + } + } + } +} diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index 3ea35054d4..8640c5fbc4 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -25,6 +25,7 @@ export const ACTIVATE_SKILL_TOOL_NAME = 'activate_skill'; export const EDIT_TOOL_NAMES = new Set([EDIT_TOOL_NAME, WRITE_FILE_TOOL_NAME]); export const ASK_USER_TOOL_NAME = 'ask_user'; export const ASK_USER_DISPLAY_NAME = 'Ask User'; +export const EXIT_PLAN_MODE_TOOL_NAME = 'exit_plan_mode'; /** * Mapping of legacy tool names to their current names. @@ -92,6 +93,7 @@ export const PLAN_MODE_TOOLS = [ LS_TOOL_NAME, WEB_SEARCH_TOOL_NAME, ASK_USER_TOOL_NAME, + EXIT_PLAN_MODE_TOOL_NAME, ] as const; /** diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 9c308ecba6..65aeb0884f 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -18,6 +18,7 @@ import { type ToolConfirmationResponse, type Question, } from '../confirmation-bus/types.js'; +import { type ApprovalMode } from '../policy/types.js'; /** * Represents a validated and ready-to-execute tool call. @@ -701,9 +702,19 @@ export interface ToolAskUserConfirmationPayload { answers: { [questionIndex: string]: string }; } +export interface ToolExitPlanModeConfirmationPayload { + /** Whether the user approved the plan */ + approved: boolean; + /** If approved, the approval mode to use for implementation */ + approvalMode?: ApprovalMode; + /** If rejected, the user's feedback */ + feedback?: string; +} + export type ToolConfirmationPayload = | ToolEditConfirmationPayload - | ToolAskUserConfirmationPayload; + | ToolAskUserConfirmationPayload + | ToolExitPlanModeConfirmationPayload; export interface ToolExecuteConfirmationDetails { type: 'exec'; @@ -742,12 +753,23 @@ export interface ToolAskUserConfirmationDetails { ) => Promise; } +export interface ToolExitPlanModeConfirmationDetails { + type: 'exit_plan_mode'; + title: string; + planPath: string; + onConfirm: ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => Promise; +} + export type ToolCallConfirmationDetails = | ToolEditConfirmationDetails | ToolExecuteConfirmationDetails | ToolMcpConfirmationDetails | ToolInfoConfirmationDetails - | ToolAskUserConfirmationDetails; + | ToolAskUserConfirmationDetails + | ToolExitPlanModeConfirmationDetails; export enum ToolConfirmationOutcome { ProceedOnce = 'proceed_once', @@ -769,6 +791,7 @@ export enum Kind { Think = 'think', Fetch = 'fetch', Communicate = 'communicate', + Plan = 'plan', Other = 'other', } diff --git a/packages/core/src/utils/fileUtils.test.ts b/packages/core/src/utils/fileUtils.test.ts index a0f3ac754c..742c782c7a 100644 --- a/packages/core/src/utils/fileUtils.test.ts +++ b/packages/core/src/utils/fileUtils.test.ts @@ -34,6 +34,8 @@ import { readWasmBinaryFromDisk, saveTruncatedToolOutput, formatTruncatedToolOutput, + getRealPath, + isEmpty, } from './fileUtils.js'; import { StandardFileSystemService } from '../services/fileSystemService.js'; @@ -172,6 +174,47 @@ describe('fileUtils', () => { ); }); + describe('getRealPath', () => { + it('should resolve a real path for an existing file', () => { + const testFile = path.join(tempRootDir, 'real.txt'); + actualNodeFs.writeFileSync(testFile, 'content'); + expect(getRealPath(testFile)).toBe(actualNodeFs.realpathSync(testFile)); + }); + + it('should return absolute resolved path for a non-existent file', () => { + const ghostFile = path.join(tempRootDir, 'ghost.txt'); + expect(getRealPath(ghostFile)).toBe(path.resolve(ghostFile)); + }); + + it('should resolve symbolic links', () => { + const targetFile = path.join(tempRootDir, 'target.txt'); + const linkFile = path.join(tempRootDir, 'link.txt'); + actualNodeFs.writeFileSync(targetFile, 'content'); + actualNodeFs.symlinkSync(targetFile, linkFile); + + expect(getRealPath(linkFile)).toBe(actualNodeFs.realpathSync(targetFile)); + }); + }); + + describe('isEmpty', () => { + it('should return false for a non-empty file', async () => { + const testFile = path.join(tempRootDir, 'full.txt'); + actualNodeFs.writeFileSync(testFile, 'some content'); + expect(await isEmpty(testFile)).toBe(false); + }); + + it('should return true for an empty file', async () => { + const testFile = path.join(tempRootDir, 'empty.txt'); + actualNodeFs.writeFileSync(testFile, ' '); + expect(await isEmpty(testFile)).toBe(true); + }); + + it('should return true for a non-existent file (defensive)', async () => { + const testFile = path.join(tempRootDir, 'ghost.txt'); + expect(await isEmpty(testFile)).toBe(true); + }); + }); + describe('fileExists', () => { it('should return true if the file exists', async () => { const testFile = path.join(tempRootDir, 'exists.txt'); diff --git a/packages/core/src/utils/fileUtils.ts b/packages/core/src/utils/fileUtils.ts index 49c0a497fe..6689467277 100644 --- a/packages/core/src/utils/fileUtils.ts +++ b/packages/core/src/utils/fileUtils.ts @@ -228,6 +228,55 @@ export function isWithinRoot( ); } +/** + * Safely resolves a path to its real path if it exists, otherwise returns the absolute resolved path. + */ +export function getRealPath(filePath: string): string { + try { + return fs.realpathSync(filePath); + } catch { + return path.resolve(filePath); + } +} + +/** + * Checks if a file's content is empty or contains only whitespace. + * Efficiently checks file size first, and only samples the beginning of the file. + * Honors Unicode BOM encodings. + */ +export async function isEmpty(filePath: string): Promise { + try { + const stats = await fsPromises.stat(filePath); + if (stats.size === 0) return true; + + // Sample up to 1KB to check for non-whitespace content. + // If a file is larger than 1KB and contains only whitespace, + // it's an extreme edge case we can afford to read slightly more of if needed, + // but for most valid plans/files, this is sufficient. + const fd = await fsPromises.open(filePath, 'r'); + try { + const { buffer } = await fd.read({ + buffer: Buffer.alloc(Math.min(1024, stats.size)), + offset: 0, + length: Math.min(1024, stats.size), + position: 0, + }); + + const bom = detectBOM(buffer); + const content = bom + ? buffer.subarray(bom.bomLength).toString('utf8') + : buffer.toString('utf8'); + + return content.trim().length === 0; + } finally { + await fd.close(); + } + } catch { + // If file is unreadable, we treat it as empty/invalid for validation purposes + return true; + } +} + /** * Heuristic: determine if a file is likely binary. * Now BOM-aware: if a Unicode BOM is detected, we treat it as text. diff --git a/packages/core/src/utils/planUtils.test.ts b/packages/core/src/utils/planUtils.test.ts new file mode 100644 index 0000000000..2e4f4f04eb --- /dev/null +++ b/packages/core/src/utils/planUtils.test.ts @@ -0,0 +1,95 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import path from 'node:path'; +import * as fs from 'node:fs'; +import os from 'node:os'; +import { validatePlanPath, validatePlanContent } from './planUtils.js'; + +describe('planUtils', () => { + let tempRootDir: string; + let plansDir: string; + + beforeEach(() => { + tempRootDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'planUtils-test-')), + ); + const plansDirRaw = path.join(tempRootDir, 'plans'); + fs.mkdirSync(plansDirRaw, { recursive: true }); + plansDir = fs.realpathSync(plansDirRaw); + }); + + afterEach(() => { + if (fs.existsSync(tempRootDir)) { + fs.rmSync(tempRootDir, { recursive: true, force: true }); + } + }); + + describe('validatePlanPath', () => { + it('should return null for a valid path within plans directory', async () => { + const planPath = path.join('plans', 'test.md'); + const fullPath = path.join(tempRootDir, planPath); + fs.writeFileSync(fullPath, '# My Plan'); + + const result = await validatePlanPath(planPath, plansDir, tempRootDir); + expect(result).toBeNull(); + }); + + it('should return error for path traversal', async () => { + const planPath = path.join('..', 'secret.txt'); + const result = await validatePlanPath(planPath, plansDir, tempRootDir); + expect(result).toContain('Access denied'); + }); + + it('should return error for non-existent file', async () => { + const planPath = path.join('plans', 'ghost.md'); + const result = await validatePlanPath(planPath, plansDir, tempRootDir); + expect(result).toContain('Plan file does not exist'); + }); + + it('should detect path traversal via symbolic links', async () => { + const maliciousPath = path.join('plans', 'malicious.md'); + const fullMaliciousPath = path.join(tempRootDir, maliciousPath); + const outsideFile = path.join(tempRootDir, 'outside.txt'); + fs.writeFileSync(outsideFile, 'secret content'); + + // Create a symbolic link pointing outside the plans directory + fs.symlinkSync(outsideFile, fullMaliciousPath); + + const result = await validatePlanPath( + maliciousPath, + plansDir, + tempRootDir, + ); + expect(result).toContain('Access denied'); + }); + }); + + describe('validatePlanContent', () => { + it('should return null for non-empty content', async () => { + const planPath = path.join(plansDir, 'full.md'); + fs.writeFileSync(planPath, 'some content'); + const result = await validatePlanContent(planPath); + expect(result).toBeNull(); + }); + + it('should return error for empty content', async () => { + const planPath = path.join(plansDir, 'empty.md'); + fs.writeFileSync(planPath, ' '); + const result = await validatePlanContent(planPath); + expect(result).toContain('Plan file is empty'); + }); + + it('should return error for unreadable file', async () => { + const planPath = path.join(plansDir, 'ghost.md'); + const result = await validatePlanContent(planPath); + // Since isEmpty treats unreadable files as empty (defensive), + // we expect the "Plan file is empty" message. + expect(result).toContain('Plan file is empty'); + }); + }); +}); diff --git a/packages/core/src/utils/planUtils.ts b/packages/core/src/utils/planUtils.ts new file mode 100644 index 0000000000..534fe6923f --- /dev/null +++ b/packages/core/src/utils/planUtils.ts @@ -0,0 +1,69 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; +import { isEmpty, fileExists } from './fileUtils.js'; +import { isSubpath, resolveToRealPath } from './paths.js'; + +/** + * Standard error messages for the plan approval workflow. + * Shared between backend tools and CLI UI for consistency. + */ +export const PlanErrorMessages = { + PATH_ACCESS_DENIED: + 'Access denied: plan path must be within the designated plans directory.', + FILE_NOT_FOUND: (path: string) => + `Plan file does not exist: ${path}. You must create the plan file before requesting approval.`, + FILE_EMPTY: + 'Plan file is empty. You must write content to the plan file before requesting approval.', + READ_FAILURE: (detail: string) => `Failed to read plan file: ${detail}`, +} as const; + +/** + * Validates a plan file path for safety (traversal) and existence. + * @param planPath The untrusted path to the plan file. + * @param plansDir The authorized project plans directory. + * @param targetDir The current working directory (project root). + * @returns An error message if validation fails, or null if successful. + */ +export async function validatePlanPath( + planPath: string, + plansDir: string, + targetDir: string, +): Promise { + const resolvedPath = path.resolve(targetDir, planPath); + const realPath = resolveToRealPath(resolvedPath); + const realPlansDir = resolveToRealPath(plansDir); + + if (!isSubpath(realPlansDir, realPath)) { + return PlanErrorMessages.PATH_ACCESS_DENIED; + } + + if (!(await fileExists(resolvedPath))) { + return PlanErrorMessages.FILE_NOT_FOUND(planPath); + } + + return null; +} + +/** + * Validates that a plan file has non-empty content. + * @param planPath The path to the plan file. + * @returns An error message if the file is empty or unreadable, or null if successful. + */ +export async function validatePlanContent( + planPath: string, +): Promise { + try { + if (await isEmpty(planPath)) { + return PlanErrorMessages.FILE_EMPTY; + } + return null; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return PlanErrorMessages.READ_FAILURE(message); + } +}