summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorArya Gummadi <[email protected]>2025-08-12 15:10:22 -0700
committerGitHub <[email protected]>2025-08-12 22:10:22 +0000
commit8d6eb8c322890b5cdf20d4a30dd17afb1541f5aa (patch)
tree34c8c86d69aa96405c2c1024c45cef76d1675ece
parent11377915dbf42064ed9a8d9e31b46adc565ae021 (diff)
feat: add --approval-mode parameter (#6024)
Co-authored-by: Jacob Richman <[email protected]>
-rw-r--r--docs/cli/configuration.md9
-rw-r--r--packages/cli/src/config/config.integration.test.ts145
-rw-r--r--packages/cli/src/config/config.test.ts369
-rw-r--r--packages/cli/src/config/config.ts67
4 files changed, 581 insertions, 9 deletions
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 <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<CliArgs> & { 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<CliArgs> {
'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<CliArgs> {
'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 }> = [];