From 537e56ffae2df937f5763f01663df67b8277fa4b Mon Sep 17 00:00:00 2001 From: Jerop Kipruto Date: Thu, 19 Feb 2026 17:47:08 -0500 Subject: [PATCH] feat(plan): support configuring custom plans storage directory (#19577) --- docs/cli/plan-mode.md | 41 +++++++ docs/cli/settings.md | 1 + docs/get-started/configuration.md | 6 + packages/cli/src/config/config.test.ts | 21 +++- packages/cli/src/config/config.ts | 1 + .../cli/src/config/settingsSchema.test.ts | 10 ++ packages/cli/src/config/settingsSchema.ts | 22 ++++ .../cli/src/ui/commands/planCommand.test.ts | 2 +- packages/cli/src/ui/commands/planCommand.ts | 2 +- .../ui/components/ExitPlanModeDialog.test.tsx | 4 +- .../src/ui/components/ExitPlanModeDialog.tsx | 4 +- .../components/ToolConfirmationQueue.test.tsx | 2 +- .../SettingsDialog.test.tsx.snap | 54 ++++----- packages/core/src/config/config.test.ts | 40 ++++++- packages/core/src/config/config.ts | 8 +- packages/core/src/config/storage.test.ts | 106 +++++++++++++++++- packages/core/src/config/storage.ts | 27 +++++ packages/core/src/core/prompts.test.ts | 8 +- .../core/src/prompts/promptProvider.test.ts | 4 +- packages/core/src/prompts/promptProvider.ts | 2 +- .../core/src/tools/enter-plan-mode.test.ts | 2 +- .../core/src/tools/exit-plan-mode.test.ts | 2 +- packages/core/src/tools/exit-plan-mode.ts | 10 +- schemas/settings.schema.json | 16 +++ 24 files changed, 337 insertions(+), 58 deletions(-) diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index 1f283a63aa..995e693bb2 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -127,6 +127,47 @@ To use a skill in Plan Mode, you can explicitly ask the agent to "use the [skill-name] skill to plan..." or the agent may autonomously activate it based on the task description. +### Custom Plan Directory and Policies + +By default, planning artifacts are stored in a managed temporary directory +outside your project: `~/.gemini/tmp///plans/`. + +You can configure a custom directory for plans in your `settings.json`. For +example, to store plans in a `.gemini/plans` directory within your project: + +```json +{ + "general": { + "plan": { + "directory": ".gemini/plans" + } + } +} +``` + +To maintain the safety of Plan Mode, user-configured paths for the plans +directory are restricted to the project root. This ensures that custom planning +locations defined within a project's workspace cannot be used to escape and +overwrite sensitive files elsewhere. Any user-configured directory must reside +within the project boundary. + +Because Plan Mode is read-only by default, using a custom directory requires +updating your [Policy Engine] configurations to allow `write_file` and `replace` +in that specific location. For example, to allow writing to the `.gemini/plans` +directory within your project, create a policy file at +`~/.gemini/policies/plan-custom-directory.toml`: + +```toml +[[rule]] +toolName = ["write_file", "replace"] +decision = "allow" +priority = 100 +modes = ["plan"] +# Adjust the pattern to match your custom directory. +# This example matches any .md file in a .gemini/plans directory within the project. +argsPattern = "\"file_path\":\".*\\\\.gemini/plans/.*\\\\.md\"" +``` + ### Customizing Policies Plan Mode is designed to be read-only by default to ensure safety during the diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 318fdbea75..a7689fbcea 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -28,6 +28,7 @@ they appear in the UI. | Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` | | Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` | | Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` | +| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. | `undefined` | | Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` | | Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` | | Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` | diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index 8e2164080f..ba86442a4d 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -131,6 +131,12 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **Requires restart:** Yes +- **`general.plan.directory`** (string): + - **Description:** The directory where planning artifacts are stored. If not + specified, defaults to the system temporary directory. + - **Default:** `undefined` + - **Requires restart:** Yes + - **`general.enablePromptCompletion`** (boolean): - **Description:** Enable AI-powered prompt completion suggestions while typing. diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 0c52c9bc4b..809b31cd82 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -21,7 +21,11 @@ import { type MCPServerConfig, } from '@google/gemini-cli-core'; import { loadCliConfig, parseArguments, type CliArgs } from './config.js'; -import { type Settings, createTestMergedSettings } from './settings.js'; +import { + type Settings, + type MergedSettings, + createTestMergedSettings, +} from './settings.js'; import * as ServerConfig from '@google/gemini-cli-core'; import { isWorkspaceTrusted } from './trustedFolders.js'; @@ -2599,6 +2603,21 @@ describe('loadCliConfig approval mode', () => { expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT); }); + it('should pass planSettings.directory from settings to config', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + general: { + plan: { + directory: '.custom-plans', + }, + }, + } as unknown as MergedSettings); + const argv = await parseArguments(settings); + const config = await loadCliConfig(settings, 'test-session', argv); + const plansDir = config.storage.getPlansDir(); + expect(plansDir).toContain('.custom-plans'); + }); + // --- Untrusted Folder Scenarios --- describe('when folder is NOT trusted', () => { beforeEach(() => { diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 4a17ae8ecc..6b7f3460af 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -814,6 +814,7 @@ export async function loadCliConfig( enableExtensionReloading: settings.experimental?.extensionReloading, enableAgents: settings.experimental?.enableAgents, plan: settings.experimental?.plan, + planSettings: settings.general.plan, enableEventDrivenScheduler: true, skillsSupport: settings.skills?.enabled ?? true, disabledSkills: settings.skills?.disabled, diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 2638ac0347..ffe1dd2ac5 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -107,6 +107,16 @@ describe('SettingsSchema', () => { ).toBe('boolean'); }); + it('should have plan nested properties', () => { + expect( + getSettingsSchema().general?.properties?.plan?.properties?.directory, + ).toBeDefined(); + expect( + getSettingsSchema().general?.properties?.plan?.properties?.directory + .type, + ).toBe('string'); + }); + it('should have fileFiltering nested properties', () => { expect( getSettingsSchema().context.properties.fileFiltering.properties diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 70c5363659..f0e092b45b 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -266,6 +266,27 @@ const SETTINGS_SCHEMA = { }, }, }, + plan: { + type: 'object', + label: 'Plan', + category: 'General', + requiresRestart: true, + default: {}, + description: 'Planning features configuration.', + showInDialog: false, + properties: { + directory: { + type: 'string', + label: 'Plan Directory', + category: 'General', + requiresRestart: true, + default: undefined as string | undefined, + description: + 'The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory.', + showInDialog: true, + }, + }, + }, enablePromptCompletion: { type: 'boolean', label: 'Enable Prompt Completion', @@ -1313,6 +1334,7 @@ const SETTINGS_SCHEMA = { }, }, }, + useWriteTodos: { type: 'boolean', label: 'Use WriteTodos', diff --git a/packages/cli/src/ui/commands/planCommand.test.ts b/packages/cli/src/ui/commands/planCommand.test.ts index af556ae255..2608b44ca9 100644 --- a/packages/cli/src/ui/commands/planCommand.test.ts +++ b/packages/cli/src/ui/commands/planCommand.test.ts @@ -51,7 +51,7 @@ describe('planCommand', () => { getApprovalMode: vi.fn(), getFileSystemService: vi.fn(), storage: { - getProjectTempPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'), + getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'), }, }, }, diff --git a/packages/cli/src/ui/commands/planCommand.ts b/packages/cli/src/ui/commands/planCommand.ts index c64b0048f4..d9cc6739da 100644 --- a/packages/cli/src/ui/commands/planCommand.ts +++ b/packages/cli/src/ui/commands/planCommand.ts @@ -43,7 +43,7 @@ export const planCommand: SlashCommand = { try { const content = await processSingleFileContent( approvedPlanPath, - config.storage.getProjectTempPlansDir(), + config.storage.getPlansDir(), config.getFileSystemService(), ); const fileName = path.basename(approvedPlanPath); diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx index 36c7bb3437..26b61829a0 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx @@ -154,7 +154,7 @@ Implement a comprehensive authentication system with multiple providers. getIdeMode: () => false, isTrustedFolder: () => true, storage: { - getProjectTempPlansDir: () => mockPlansDir, + getPlansDir: () => mockPlansDir, }, getFileSystemService: (): FileSystemService => ({ readTextFile: vi.fn(), @@ -429,7 +429,7 @@ Implement a comprehensive authentication system with multiple providers. getIdeMode: () => false, isTrustedFolder: () => true, storage: { - getProjectTempPlansDir: () => mockPlansDir, + getPlansDir: () => mockPlansDir, }, getFileSystemService: (): FileSystemService => ({ readTextFile: vi.fn(), diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx index 9fc1adfc23..8777136d86 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx @@ -65,7 +65,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState { try { const pathError = await validatePlanPath( planPath, - config.storage.getProjectTempPlansDir(), + config.storage.getPlansDir(), config.getTargetDir(), ); if (ignore) return; @@ -83,7 +83,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState { const result = await processSingleFileContent( planPath, - config.storage.getProjectTempPlansDir(), + config.storage.getPlansDir(), config.getFileSystemService(), ); diff --git a/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx b/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx index 4ea2f22796..345d00a263 100644 --- a/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx +++ b/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx @@ -49,7 +49,7 @@ describe('ToolConfirmationQueue', () => { readFile: vi.fn().mockResolvedValue('Plan content'), }), storage: { - getProjectTempPlansDir: () => '/mock/temp/plans', + getPlansDir: () => '/mock/temp/plans', }, } as unknown as Config; diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap index 0dba43a791..3fec2244d7 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap @@ -22,6 +22,9 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v │ Enable Notifications false │ │ Enable run-event notifications for action-required prompts and session completion. … │ │ │ +│ Plan Directory undefined │ +│ The directory where planning artifacts are stored. If not specified, defaults t… │ +│ │ │ Enable Prompt Completion false │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -31,9 +34,6 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ -│ Keep chat history undefined │ -│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -69,6 +69,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings │ Enable Notifications false │ │ Enable run-event notifications for action-required prompts and session completion. … │ │ │ +│ Plan Directory undefined │ +│ The directory where planning artifacts are stored. If not specified, defaults t… │ +│ │ │ Enable Prompt Completion false │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -78,9 +81,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ -│ Keep chat history undefined │ -│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -116,6 +116,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d │ Enable Notifications false │ │ Enable run-event notifications for action-required prompts and session completion. … │ │ │ +│ Plan Directory undefined │ +│ The directory where planning artifacts are stored. If not specified, defaults t… │ +│ │ │ Enable Prompt Completion false* │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -125,9 +128,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ -│ Keep chat history undefined │ -│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -163,6 +163,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct │ Enable Notifications false │ │ Enable run-event notifications for action-required prompts and session completion. … │ │ │ +│ Plan Directory undefined │ +│ The directory where planning artifacts are stored. If not specified, defaults t… │ +│ │ │ Enable Prompt Completion false │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -172,9 +175,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ -│ Keep chat history undefined │ -│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -210,6 +210,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting │ Enable Notifications false │ │ Enable run-event notifications for action-required prompts and session completion. … │ │ │ +│ Plan Directory undefined │ +│ The directory where planning artifacts are stored. If not specified, defaults t… │ +│ │ │ Enable Prompt Completion false │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -219,9 +222,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ -│ Keep chat history undefined │ -│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -257,6 +257,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec │ Enable Notifications false │ │ Enable run-event notifications for action-required prompts and session completion. … │ │ │ +│ Plan Directory undefined │ +│ The directory where planning artifacts are stored. If not specified, defaults t… │ +│ │ │ Enable Prompt Completion false │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -266,9 +269,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ -│ Keep chat history undefined │ -│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ -│ │ │ ▼ │ │ │ │ > Apply To │ @@ -304,6 +304,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb │ Enable Notifications false │ │ Enable run-event notifications for action-required prompts and session completion. … │ │ │ +│ Plan Directory undefined │ +│ The directory where planning artifacts are stored. If not specified, defaults t… │ +│ │ │ Enable Prompt Completion false │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -313,9 +316,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ -│ Keep chat history undefined │ -│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -351,6 +351,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set │ Enable Notifications false │ │ Enable run-event notifications for action-required prompts and session completion. … │ │ │ +│ Plan Directory undefined │ +│ The directory where planning artifacts are stored. If not specified, defaults t… │ +│ │ │ Enable Prompt Completion false │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -360,9 +363,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ -│ Keep chat history undefined │ -│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -398,6 +398,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin │ Enable Notifications false │ │ Enable run-event notifications for action-required prompts and session completion. … │ │ │ +│ Plan Directory undefined │ +│ The directory where planning artifacts are stored. If not specified, defaults t… │ +│ │ │ Enable Prompt Completion true* │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -407,9 +410,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ -│ Keep chat history undefined │ -│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ -│ │ │ ▼ │ │ │ │ Apply To │ diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index c297a20ef6..1c8820f273 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -737,6 +737,42 @@ describe('Server Config (config.ts)', () => { ); }); + describe('Plan Settings', () => { + const testCases = [ + { + name: 'should pass custom plan directory to storage', + planSettings: { directory: 'custom-plans' }, + expected: 'custom-plans', + }, + { + name: 'should call setCustomPlansDir with undefined if directory is not provided', + planSettings: {}, + expected: undefined, + }, + { + name: 'should call setCustomPlansDir with undefined if planSettings is not provided', + planSettings: undefined, + expected: undefined, + }, + ]; + + testCases.forEach(({ name, planSettings, expected }) => { + it(`${name}`, () => { + const setCustomPlansDirSpy = vi.spyOn( + Storage.prototype, + 'setCustomPlansDir', + ); + new Config({ + ...baseParams, + planSettings, + }); + + expect(setCustomPlansDirSpy).toHaveBeenCalledWith(expected); + setCustomPlansDirSpy.mockRestore(); + }); + }); + }); + describe('Telemetry Settings', () => { it('should return default telemetry target if not provided', () => { const params: ConfigParameters = { @@ -2501,7 +2537,7 @@ describe('Plans Directory Initialization', () => { await config.initialize(); - const plansDir = config.storage.getProjectTempPlansDir(); + const plansDir = config.storage.getPlansDir(); expect(fs.promises.mkdir).toHaveBeenCalledWith(plansDir, { recursive: true, }); @@ -2518,7 +2554,7 @@ describe('Plans Directory Initialization', () => { await config.initialize(); - const plansDir = config.storage.getProjectTempPlansDir(); + const plansDir = config.storage.getPlansDir(); expect(fs.promises.mkdir).not.toHaveBeenCalledWith(plansDir, { recursive: true, }); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 33e02abf89..5b57a81acf 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -141,6 +141,10 @@ export interface SummarizeToolOutputSettings { tokenBudget?: number; } +export interface PlanSettings { + directory?: string; +} + export interface TelemetrySettings { enabled?: boolean; target?: TelemetryTarget; @@ -483,6 +487,7 @@ export interface ConfigParameters { toolOutputMasking?: Partial; disableLLMCorrection?: boolean; plan?: boolean; + planSettings?: PlanSettings; modelSteering?: boolean; onModelChange?: (model: string) => void; mcpEnabled?: boolean; @@ -836,6 +841,7 @@ export class Config { this.extensionManagement = params.extensionManagement ?? true; this.enableExtensionReloading = params.enableExtensionReloading ?? false; this.storage = new Storage(this.targetDir, this.sessionId); + this.storage.setCustomPlansDir(params.planSettings?.directory); this.fakeResponses = params.fakeResponses; this.recordResponses = params.recordResponses; @@ -949,7 +955,7 @@ export class Config { // Add plans directory to workspace context for plan file storage if (this.planEnabled) { - const plansDir = this.storage.getProjectTempPlansDir(); + const plansDir = this.storage.getPlansDir(); await fs.promises.mkdir(plansDir, { recursive: true }); this.workspaceContext.addDirectory(plansDir); } diff --git a/packages/core/src/config/storage.test.ts b/packages/core/src/config/storage.test.ts index 8d91ca1a3e..afb3eaeeeb 100644 --- a/packages/core/src/config/storage.test.ts +++ b/packages/core/src/config/storage.test.ts @@ -12,12 +12,14 @@ vi.unmock('./storageMigration.js'); import * as os from 'node:os'; import * as path from 'node:path'; +import * as fs from 'node:fs'; vi.mock('fs', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, mkdirSync: vi.fn(), + realpathSync: vi.fn(actual.realpathSync), }; }); @@ -61,12 +63,11 @@ describe('Storage – initialize', () => { ).toHaveBeenCalledWith(projectRoot); // Verify migration calls - const shortId = 'project-slug'; // We can't easily get the hash here without repeating logic, but we can verify it's called twice expect(StorageMigration.migrateDirectory).toHaveBeenCalledTimes(2); // Verify identifier is set by checking a path - expect(storage.getProjectTempDir()).toContain(shortId); + expect(storage.getProjectTempDir()).toContain(PROJECT_SLUG); }); }); @@ -105,6 +106,12 @@ describe('Storage – additional helpers', () => { const projectRoot = '/tmp/project'; const storage = new Storage(projectRoot); + beforeEach(() => { + ProjectRegistry.prototype.getShortId = vi + .fn() + .mockReturnValue(PROJECT_SLUG); + }); + it('getWorkspaceSettingsPath returns project/.gemini/settings.json', () => { const expected = path.join(projectRoot, GEMINI_DIR, 'settings.json'); expect(storage.getWorkspaceSettingsPath()).toBe(expected); @@ -172,6 +179,101 @@ describe('Storage – additional helpers', () => { const expected = path.join(tempDir, sessionId, 'plans'); expect(storageWithSession.getProjectTempPlansDir()).toBe(expected); }); + + describe('getPlansDir', () => { + interface TestCase { + name: string; + customDir: string | undefined; + expected: string | (() => string); + expectedError?: string; + setup?: () => () => void; + } + + const testCases: TestCase[] = [ + { + name: 'custom relative path', + customDir: '.my-plans', + expected: path.resolve(projectRoot, '.my-plans'), + }, + { + name: 'custom absolute path outside throws', + customDir: '/absolute/path/to/plans', + expected: '', + expectedError: + "Custom plans directory '/absolute/path/to/plans' resolves to '/absolute/path/to/plans', which is outside the project root '/tmp/project'.", + }, + { + name: 'absolute path that happens to be inside project root', + customDir: path.join(projectRoot, 'internal-plans'), + expected: path.join(projectRoot, 'internal-plans'), + }, + { + name: 'relative path that stays within project root', + customDir: 'subdir/../plans', + expected: path.resolve(projectRoot, 'plans'), + }, + { + name: 'dot path', + customDir: '.', + expected: projectRoot, + }, + { + name: 'default behavior when customDir is undefined', + customDir: undefined, + expected: () => storage.getProjectTempPlansDir(), + }, + { + name: 'escaping relative path throws', + customDir: '../escaped-plans', + expected: '', + expectedError: + "Custom plans directory '../escaped-plans' resolves to '/tmp/escaped-plans', which is outside the project root '/tmp/project'.", + }, + { + name: 'hidden directory starting with ..', + customDir: '..plans', + expected: path.resolve(projectRoot, '..plans'), + }, + { + name: 'security escape via symbolic link throws', + customDir: 'symlink-to-outside', + setup: () => { + vi.mocked(fs.realpathSync).mockImplementation((p: fs.PathLike) => { + if (p.toString().includes('symlink-to-outside')) { + return '/outside/project/root'; + } + return p.toString(); + }); + return () => vi.mocked(fs.realpathSync).mockRestore(); + }, + expected: '', + expectedError: + "Custom plans directory 'symlink-to-outside' resolves to '/outside/project/root', which is outside the project root '/tmp/project'.", + }, + ]; + + testCases.forEach(({ name, customDir, expected, expectedError, setup }) => { + it(`should handle ${name}`, async () => { + const cleanup = setup?.(); + try { + if (name.includes('default behavior')) { + await storage.initialize(); + } + + storage.setCustomPlansDir(customDir); + if (expectedError) { + expect(() => storage.getPlansDir()).toThrow(expectedError); + } else { + const expectedValue = + typeof expected === 'function' ? expected() : expected; + expect(storage.getPlansDir()).toBe(expectedValue); + } + } finally { + cleanup?.(); + } + }); + }); + }); }); describe('Storage - System Paths', () => { diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 2d7f5d8c2a..bce91f7991 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -12,6 +12,8 @@ import { GEMINI_DIR, homedir, GOOGLE_ACCOUNTS_FILENAME, + isSubpath, + resolveToRealPath, } from '../utils/paths.js'; import { ProjectRegistry } from './projectRegistry.js'; import { StorageMigration } from './storageMigration.js'; @@ -26,12 +28,17 @@ export class Storage { private readonly sessionId: string | undefined; private projectIdentifier: string | undefined; private initPromise: Promise | undefined; + private customPlansDir: string | undefined; constructor(targetDir: string, sessionId?: string) { this.targetDir = targetDir; this.sessionId = sessionId; } + setCustomPlansDir(dir: string | undefined): void { + this.customPlansDir = dir; + } + static getGlobalGeminiDir(): string { const homeDir = homedir(); if (!homeDir) { @@ -253,6 +260,26 @@ export class Storage { return path.join(this.getProjectTempDir(), 'plans'); } + getPlansDir(): string { + if (this.customPlansDir) { + const resolvedPath = path.resolve( + this.getProjectRoot(), + this.customPlansDir, + ); + const realProjectRoot = resolveToRealPath(this.getProjectRoot()); + const realResolvedPath = resolveToRealPath(resolvedPath); + + if (!isSubpath(realProjectRoot, realResolvedPath)) { + throw new Error( + `Custom plans directory '${this.customPlansDir}' resolves to '${realResolvedPath}', which is outside the project root '${realProjectRoot}'.`, + ); + } + + return resolvedPath; + } + return this.getProjectTempPlansDir(); + } + getProjectTempTasksDir(): string { if (this.sessionId) { return path.join(this.getProjectTempDir(), this.sessionId, 'tasks'); diff --git a/packages/core/src/core/prompts.test.ts b/packages/core/src/core/prompts.test.ts index ce6f383009..0cee2f8ae4 100644 --- a/packages/core/src/core/prompts.test.ts +++ b/packages/core/src/core/prompts.test.ts @@ -89,9 +89,7 @@ describe('Core System Prompt (prompts.ts)', () => { getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true), storage: { getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'), - getProjectTempPlansDir: vi - .fn() - .mockReturnValue('/tmp/project-temp/plans'), + getPlansDir: vi.fn().mockReturnValue('/tmp/project-temp/plans'), }, isInteractive: vi.fn().mockReturnValue(true), isInteractiveShellEnabled: vi.fn().mockReturnValue(true), @@ -509,9 +507,7 @@ describe('Core System Prompt (prompts.ts)', () => { vi.mocked(mockConfig.getApprovalMode).mockReturnValue( ApprovalMode.PLAN, ); - vi.mocked(mockConfig.storage.getProjectTempPlansDir).mockReturnValue( - '/tmp/plans', - ); + vi.mocked(mockConfig.storage.getPlansDir).mockReturnValue('/tmp/plans'); }); it('should include approved plan path when set in config', () => { diff --git a/packages/core/src/prompts/promptProvider.test.ts b/packages/core/src/prompts/promptProvider.test.ts index bdc8d553f3..d112b2f06f 100644 --- a/packages/core/src/prompts/promptProvider.test.ts +++ b/packages/core/src/prompts/promptProvider.test.ts @@ -38,9 +38,7 @@ describe('PromptProvider', () => { getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true), storage: { getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'), - getProjectTempPlansDir: vi - .fn() - .mockReturnValue('/tmp/project-temp/plans'), + getPlansDir: vi.fn().mockReturnValue('/tmp/project-temp/plans'), }, isInteractive: vi.fn().mockReturnValue(true), isInteractiveShellEnabled: vi.fn().mockReturnValue(true), diff --git a/packages/core/src/prompts/promptProvider.ts b/packages/core/src/prompts/promptProvider.ts index 2b7b7854eb..36ffddf71c 100644 --- a/packages/core/src/prompts/promptProvider.ts +++ b/packages/core/src/prompts/promptProvider.ts @@ -172,7 +172,7 @@ export class PromptProvider { 'planningWorkflow', () => ({ planModeToolsList, - plansDir: config.storage.getProjectTempPlansDir(), + plansDir: config.storage.getPlansDir(), approvedPlanPath: config.getApprovedPlanPath(), }), isPlanMode, diff --git a/packages/core/src/tools/enter-plan-mode.test.ts b/packages/core/src/tools/enter-plan-mode.test.ts index 0b1d0a37f0..48bc5b494e 100644 --- a/packages/core/src/tools/enter-plan-mode.test.ts +++ b/packages/core/src/tools/enter-plan-mode.test.ts @@ -24,7 +24,7 @@ describe('EnterPlanModeTool', () => { mockConfig = { setApprovalMode: vi.fn(), storage: { - getProjectTempPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'), + getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'), } as unknown as Config['storage'], }; tool = new EnterPlanModeTool( diff --git a/packages/core/src/tools/exit-plan-mode.test.ts b/packages/core/src/tools/exit-plan-mode.test.ts index 3e226c5142..22de81fc7f 100644 --- a/packages/core/src/tools/exit-plan-mode.test.ts +++ b/packages/core/src/tools/exit-plan-mode.test.ts @@ -45,7 +45,7 @@ describe('ExitPlanModeTool', () => { setApprovalMode: vi.fn(), setApprovedPlanPath: vi.fn(), storage: { - getProjectTempPlansDir: vi.fn().mockReturnValue(mockPlansDir), + getPlansDir: vi.fn().mockReturnValue(mockPlansDir), } as unknown as Config['storage'], }; tool = new ExitPlanModeTool( diff --git a/packages/core/src/tools/exit-plan-mode.ts b/packages/core/src/tools/exit-plan-mode.ts index a0540b11e3..c11eaa119e 100644 --- a/packages/core/src/tools/exit-plan-mode.ts +++ b/packages/core/src/tools/exit-plan-mode.ts @@ -57,7 +57,7 @@ export class ExitPlanModeTool extends BaseDeclarativeTool< private config: Config, messageBus: MessageBus, ) { - const plansDir = config.storage.getProjectTempPlansDir(); + const plansDir = config.storage.getPlansDir(); const definition = getExitPlanModeDefinition(plansDir); super( EXIT_PLAN_MODE_TOOL_NAME, @@ -78,9 +78,7 @@ export class ExitPlanModeTool extends BaseDeclarativeTool< // 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 plansDir = resolveToRealPath(this.config.storage.getPlansDir()); const resolvedPath = path.resolve( this.config.getTargetDir(), params.plan_path, @@ -111,7 +109,7 @@ export class ExitPlanModeTool extends BaseDeclarativeTool< } override getSchema(modelId?: string) { - const plansDir = this.config.storage.getProjectTempPlansDir(); + const plansDir = this.config.storage.getPlansDir(); return resolveToolDeclaration(getExitPlanModeDefinition(plansDir), modelId); } } @@ -141,7 +139,7 @@ export class ExitPlanModeInvocation extends BaseToolInvocation< const pathError = await validatePlanPath( this.params.plan_path, - this.config.storage.getProjectTempPlansDir(), + this.config.storage.getPlansDir(), this.config.getTargetDir(), ); if (pathError) { diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index f15af605a3..4c51c13e1b 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -105,6 +105,22 @@ }, "additionalProperties": false }, + "plan": { + "title": "Plan", + "description": "Planning features configuration.", + "markdownDescription": "Planning features configuration.\n\n- Category: `General`\n- Requires restart: `yes`\n- Default: `{}`", + "default": {}, + "type": "object", + "properties": { + "directory": { + "title": "Plan Directory", + "description": "The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory.", + "markdownDescription": "The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory.\n\n- Category: `General`\n- Requires restart: `yes`", + "type": "string" + } + }, + "additionalProperties": false + }, "enablePromptCompletion": { "title": "Enable Prompt Completion", "description": "Enable AI-powered prompt completion suggestions while typing.",