feat: add --approval-mode parameter (#6024)

Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
Arya Gummadi 2025-08-12 15:10:22 -07:00 committed by GitHub
parent 11377915db
commit 8d6eb8c322
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 581 additions and 9 deletions

View File

@ -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.

View File

@ -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;
}
});
});
});

View File

@ -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);
});
});

View File

@ -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 =
// 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 }> = [];