diff --git a/docs/cli/enterprise.md b/docs/cli/enterprise.md index 0274e48f00..fb641b82f0 100644 --- a/docs/cli/enterprise.md +++ b/docs/cli/enterprise.md @@ -202,6 +202,26 @@ allowlisting with `coreTools`, as it relies on blocking known-bad commands, and clever users may find ways to bypass simple string-based blocks. **Allowlisting is the recommended approach.** +### Disabling YOLO Mode + +To ensure that users cannot bypass the confirmation prompt for tool execution, +you can disable YOLO mode at the policy level. This adds a critical layer of +safety, as it prevents the model from executing tools without explicit user +approval. + +**Example:** Force all tool executions to require user confirmation. + +```json +{ + "security": { + "disableYoloMode": true + } +} +``` + +This setting is highly recommended in an enterprise environment to prevent +unintended tool execution. + ## Managing Custom Tools (MCP Servers) If your organization uses custom tools via diff --git a/integration-tests/file-system-interactive.test.ts b/integration-tests/file-system-interactive.test.ts index 2dec6c593c..d7ad73fd0d 100644 --- a/integration-tests/file-system-interactive.test.ts +++ b/integration-tests/file-system-interactive.test.ts @@ -20,7 +20,16 @@ describe('Interactive file system', () => { it('should perform a read-then-write sequence', async () => { const fileName = 'version.txt'; - await rig.setup('interactive-read-then-write'); + await rig.setup('interactive-read-then-write', { + settings: { + security: { + auth: { + selectedType: 'gemini-api-key', + }, + disableYoloMode: false, + }, + }, + }); rig.createFile(fileName, '1.0.0'); const run = await rig.runInteractive(); diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 5490afb678..b935d4a696 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -1112,6 +1112,23 @@ describe('Approval mode tool exclusion logic', () => { expect(excludedTools).not.toContain(WRITE_FILE_TOOL_NAME); // Should be allowed in auto_edit }); + it('should throw an error if YOLO mode is attempted when disableYoloMode is true', async () => { + process.argv = ['node', 'script.js', '--yolo']; + const argv = await parseArguments({} as Settings); + const settings: Settings = { + security: { + disableYoloMode: true, + }, + }; + const extensions: GeminiCLIExtension[] = []; + + await expect( + loadCliConfig(settings, extensions, 'test-session', argv), + ).rejects.toThrow( + 'Cannot start in YOLO mode when it is disabled by settings', + ); + }); + 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 } = { diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 1bb1a3b70d..96ab5c04d1 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -442,6 +442,21 @@ export async function loadCliConfig( argv.yolo || false ? ApprovalMode.YOLO : ApprovalMode.DEFAULT; } + // Override approval mode if disableYoloMode is set. + if (settings.security?.disableYoloMode) { + if (approvalMode === ApprovalMode.YOLO) { + debugLogger.error('YOLO mode is disabled by the "disableYolo" setting.'); + throw new FatalConfigError( + 'Cannot start in YOLO mode when it is disabled by settings', + ); + } + approvalMode = ApprovalMode.DEFAULT; + } else if (approvalMode === ApprovalMode.YOLO) { + debugLogger.warn( + 'YOLO mode is enabled. All tool calls will be automatically approved.', + ); + } + // Force approval mode to default if the folder is not trusted. if (!trustedFolder && approvalMode !== ApprovalMode.DEFAULT) { debugLogger.warn( @@ -583,6 +598,7 @@ export async function loadCliConfig( geminiMdFileCount: fileCount, geminiMdFilePaths: filePaths, approvalMode, + disableYoloMode: settings.security?.disableYoloMode, showMemoryUsage: settings.ui?.showMemoryUsage || false, accessibility: { ...settings.ui?.accessibility, diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 672f42bd5b..c3b7cf748e 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -609,6 +609,40 @@ describe('Settings Loading and Merging', () => { expect(settings.merged.security?.folderTrust?.enabled).toBe(true); // System setting should be used }); + it('should not allow user or workspace to override system disableYoloMode', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const userSettingsContent = { + security: { + disableYoloMode: false, + }, + }; + const workspaceSettingsContent = { + security: { + disableYoloMode: false, // This should be ignored + }, + }; + const systemSettingsContent = { + security: { + disableYoloMode: true, + }, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === getSystemSettingsPath()) + return JSON.stringify(systemSettingsContent); + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + if (p === MOCK_WORKSPACE_SETTINGS_PATH) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.merged.security?.disableYoloMode).toBe(true); // System setting should be used + }); + it('should handle contextFileName correctly when only in user settings', () => { (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => p === USER_SETTINGS_PATH, diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index c01e691f44..2c3fc21ff4 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -937,6 +937,15 @@ const SETTINGS_SCHEMA = { description: 'Security-related settings.', showInDialog: false, properties: { + disableYoloMode: { + type: 'boolean', + label: 'Disable YOLO Mode', + category: 'Security', + requiresRestart: true, + default: false, + description: 'Disable YOLO mode, even if enabled by a flag.', + showInDialog: true, + }, folderTrust: { type: 'object', label: 'Folder Trust', diff --git a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts index 8ad0e7546f..2e103ca234 100644 --- a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts +++ b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts @@ -37,6 +37,7 @@ vi.mock('@google/gemini-cli-core', async () => { interface MockConfigInstanceShape { getApprovalMode: Mock<() => ApprovalMode>; setApprovalMode: Mock<(value: ApprovalMode) => void>; + isYoloModeDisabled: Mock<() => boolean>; isTrustedFolder: Mock<() => boolean>; getCoreTools: Mock<() => string[]>; getToolDiscoveryCommand: Mock<() => string | undefined>; @@ -76,6 +77,7 @@ describe('useAutoAcceptIndicator', () => { setApprovalMode: instanceSetApprovalModeMock as Mock< (value: ApprovalMode) => void >, + isYoloModeDisabled: vi.fn().mockReturnValue(false), isTrustedFolder: vi.fn().mockReturnValue(true) as Mock<() => boolean>, getCoreTools: vi.fn().mockReturnValue([]) as Mock<() => string[]>, getToolDiscoveryCommand: vi.fn().mockReturnValue(undefined) as Mock< @@ -471,6 +473,45 @@ describe('useAutoAcceptIndicator', () => { }); }); + describe('when YOLO mode is disabled by settings', () => { + beforeEach(() => { + // Ensure isYoloModeDisabled returns true for these tests + if (mockConfigInstance && mockConfigInstance.isYoloModeDisabled) { + mockConfigInstance.isYoloModeDisabled.mockReturnValue(true); + } + }); + + it('should not enable YOLO mode when Ctrl+Y is pressed and add an info message', () => { + mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT); + const mockAddItem = vi.fn(); + const { result } = renderHook(() => + useAutoAcceptIndicator({ + config: mockConfigInstance as unknown as ActualConfigType, + addItem: mockAddItem, + }), + ); + + expect(result.current).toBe(ApprovalMode.DEFAULT); + + act(() => { + capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key); + }); + + // setApprovalMode should not be called because the check should return early + expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled(); + // An info message should be added + expect(mockAddItem).toHaveBeenCalledWith( + { + type: MessageType.WARNING, + text: 'You cannot enter YOLO mode since it is disabled in your settings.', + }, + expect.any(Number), + ); + // The mode should not change + expect(result.current).toBe(ApprovalMode.DEFAULT); + }); + }); + it('should call onApprovalModeChange when switching to YOLO mode', () => { mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT); diff --git a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts index ae749a4648..6091420abd 100644 --- a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts +++ b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts @@ -34,6 +34,21 @@ export function useAutoAcceptIndicator({ let nextApprovalMode: ApprovalMode | undefined; if (key.ctrl && key.name === 'y') { + if ( + config.isYoloModeDisabled() && + config.getApprovalMode() !== ApprovalMode.YOLO + ) { + if (addItem) { + addItem( + { + type: MessageType.WARNING, + text: 'You cannot enter YOLO mode since it is disabled in your settings.', + }, + Date.now(), + ); + } + return; + } nextApprovalMode = config.getApprovalMode() === ApprovalMode.YOLO ? ApprovalMode.DEFAULT diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 0546f7cba6..d1549c5355 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -995,6 +995,40 @@ describe('setApprovalMode with folder trust', () => { }); }); +describe('isYoloModeDisabled', () => { + const baseParams: ConfigParameters = { + sessionId: 'test', + targetDir: '.', + debugMode: false, + model: 'test-model', + cwd: '.', + }; + + it('should return false when yolo mode is not disabled and folder is trusted', () => { + const config = new Config(baseParams); + vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true); + expect(config.isYoloModeDisabled()).toBe(false); + }); + + it('should return true when yolo mode is disabled by parameter', () => { + const config = new Config({ ...baseParams, disableYoloMode: true }); + vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true); + expect(config.isYoloModeDisabled()).toBe(true); + }); + + it('should return true when folder is untrusted', () => { + const config = new Config(baseParams); + vi.spyOn(config, 'isTrustedFolder').mockReturnValue(false); + expect(config.isYoloModeDisabled()).toBe(true); + }); + + it('should return true when yolo is disabled and folder is untrusted', () => { + const config = new Config({ ...baseParams, disableYoloMode: true }); + vi.spyOn(config, 'isTrustedFolder').mockReturnValue(false); + expect(config.isYoloModeDisabled()).toBe(true); + }); +}); + describe('BaseLlmClient Lifecycle', () => { const MODEL = 'gemini-pro'; const SANDBOX: SandboxConfig = { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index baa8bc6175..e6e859ee00 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -284,6 +284,7 @@ export interface ConfigParameters { retryFetchErrors?: boolean; enableShellOutputEfficiency?: boolean; ptyInfo?: string; + disableYoloMode?: boolean; } export class Config { @@ -380,6 +381,7 @@ export class Config { private readonly continueOnFailedApiCall: boolean; private readonly retryFetchErrors: boolean; private readonly enableShellOutputEfficiency: boolean; + private readonly disableYoloMode: boolean; constructor(params: ConfigParameters) { this.sessionId = params.sessionId; @@ -496,6 +498,7 @@ export class Config { format: params.output?.format ?? OutputFormat.TEXT, }; this.retryFetchErrors = params.retryFetchErrors ?? false; + this.disableYoloMode = params.disableYoloMode ?? false; if (params.contextFileName) { setGeminiMdFilename(params.contextFileName); @@ -761,6 +764,10 @@ export class Config { this.approvalMode = mode; } + isYoloModeDisabled(): boolean { + return this.disableYoloMode || !this.isTrustedFolder(); + } + getShowMemoryUsage(): boolean { return this.showMemoryUsage; }