From 655ab21d8b0162dd9a6d0bcbc57c253366b4cc14 Mon Sep 17 00:00:00 2001 From: Jerop Kipruto Date: Thu, 15 Jan 2026 17:00:19 -0500 Subject: [PATCH] feat(plan): add experimental 'plan' approval mode (#16753) --- docs/get-started/configuration.md | 4 ++ packages/cli/src/config/config.test.ts | 62 +++++++++++++++++++++++++- packages/cli/src/config/config.ts | 19 ++++++-- packages/core/src/policy/types.ts | 1 + 4 files changed, 82 insertions(+), 4 deletions(-) diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index d0ae56041c..de9c6db5e7 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -1346,6 +1346,10 @@ for that specific session. - `auto_edit`: Automatically approve edit tools (replace, write_file) while prompting for others - `yolo`: Automatically approve all tool calls (equivalent to `--yolo`) + - `plan`: Read-only mode for tool calls (requires experimental planning to + be enabled). + > **Note:** This mode is currently under development and not yet fully + > functional. - Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of `--yolo` for the new unified approach. - Example: `gemini --approval-mode auto_edit` diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 3dd1a6e155..59d1f4d906 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -1011,6 +1011,30 @@ describe('Approval mode tool exclusion logic', () => { expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME); }); + it('should exclude all interactive tools in non-interactive mode with plan approval mode', async () => { + process.argv = [ + 'node', + 'script.js', + '--approval-mode', + 'plan', + '-p', + 'test', + ]; + const settings = createTestMergedSettings({ + experimental: { + plan: true, + }, + }); + const argv = await parseArguments(createTestMergedSettings()); + + const config = await loadCliConfig(settings, 'test-session', argv); + + const excludedTools = config.getExcludeTools(); + expect(excludedTools).toContain(SHELL_TOOL_NAME); + expect(excludedTools).toContain(EDIT_TOOL_NAME); + expect(excludedTools).toContain(WRITE_FILE_TOOL_NAME); + }); + it('should exclude no interactive tools in non-interactive mode with legacy yolo flag', async () => { process.argv = ['node', 'script.js', '--yolo', '-p', 'test']; const argv = await parseArguments(createTestMergedSettings()); @@ -1099,7 +1123,7 @@ describe('Approval mode tool exclusion logic', () => { await expect( loadCliConfig(settings, 'test-session', invalidArgv as CliArgs), ).rejects.toThrow( - 'Invalid approval mode: invalid_mode. Valid values are: yolo, auto_edit, default', + 'Invalid approval mode: invalid_mode. Valid values are: yolo, auto_edit, plan, default', ); }); }); @@ -2052,6 +2076,42 @@ describe('loadCliConfig approval mode', () => { expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); }); + it('should set Plan approval mode when --approval-mode=plan is used and experimental.plan is enabled', async () => { + process.argv = ['node', 'script.js', '--approval-mode', 'plan']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + experimental: { + plan: true, + }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.PLAN); + }); + + it('should throw error when --approval-mode=plan is used but experimental.plan is disabled', async () => { + process.argv = ['node', 'script.js', '--approval-mode', 'plan']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + experimental: { + plan: false, + }, + }); + + await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( + 'Approval mode "plan" is only available when experimental.plan is enabled.', + ); + }); + + it('should throw error when --approval-mode=plan is used but experimental.plan setting is missing', async () => { + process.argv = ['node', 'script.js', '--approval-mode', 'plan']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({}); + + await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( + 'Approval mode "plan" is only available when experimental.plan is enabled.', + ); + }); + // --- 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 7dfa125bcb..591d861d7c 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -143,9 +143,9 @@ export async function parseArguments( .option('approval-mode', { type: 'string', nargs: 1, - choices: ['default', 'auto_edit', 'yolo'], + choices: ['default', 'auto_edit', 'yolo', 'plan'], description: - 'Set the approval mode: default (prompt for approval), auto_edit (auto-approve edit tools), yolo (auto-approve all tools)', + 'Set the approval mode: default (prompt for approval), auto_edit (auto-approve edit tools), yolo (auto-approve all tools), plan (read-only mode)', }) .option('experimental-acp', { type: 'boolean', @@ -492,12 +492,20 @@ export async function loadCliConfig( case 'auto_edit': approvalMode = ApprovalMode.AUTO_EDIT; break; + case 'plan': + if (!(settings.experimental?.plan ?? false)) { + throw new Error( + 'Approval mode "plan" is only available when experimental.plan is enabled.', + ); + } + approvalMode = ApprovalMode.PLAN; + break; case 'default': approvalMode = ApprovalMode.DEFAULT; break; default: throw new Error( - `Invalid approval mode: ${argv.approvalMode}. Valid values are: yolo, auto_edit, default`, + `Invalid approval mode: ${argv.approvalMode}. Valid values are: yolo, auto_edit, plan, default`, ); } } else { @@ -578,6 +586,11 @@ export async function loadCliConfig( ); switch (approvalMode) { + case ApprovalMode.PLAN: + // In plan non-interactive mode, all tools that require approval are excluded. + // TODO(#16625): Replace this default exclusion logic with specific rules for plan mode. + extraExcludes.push(...defaultExcludes.filter(toolExclusionFilter)); + break; case ApprovalMode.DEFAULT: // In default non-interactive mode, all tools that require approval are excluded. extraExcludes.push(...defaultExcludes.filter(toolExclusionFilter)); diff --git a/packages/core/src/policy/types.ts b/packages/core/src/policy/types.ts index 28f32035bc..21c7c7f190 100644 --- a/packages/core/src/policy/types.ts +++ b/packages/core/src/policy/types.ts @@ -46,6 +46,7 @@ export enum ApprovalMode { DEFAULT = 'default', AUTO_EDIT = 'autoEdit', YOLO = 'yolo', + PLAN = 'plan', } /**