mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-14 13:53:02 -07:00
feat(plan): add core logic and exit_plan_mode tool definition (#18110)
This commit is contained in:
@@ -1183,6 +1183,9 @@ function toPermissionOptions(
|
|||||||
case 'ask_user':
|
case 'ask_user':
|
||||||
// askuser doesn't need "always allow" options since it's asking questions
|
// askuser doesn't need "always allow" options since it's asking questions
|
||||||
return [...basicPermissionOptions];
|
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: {
|
default: {
|
||||||
const unreachable: never = confirmation;
|
const unreachable: never = confirmation;
|
||||||
throw new Error(`Unexpected: ${unreachable}`);
|
throw new Error(`Unexpected: ${unreachable}`);
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { WebFetchTool } from '../tools/web-fetch.js';
|
|||||||
import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js';
|
import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js';
|
||||||
import { WebSearchTool } from '../tools/web-search.js';
|
import { WebSearchTool } from '../tools/web-search.js';
|
||||||
import { AskUserTool } from '../tools/ask-user.js';
|
import { AskUserTool } from '../tools/ask-user.js';
|
||||||
|
import { ExitPlanModeTool } from '../tools/exit-plan-mode.js';
|
||||||
import { GeminiClient } from '../core/client.js';
|
import { GeminiClient } from '../core/client.js';
|
||||||
import { BaseLlmClient } from '../core/baseLlmClient.js';
|
import { BaseLlmClient } from '../core/baseLlmClient.js';
|
||||||
import type { HookDefinition, HookEventName } from '../hooks/types.js';
|
import type { HookDefinition, HookEventName } from '../hooks/types.js';
|
||||||
@@ -2140,6 +2141,9 @@ export class Config {
|
|||||||
if (this.getUseWriteTodos()) {
|
if (this.getUseWriteTodos()) {
|
||||||
registerCoreTool(WriteTodosTool);
|
registerCoreTool(WriteTodosTool);
|
||||||
}
|
}
|
||||||
|
if (this.isPlanEnabled()) {
|
||||||
|
registerCoreTool(ExitPlanModeTool, this);
|
||||||
|
}
|
||||||
|
|
||||||
// Register Subagents as Tools
|
// Register Subagents as Tools
|
||||||
this.registerSubAgentTools(registry);
|
this.registerSubAgentTools(registry);
|
||||||
|
|||||||
@@ -100,6 +100,11 @@ export type SerializableConfirmationDetails =
|
|||||||
type: 'ask_user';
|
type: 'ask_user';
|
||||||
title: string;
|
title: string;
|
||||||
questions: Question[];
|
questions: Question[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'exit_plan_mode';
|
||||||
|
title: string;
|
||||||
|
planPath: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface UpdatePolicy {
|
export interface UpdatePolicy {
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export * from './utils/quotaErrorDetection.js';
|
|||||||
export * from './utils/userAccountManager.js';
|
export * from './utils/userAccountManager.js';
|
||||||
export * from './utils/googleQuotaErrors.js';
|
export * from './utils/googleQuotaErrors.js';
|
||||||
export * from './utils/fileUtils.js';
|
export * from './utils/fileUtils.js';
|
||||||
|
export * from './utils/planUtils.js';
|
||||||
export * from './utils/fileDiffUtils.js';
|
export * from './utils/fileDiffUtils.js';
|
||||||
export * from './utils/retry.js';
|
export * from './utils/retry.js';
|
||||||
export * from './utils/shell-utils.js';
|
export * from './utils/shell-utils.js';
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ decision = "ask_user"
|
|||||||
priority = 50
|
priority = 50
|
||||||
modes = ["plan"]
|
modes = ["plan"]
|
||||||
|
|
||||||
|
[[rule]]
|
||||||
|
toolName = "exit_plan_mode"
|
||||||
|
decision = "ask_user"
|
||||||
|
priority = 50
|
||||||
|
modes = ["plan"]
|
||||||
|
|
||||||
# Allow write_file for .md files in plans directory
|
# Allow write_file for .md files in plans directory
|
||||||
[[rule]]
|
[[rule]]
|
||||||
toolName = "write_file"
|
toolName = "write_file"
|
||||||
|
|||||||
@@ -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<typeof createMockMessageBus>;
|
||||||
|
let mockConfig: Partial<Config>;
|
||||||
|
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<string>;
|
||||||
|
},
|
||||||
|
'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<string>;
|
||||||
|
},
|
||||||
|
'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<string>;
|
||||||
|
},
|
||||||
|
'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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<ToolExitPlanModeConfirmationDetails | false> {
|
||||||
|
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<ToolResult> {
|
||||||
|
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)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 EDIT_TOOL_NAMES = new Set([EDIT_TOOL_NAME, WRITE_FILE_TOOL_NAME]);
|
||||||
export const ASK_USER_TOOL_NAME = 'ask_user';
|
export const ASK_USER_TOOL_NAME = 'ask_user';
|
||||||
export const ASK_USER_DISPLAY_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.
|
* Mapping of legacy tool names to their current names.
|
||||||
@@ -92,6 +93,7 @@ export const PLAN_MODE_TOOLS = [
|
|||||||
LS_TOOL_NAME,
|
LS_TOOL_NAME,
|
||||||
WEB_SEARCH_TOOL_NAME,
|
WEB_SEARCH_TOOL_NAME,
|
||||||
ASK_USER_TOOL_NAME,
|
ASK_USER_TOOL_NAME,
|
||||||
|
EXIT_PLAN_MODE_TOOL_NAME,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
type ToolConfirmationResponse,
|
type ToolConfirmationResponse,
|
||||||
type Question,
|
type Question,
|
||||||
} from '../confirmation-bus/types.js';
|
} from '../confirmation-bus/types.js';
|
||||||
|
import { type ApprovalMode } from '../policy/types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a validated and ready-to-execute tool call.
|
* Represents a validated and ready-to-execute tool call.
|
||||||
@@ -701,9 +702,19 @@ export interface ToolAskUserConfirmationPayload {
|
|||||||
answers: { [questionIndex: string]: string };
|
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 =
|
export type ToolConfirmationPayload =
|
||||||
| ToolEditConfirmationPayload
|
| ToolEditConfirmationPayload
|
||||||
| ToolAskUserConfirmationPayload;
|
| ToolAskUserConfirmationPayload
|
||||||
|
| ToolExitPlanModeConfirmationPayload;
|
||||||
|
|
||||||
export interface ToolExecuteConfirmationDetails {
|
export interface ToolExecuteConfirmationDetails {
|
||||||
type: 'exec';
|
type: 'exec';
|
||||||
@@ -742,12 +753,23 @@ export interface ToolAskUserConfirmationDetails {
|
|||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ToolExitPlanModeConfirmationDetails {
|
||||||
|
type: 'exit_plan_mode';
|
||||||
|
title: string;
|
||||||
|
planPath: string;
|
||||||
|
onConfirm: (
|
||||||
|
outcome: ToolConfirmationOutcome,
|
||||||
|
payload?: ToolConfirmationPayload,
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
export type ToolCallConfirmationDetails =
|
export type ToolCallConfirmationDetails =
|
||||||
| ToolEditConfirmationDetails
|
| ToolEditConfirmationDetails
|
||||||
| ToolExecuteConfirmationDetails
|
| ToolExecuteConfirmationDetails
|
||||||
| ToolMcpConfirmationDetails
|
| ToolMcpConfirmationDetails
|
||||||
| ToolInfoConfirmationDetails
|
| ToolInfoConfirmationDetails
|
||||||
| ToolAskUserConfirmationDetails;
|
| ToolAskUserConfirmationDetails
|
||||||
|
| ToolExitPlanModeConfirmationDetails;
|
||||||
|
|
||||||
export enum ToolConfirmationOutcome {
|
export enum ToolConfirmationOutcome {
|
||||||
ProceedOnce = 'proceed_once',
|
ProceedOnce = 'proceed_once',
|
||||||
@@ -769,6 +791,7 @@ export enum Kind {
|
|||||||
Think = 'think',
|
Think = 'think',
|
||||||
Fetch = 'fetch',
|
Fetch = 'fetch',
|
||||||
Communicate = 'communicate',
|
Communicate = 'communicate',
|
||||||
|
Plan = 'plan',
|
||||||
Other = 'other',
|
Other = 'other',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ import {
|
|||||||
readWasmBinaryFromDisk,
|
readWasmBinaryFromDisk,
|
||||||
saveTruncatedToolOutput,
|
saveTruncatedToolOutput,
|
||||||
formatTruncatedToolOutput,
|
formatTruncatedToolOutput,
|
||||||
|
getRealPath,
|
||||||
|
isEmpty,
|
||||||
} from './fileUtils.js';
|
} from './fileUtils.js';
|
||||||
import { StandardFileSystemService } from '../services/fileSystemService.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', () => {
|
describe('fileExists', () => {
|
||||||
it('should return true if the file exists', async () => {
|
it('should return true if the file exists', async () => {
|
||||||
const testFile = path.join(tempRootDir, 'exists.txt');
|
const testFile = path.join(tempRootDir, 'exists.txt');
|
||||||
|
|||||||
@@ -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<boolean> {
|
||||||
|
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.
|
* Heuristic: determine if a file is likely binary.
|
||||||
* Now BOM-aware: if a Unicode BOM is detected, we treat it as text.
|
* Now BOM-aware: if a Unicode BOM is detected, we treat it as text.
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string | null> {
|
||||||
|
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<string | null> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user