diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md index 6ebcd630..d95793f1 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -422,6 +422,13 @@ Arguments passed directly when running the CLI can override other configurations - Displays the current memory usage. - **`--yolo`**: - Enables YOLO mode, which automatically approves all tool calls. +- **`--approval-mode `**: + - Sets the approval mode for tool calls. Available modes: + - `default`: Prompt for approval on each tool call (default behavior) + - `auto_edit`: Automatically approve edit tools (replace, write_file) while prompting for others + - `yolo`: Automatically approve all tool calls (equivalent to `--yolo`) + - Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of `--yolo` for the new unified approach. + - Example: `gemini --approval-mode auto_edit` - **`--telemetry`**: - Enables [telemetry](../telemetry.md). - **`--telemetry-target`**: @@ -517,7 +524,7 @@ Sandboxing is disabled by default, but you can enable it in a few ways: - Using `--sandbox` or `-s` flag. - Setting `GEMINI_SANDBOX` environment variable. -- Sandbox is enabled in `--yolo` mode by default. +- Sandbox is enabled when using `--yolo` or `--approval-mode=yolo` by default. By default, it uses a pre-built `gemini-cli-sandbox` Docker image. diff --git a/packages/cli/src/config/config.integration.test.ts b/packages/cli/src/config/config.integration.test.ts index 87a74578..45ed6d82 100644 --- a/packages/cli/src/config/config.integration.test.ts +++ b/packages/cli/src/config/config.integration.test.ts @@ -261,4 +261,149 @@ describe('Configuration Integration Tests', () => { expect(config.getExtensionContextFilePaths()).toEqual(contextFiles); }); }); + + describe('Approval Mode Integration Tests', () => { + let parseArguments: typeof import('./config').parseArguments; + + beforeEach(async () => { + // Import the argument parsing function for integration testing + const { parseArguments: parseArgs } = await import('./config'); + parseArguments = parseArgs; + }); + + it('should parse --approval-mode=auto_edit correctly through the full argument parsing flow', async () => { + const originalArgv = process.argv; + + try { + process.argv = [ + 'node', + 'script.js', + '--approval-mode', + 'auto_edit', + '-p', + 'test', + ]; + + const argv = await parseArguments(); + + // Verify that the argument was parsed correctly + expect(argv.approvalMode).toBe('auto_edit'); + expect(argv.prompt).toBe('test'); + expect(argv.yolo).toBe(false); + } finally { + process.argv = originalArgv; + } + }); + + it('should parse --approval-mode=yolo correctly through the full argument parsing flow', async () => { + const originalArgv = process.argv; + + try { + process.argv = [ + 'node', + 'script.js', + '--approval-mode', + 'yolo', + '-p', + 'test', + ]; + + const argv = await parseArguments(); + + expect(argv.approvalMode).toBe('yolo'); + expect(argv.prompt).toBe('test'); + expect(argv.yolo).toBe(false); // Should NOT be set when using --approval-mode + } finally { + process.argv = originalArgv; + } + }); + + it('should parse --approval-mode=default correctly through the full argument parsing flow', async () => { + const originalArgv = process.argv; + + try { + process.argv = [ + 'node', + 'script.js', + '--approval-mode', + 'default', + '-p', + 'test', + ]; + + const argv = await parseArguments(); + + expect(argv.approvalMode).toBe('default'); + expect(argv.prompt).toBe('test'); + expect(argv.yolo).toBe(false); + } finally { + process.argv = originalArgv; + } + }); + + it('should parse legacy --yolo flag correctly', async () => { + const originalArgv = process.argv; + + try { + process.argv = ['node', 'script.js', '--yolo', '-p', 'test']; + + const argv = await parseArguments(); + + expect(argv.yolo).toBe(true); + expect(argv.approvalMode).toBeUndefined(); // Should NOT be set when using --yolo + expect(argv.prompt).toBe('test'); + } finally { + process.argv = originalArgv; + } + }); + + it('should reject invalid approval mode values during argument parsing', async () => { + const originalArgv = process.argv; + + try { + process.argv = ['node', 'script.js', '--approval-mode', 'invalid_mode']; + + // Should throw during argument parsing due to yargs validation + await expect(parseArguments()).rejects.toThrow(); + } finally { + process.argv = originalArgv; + } + }); + + it('should reject conflicting --yolo and --approval-mode flags', async () => { + const originalArgv = process.argv; + + try { + process.argv = [ + 'node', + 'script.js', + '--yolo', + '--approval-mode', + 'default', + ]; + + // Should throw during argument parsing due to conflict validation + await expect(parseArguments()).rejects.toThrow(); + } finally { + process.argv = originalArgv; + } + }); + + it('should handle backward compatibility with mixed scenarios', async () => { + const originalArgv = process.argv; + + try { + // Test that no approval mode arguments defaults to no flags set + process.argv = ['node', 'script.js', '-p', 'test']; + + const argv = await parseArguments(); + + expect(argv.approvalMode).toBeUndefined(); + expect(argv.yolo).toBe(false); + expect(argv.prompt).toBe('test'); + } finally { + process.argv = originalArgv; + } + }); + }); }); diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 178980eb..fc4d24bd 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -156,6 +156,93 @@ describe('parseArguments', () => { expect(argv.promptInteractive).toBe('interactive prompt'); expect(argv.prompt).toBeUndefined(); }); + + it('should throw an error when both --yolo and --approval-mode are used together', async () => { + process.argv = [ + 'node', + 'script.js', + '--yolo', + '--approval-mode', + 'default', + ]; + + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + const mockConsoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + await expect(parseArguments()).rejects.toThrow('process.exit called'); + + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining( + 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.', + ), + ); + + mockExit.mockRestore(); + mockConsoleError.mockRestore(); + }); + + it('should throw an error when using short flags -y and --approval-mode together', async () => { + process.argv = ['node', 'script.js', '-y', '--approval-mode', 'yolo']; + + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + const mockConsoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + await expect(parseArguments()).rejects.toThrow('process.exit called'); + + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining( + 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.', + ), + ); + + mockExit.mockRestore(); + mockConsoleError.mockRestore(); + }); + + it('should allow --approval-mode without --yolo', async () => { + process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit']; + const argv = await parseArguments(); + expect(argv.approvalMode).toBe('auto_edit'); + expect(argv.yolo).toBe(false); + }); + + it('should allow --yolo without --approval-mode', async () => { + process.argv = ['node', 'script.js', '--yolo']; + const argv = await parseArguments(); + expect(argv.yolo).toBe(true); + expect(argv.approvalMode).toBeUndefined(); + }); + + it('should reject invalid --approval-mode values', async () => { + process.argv = ['node', 'script.js', '--approval-mode', 'invalid']; + + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + const mockConsoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + await expect(parseArguments()).rejects.toThrow('process.exit called'); + + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('Invalid values:'), + ); + + mockExit.mockRestore(); + mockConsoleError.mockRestore(); + }); }); describe('loadCliConfig', () => { @@ -760,6 +847,211 @@ describe('mergeExcludeTools', () => { }); }); +describe('Approval mode tool exclusion logic', () => { + const originalIsTTY = process.stdin.isTTY; + + beforeEach(() => { + process.stdin.isTTY = false; // Ensure non-interactive mode + }); + + afterEach(() => { + process.stdin.isTTY = originalIsTTY; + }); + + it('should exclude all interactive tools in non-interactive mode with default approval mode', async () => { + process.argv = ['node', 'script.js', '-p', 'test']; + const argv = await parseArguments(); + const settings: Settings = {}; + const extensions: Extension[] = []; + + const config = await loadCliConfig( + settings, + extensions, + 'test-session', + argv, + ); + + const excludedTools = config.getExcludeTools(); + expect(excludedTools).toContain(ShellTool.Name); + expect(excludedTools).toContain(EditTool.Name); + expect(excludedTools).toContain(WriteFileTool.Name); + }); + + it('should exclude all interactive tools in non-interactive mode with explicit default approval mode', async () => { + process.argv = [ + 'node', + 'script.js', + '--approval-mode', + 'default', + '-p', + 'test', + ]; + const argv = await parseArguments(); + const settings: Settings = {}; + const extensions: Extension[] = []; + + const config = await loadCliConfig( + settings, + extensions, + 'test-session', + argv, + ); + + const excludedTools = config.getExcludeTools(); + expect(excludedTools).toContain(ShellTool.Name); + expect(excludedTools).toContain(EditTool.Name); + expect(excludedTools).toContain(WriteFileTool.Name); + }); + + it('should exclude only shell tools in non-interactive mode with auto_edit approval mode', async () => { + process.argv = [ + 'node', + 'script.js', + '--approval-mode', + 'auto_edit', + '-p', + 'test', + ]; + const argv = await parseArguments(); + const settings: Settings = {}; + const extensions: Extension[] = []; + + const config = await loadCliConfig( + settings, + extensions, + 'test-session', + argv, + ); + + const excludedTools = config.getExcludeTools(); + expect(excludedTools).toContain(ShellTool.Name); + expect(excludedTools).not.toContain(EditTool.Name); + expect(excludedTools).not.toContain(WriteFileTool.Name); + }); + + it('should exclude no interactive tools in non-interactive mode with yolo approval mode', async () => { + process.argv = [ + 'node', + 'script.js', + '--approval-mode', + 'yolo', + '-p', + 'test', + ]; + const argv = await parseArguments(); + const settings: Settings = {}; + const extensions: Extension[] = []; + + const config = await loadCliConfig( + settings, + extensions, + 'test-session', + argv, + ); + + const excludedTools = config.getExcludeTools(); + expect(excludedTools).not.toContain(ShellTool.Name); + expect(excludedTools).not.toContain(EditTool.Name); + expect(excludedTools).not.toContain(WriteFileTool.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(); + const settings: Settings = {}; + const extensions: Extension[] = []; + + const config = await loadCliConfig( + settings, + extensions, + 'test-session', + argv, + ); + + const excludedTools = config.getExcludeTools(); + expect(excludedTools).not.toContain(ShellTool.Name); + expect(excludedTools).not.toContain(EditTool.Name); + expect(excludedTools).not.toContain(WriteFileTool.Name); + }); + + it('should not exclude interactive tools in interactive mode regardless of approval mode', async () => { + process.stdin.isTTY = true; // Interactive mode + + const testCases = [ + { args: ['node', 'script.js'] }, // default + { args: ['node', 'script.js', '--approval-mode', 'default'] }, + { args: ['node', 'script.js', '--approval-mode', 'auto_edit'] }, + { args: ['node', 'script.js', '--approval-mode', 'yolo'] }, + { args: ['node', 'script.js', '--yolo'] }, + ]; + + for (const testCase of testCases) { + process.argv = testCase.args; + const argv = await parseArguments(); + const settings: Settings = {}; + const extensions: Extension[] = []; + + const config = await loadCliConfig( + settings, + extensions, + 'test-session', + argv, + ); + + const excludedTools = config.getExcludeTools(); + expect(excludedTools).not.toContain(ShellTool.Name); + expect(excludedTools).not.toContain(EditTool.Name); + expect(excludedTools).not.toContain(WriteFileTool.Name); + } + }); + + it('should merge approval mode exclusions with settings exclusions in auto_edit mode', async () => { + process.argv = [ + 'node', + 'script.js', + '--approval-mode', + 'auto_edit', + '-p', + 'test', + ]; + const argv = await parseArguments(); + const settings: Settings = { excludeTools: ['custom_tool'] }; + const extensions: Extension[] = []; + + const config = await loadCliConfig( + settings, + extensions, + 'test-session', + argv, + ); + + const excludedTools = config.getExcludeTools(); + expect(excludedTools).toContain('custom_tool'); // From settings + expect(excludedTools).toContain(ShellTool.Name); // From approval mode + expect(excludedTools).not.toContain(EditTool.Name); // Should be allowed in auto_edit + expect(excludedTools).not.toContain(WriteFileTool.Name); // Should be allowed in auto_edit + }); + + it('should throw an error for invalid approval mode values in loadCliConfig', async () => { + // Create a mock argv with an invalid approval mode that bypasses argument parsing validation + const invalidArgv: Partial & { approvalMode: string } = { + approvalMode: 'invalid_mode', + promptInteractive: '', + prompt: '', + yolo: false, + }; + + const settings: Settings = {}; + const extensions: Extension[] = []; + + await expect( + loadCliConfig(settings, extensions, 'test-session', invalidArgv), + ).rejects.toThrow( + 'Invalid approval mode: invalid_mode. Valid values are: yolo, auto_edit, default', + ); + }); +}); + describe('loadCliConfig with allowed-mcp-server-names', () => { const originalArgv = process.argv; const originalEnv = { ...process.env }; @@ -1327,3 +1619,80 @@ describe('loadCliConfig interactive', () => { expect(config.isInteractive()).toBe(false); }); }); + +describe('loadCliConfig approval mode', () => { + const originalArgv = process.argv; + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + process.env.GEMINI_API_KEY = 'test-api-key'; + }); + + afterEach(() => { + process.argv = originalArgv; + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + it('should default to DEFAULT approval mode when no flags are set', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const config = await loadCliConfig({}, [], 'test-session', argv); + expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); + }); + + it('should set YOLO approval mode when --yolo flag is used', async () => { + process.argv = ['node', 'script.js', '--yolo']; + const argv = await parseArguments(); + const config = await loadCliConfig({}, [], 'test-session', argv); + expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); + }); + + it('should set YOLO approval mode when -y flag is used', async () => { + process.argv = ['node', 'script.js', '-y']; + const argv = await parseArguments(); + const config = await loadCliConfig({}, [], 'test-session', argv); + expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); + }); + + it('should set DEFAULT approval mode when --approval-mode=default', async () => { + process.argv = ['node', 'script.js', '--approval-mode', 'default']; + const argv = await parseArguments(); + const config = await loadCliConfig({}, [], 'test-session', argv); + expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); + }); + + it('should set AUTO_EDIT approval mode when --approval-mode=auto_edit', async () => { + process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit']; + const argv = await parseArguments(); + const config = await loadCliConfig({}, [], 'test-session', argv); + expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.AUTO_EDIT); + }); + + it('should set YOLO approval mode when --approval-mode=yolo', async () => { + process.argv = ['node', 'script.js', '--approval-mode', 'yolo']; + const argv = await parseArguments(); + const config = await loadCliConfig({}, [], 'test-session', argv); + expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); + }); + + it('should prioritize --approval-mode over --yolo when both would be valid (but validation prevents this)', async () => { + // Note: This test documents the intended behavior, but in practice the validation + // prevents both flags from being used together + process.argv = ['node', 'script.js', '--approval-mode', 'default']; + const argv = await parseArguments(); + // Manually set yolo to true to simulate what would happen if validation didn't prevent it + argv.yolo = true; + const config = await loadCliConfig({}, [], 'test-session', argv); + expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); + }); + + it('should fall back to --yolo behavior when --approval-mode is not set', async () => { + process.argv = ['node', 'script.js', '--yolo']; + const argv = await parseArguments(); + const config = await loadCliConfig({}, [], 'test-session', argv); + expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); + }); +}); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index d0658e75..dd207ff2 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -57,6 +57,7 @@ export interface CliArgs { showMemoryUsage: boolean | undefined; show_memory_usage: boolean | undefined; yolo: boolean | undefined; + approvalMode: string | undefined; telemetry: boolean | undefined; checkpointing: boolean | undefined; telemetryTarget: string | undefined; @@ -147,6 +148,12 @@ export async function parseArguments(): Promise { 'Automatically accept all actions (aka YOLO mode, see https://www.youtube.com/watch?v=xvFZjo5PgG0 for more details)?', default: false, }) + .option('approval-mode', { + type: 'string', + choices: ['default', 'auto_edit', 'yolo'], + description: + 'Set the approval mode: default (prompt for approval), auto_edit (auto-approve edit tools), yolo (auto-approve all tools)', + }) .option('telemetry', { type: 'boolean', description: @@ -219,6 +226,11 @@ export async function parseArguments(): Promise { 'Cannot use both --prompt (-p) and --prompt-interactive (-i) together', ); } + if (argv.yolo && argv.approvalMode) { + throw new Error( + 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.', + ); + } return true; }), ) @@ -356,20 +368,59 @@ export async function loadCliConfig( let mcpServers = mergeMcpServers(settings, activeExtensions); const question = argv.promptInteractive || argv.prompt || ''; - const approvalMode = - argv.yolo || false ? ApprovalMode.YOLO : ApprovalMode.DEFAULT; + + // Determine approval mode with backward compatibility + let approvalMode: ApprovalMode; + if (argv.approvalMode) { + // New --approval-mode flag takes precedence + switch (argv.approvalMode) { + case 'yolo': + approvalMode = ApprovalMode.YOLO; + break; + case 'auto_edit': + approvalMode = ApprovalMode.AUTO_EDIT; + break; + case 'default': + approvalMode = ApprovalMode.DEFAULT; + break; + default: + throw new Error( + `Invalid approval mode: ${argv.approvalMode}. Valid values are: yolo, auto_edit, default`, + ); + } + } else { + // Fallback to legacy --yolo flag behavior + approvalMode = + argv.yolo || false ? ApprovalMode.YOLO : ApprovalMode.DEFAULT; + } + const interactive = !!argv.promptInteractive || (process.stdin.isTTY && question.length === 0); - // In non-interactive and non-yolo mode, exclude interactive built in tools. - const extraExcludes = - !interactive && approvalMode !== ApprovalMode.YOLO - ? [ShellTool.Name, EditTool.Name, WriteFileTool.Name] - : undefined; + // In non-interactive mode, exclude tools that require a prompt. + const extraExcludes: string[] = []; + if (!interactive) { + switch (approvalMode) { + case ApprovalMode.DEFAULT: + // In default non-interactive mode, all tools that require approval are excluded. + extraExcludes.push(ShellTool.Name, EditTool.Name, WriteFileTool.Name); + break; + case ApprovalMode.AUTO_EDIT: + // In auto-edit non-interactive mode, only tools that still require a prompt are excluded. + extraExcludes.push(ShellTool.Name); + break; + case ApprovalMode.YOLO: + // No extra excludes for YOLO mode. + break; + default: + // This should never happen due to validation earlier, but satisfies the linter + break; + } + } const excludeTools = mergeExcludeTools( settings, activeExtensions, - extraExcludes, + extraExcludes.length > 0 ? extraExcludes : undefined, ); const blockedMcpServers: Array<{ name: string; extensionName: string }> = [];