diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index cebe5047ad..767630e773 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -763,7 +763,7 @@ their corresponding top-level category object in your `settings.json` file. #### `tools` -- **`tools.sandbox`** (boolean | string): +- **`tools.sandbox`** (string): - **Description:** Sandbox execution environment. Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile, or specify an explicit sandbox command (e.g., "docker", "podman", "lxc"). diff --git a/packages/cli/src/config/sandboxConfig.test.ts b/packages/cli/src/config/sandboxConfig.test.ts index 51c4f7d83c..cfe1fed660 100644 --- a/packages/cli/src/config/sandboxConfig.test.ts +++ b/packages/cli/src/config/sandboxConfig.test.ts @@ -90,7 +90,13 @@ describe('loadSandboxConfig', () => { process.env['GEMINI_SANDBOX'] = 'docker'; mockedCommandExistsSync.mockReturnValue(true); const config = await loadSandboxConfig({}, {}); - expect(config).toEqual({ command: 'docker', image: 'default/image' }); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'docker', + image: 'default/image', + }); expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker'); }); @@ -113,7 +119,13 @@ describe('loadSandboxConfig', () => { process.env['GEMINI_SANDBOX'] = 'lxc'; mockedCommandExistsSync.mockReturnValue(true); const config = await loadSandboxConfig({}, {}); - expect(config).toEqual({ command: 'lxc', image: 'default/image' }); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'lxc', + image: 'default/image', + }); expect(mockedCommandExistsSync).toHaveBeenCalledWith('lxc'); }); @@ -134,6 +146,9 @@ describe('loadSandboxConfig', () => { ); const config = await loadSandboxConfig({}, { sandbox: true }); expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, command: 'sandbox-exec', image: 'default/image', }); @@ -144,6 +159,9 @@ describe('loadSandboxConfig', () => { mockedCommandExistsSync.mockReturnValue(true); // all commands exist const config = await loadSandboxConfig({}, { sandbox: true }); expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, command: 'sandbox-exec', image: 'default/image', }); @@ -153,14 +171,26 @@ describe('loadSandboxConfig', () => { mockedOsPlatform.mockReturnValue('linux'); mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'docker'); const config = await loadSandboxConfig({ tools: { sandbox: true } }, {}); - expect(config).toEqual({ command: 'docker', image: 'default/image' }); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'docker', + image: 'default/image', + }); }); it('should use podman if available and docker is not', async () => { mockedOsPlatform.mockReturnValue('linux'); mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'podman'); const config = await loadSandboxConfig({}, { sandbox: true }); - expect(config).toEqual({ command: 'podman', image: 'default/image' }); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'podman', + image: 'default/image', + }); }); it('should throw if sandbox: true but no command is found', async () => { @@ -177,7 +207,13 @@ describe('loadSandboxConfig', () => { it('should use the specified command if it exists', async () => { mockedCommandExistsSync.mockReturnValue(true); const config = await loadSandboxConfig({}, { sandbox: 'podman' }); - expect(config).toEqual({ command: 'podman', image: 'default/image' }); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'podman', + image: 'default/image', + }); expect(mockedCommandExistsSync).toHaveBeenCalledWith('podman'); }); @@ -205,14 +241,26 @@ describe('loadSandboxConfig', () => { process.env['GEMINI_SANDBOX'] = 'docker'; mockedCommandExistsSync.mockReturnValue(true); const config = await loadSandboxConfig({}, {}); - expect(config).toEqual({ command: 'docker', image: 'env/image' }); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'docker', + image: 'env/image', + }); }); it('should use image from package.json if env var is not set', async () => { process.env['GEMINI_SANDBOX'] = 'docker'; mockedCommandExistsSync.mockReturnValue(true); const config = await loadSandboxConfig({}, {}); - expect(config).toEqual({ command: 'docker', image: 'default/image' }); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'docker', + image: 'default/image', + }); }); it('should return undefined if command is found but no image is configured', async () => { @@ -234,20 +282,115 @@ describe('loadSandboxConfig', () => { 'should enable sandbox for value: %s', async (value) => { const config = await loadSandboxConfig({}, { sandbox: value }); - expect(config).toEqual({ command: 'docker', image: 'default/image' }); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'docker', + image: 'default/image', + }); }, ); it.each([false, 'false', '0', undefined, null, ''])( 'should disable sandbox for value: %s', async (value) => { - // \`null\` is not a valid type for the arg, but good to test falsiness + // `null` is not a valid type for the arg, but good to test falsiness const config = await loadSandboxConfig({}, { sandbox: value }); expect(config).toBeUndefined(); }, ); }); + describe('with SandboxConfig object in settings', () => { + beforeEach(() => { + mockedOsPlatform.mockReturnValue('linux'); + mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'docker'); + }); + + it('should support object structure with enabled: true', async () => { + const config = await loadSandboxConfig( + { + tools: { + sandbox: { + enabled: true, + allowedPaths: ['/tmp'], + networkAccess: true, + }, + }, + }, + {}, + ); + expect(config).toEqual({ + enabled: true, + allowedPaths: ['/tmp'], + networkAccess: true, + command: 'docker', + image: 'default/image', + }); + }); + + it('should support object structure with explicit command', async () => { + mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'podman'); + const config = await loadSandboxConfig( + { + tools: { + sandbox: { + enabled: true, + command: 'podman', + }, + }, + }, + {}, + ); + expect(config?.command).toBe('podman'); + }); + + it('should support object structure with custom image', async () => { + const config = await loadSandboxConfig( + { + tools: { + sandbox: { + enabled: true, + image: 'custom/image', + }, + }, + }, + {}, + ); + expect(config?.image).toBe('custom/image'); + }); + + it('should return undefined if enabled is false in object', async () => { + const config = await loadSandboxConfig( + { + tools: { + sandbox: { + enabled: false, + }, + }, + }, + {}, + ); + expect(config).toBeUndefined(); + }); + + it('should prioritize CLI flag over settings object', async () => { + const config = await loadSandboxConfig( + { + tools: { + sandbox: { + enabled: true, + allowedPaths: ['/settings-path'], + }, + }, + }, + { sandbox: false }, + ); + expect(config).toBeUndefined(); + }); + }); + describe('with sandbox: runsc (gVisor)', () => { beforeEach(() => { mockedOsPlatform.mockReturnValue('linux'); @@ -257,7 +400,13 @@ describe('loadSandboxConfig', () => { it('should use runsc via CLI argument on Linux', async () => { const config = await loadSandboxConfig({}, { sandbox: 'runsc' }); - expect(config).toEqual({ command: 'runsc', image: 'default/image' }); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'runsc', + image: 'default/image', + }); expect(mockedCommandExistsSync).toHaveBeenCalledWith('runsc'); expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker'); }); @@ -266,7 +415,13 @@ describe('loadSandboxConfig', () => { process.env['GEMINI_SANDBOX'] = 'runsc'; const config = await loadSandboxConfig({}, {}); - expect(config).toEqual({ command: 'runsc', image: 'default/image' }); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'runsc', + image: 'default/image', + }); expect(mockedCommandExistsSync).toHaveBeenCalledWith('runsc'); expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker'); }); @@ -277,7 +432,13 @@ describe('loadSandboxConfig', () => { {}, ); - expect(config).toEqual({ command: 'runsc', image: 'default/image' }); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'runsc', + image: 'default/image', + }); expect(mockedCommandExistsSync).toHaveBeenCalledWith('runsc'); expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker'); }); @@ -289,7 +450,13 @@ describe('loadSandboxConfig', () => { { sandbox: 'podman' }, ); - expect(config).toEqual({ command: 'runsc', image: 'default/image' }); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'runsc', + image: 'default/image', + }); }); it('should reject runsc on macOS (Linux-only)', async () => { diff --git a/packages/cli/src/config/sandboxConfig.ts b/packages/cli/src/config/sandboxConfig.ts index 968d3e427a..cce5033f1a 100644 --- a/packages/cli/src/config/sandboxConfig.ts +++ b/packages/cli/src/config/sandboxConfig.ts @@ -23,7 +23,7 @@ const __dirname = path.dirname(__filename); interface SandboxCliArgs { sandbox?: boolean | string | null; } -const VALID_SANDBOX_COMMANDS: ReadonlyArray = [ +const VALID_SANDBOX_COMMANDS = [ 'docker', 'podman', 'sandbox-exec', @@ -31,8 +31,10 @@ const VALID_SANDBOX_COMMANDS: ReadonlyArray = [ 'lxc', ]; -function isSandboxCommand(value: string): value is SandboxConfig['command'] { - return (VALID_SANDBOX_COMMANDS as readonly string[]).includes(value); +function isSandboxCommand( + value: string, +): value is Exclude { + return VALID_SANDBOX_COMMANDS.includes(value); } function getSandboxCommand( @@ -116,13 +118,36 @@ export async function loadSandboxConfig( argv: SandboxCliArgs, ): Promise { const sandboxOption = argv.sandbox ?? settings.tools?.sandbox; - const command = getSandboxCommand(sandboxOption); + + let sandboxValue: boolean | string | null | undefined; + let allowedPaths: string[] = []; + let networkAccess = false; + let customImage: string | undefined; + + if ( + typeof sandboxOption === 'object' && + sandboxOption !== null && + !Array.isArray(sandboxOption) + ) { + const config = sandboxOption; + sandboxValue = config.enabled ? (config.command ?? true) : false; + allowedPaths = config.allowedPaths ?? []; + networkAccess = config.networkAccess ?? false; + customImage = config.image; + } else if (typeof sandboxOption !== 'object' || sandboxOption === null) { + sandboxValue = sandboxOption; + } + + const command = getSandboxCommand(sandboxValue); const packageJson = await getPackageJson(__dirname); const image = process.env['GEMINI_SANDBOX_IMAGE'] ?? process.env['GEMINI_SANDBOX_IMAGE_DEFAULT'] ?? + customImage ?? packageJson?.config?.sandboxImageUri; - return command && image ? { command, image } : undefined; + return command && image + ? { enabled: true, allowedPaths, networkAccess, command, image } + : undefined; } diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 007274dafc..45a6bff0cc 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -18,6 +18,7 @@ import { type AuthType, type AgentOverride, type CustomTheme, + type SandboxConfig, } from '@google/gemini-cli-core'; import type { SessionRetentionSettings } from './settings.js'; import { DEFAULT_MIN_RETENTION } from '../utils/sessionCleanup.js'; @@ -1263,8 +1264,8 @@ const SETTINGS_SCHEMA = { label: 'Sandbox', category: 'Tools', requiresRestart: true, - default: undefined as boolean | string | undefined, - ref: 'BooleanOrString', + default: undefined as boolean | string | SandboxConfig | undefined, + ref: 'BooleanOrStringOrObject', description: oneLine` Sandbox execution environment. Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile, @@ -2618,9 +2619,44 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record< description: 'Accepts either a single string or an array of strings.', anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }], }, - BooleanOrString: { - description: 'Accepts either a boolean flag or a string command name.', - anyOf: [{ type: 'boolean' }, { type: 'string' }], + BooleanOrStringOrObject: { + description: + 'Accepts either a boolean flag, a string command name, or a configuration object.', + anyOf: [ + { type: 'boolean' }, + { type: 'string' }, + { + type: 'object', + description: 'Sandbox configuration object.', + additionalProperties: false, + properties: { + enabled: { + type: 'boolean', + description: 'Enables or disables the sandbox.', + }, + command: { + type: 'string', + description: + 'The sandbox command to use (docker, podman, sandbox-exec, runsc, lxc).', + enum: ['docker', 'podman', 'sandbox-exec', 'runsc', 'lxc'], + }, + image: { + type: 'string', + description: 'The sandbox image to use.', + }, + allowedPaths: { + type: 'array', + description: + 'A list of absolute host paths that should be accessible within the sandbox.', + items: { type: 'string' }, + }, + networkAccess: { + type: 'boolean', + description: 'Whether the sandbox should have internet access.', + }, + }, + }, + ], }, HookDefinitionArray: { type: 'array', diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 02cdb679ec..31fec36db0 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -27,6 +27,7 @@ import { type CliArgs, } from './config/config.js'; import { loadSandboxConfig } from './config/sandboxConfig.js'; +import { createMockSandboxConfig } from '@google/gemini-cli-test-utils'; import { terminalCapabilityManager } from './ui/utils/terminalCapabilityManager.js'; import { start_sandbox } from './utils/sandbox.js'; import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; @@ -192,12 +193,19 @@ vi.mock('./ui/utils/terminalCapabilityManager.js', () => ({ vi.mock('./config/config.js', () => ({ loadCliConfig: vi.fn().mockImplementation(async () => createMockConfig()), - parseArguments: vi.fn().mockResolvedValue({}), + parseArguments: vi.fn().mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, + }), isDebugMode: vi.fn(() => false), })); vi.mock('read-package-up', () => ({ readPackageUp: vi.fn().mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, packageJson: { name: 'test-pkg', version: 'test-version' }, path: '/fake/path/package.json', }), @@ -235,6 +243,9 @@ vi.mock('./utils/relaunch.js', () => ({ vi.mock('./config/sandboxConfig.js', () => ({ loadSandboxConfig: vi.fn().mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, command: 'docker', image: 'test-image', }), @@ -540,6 +551,9 @@ describe('gemini.tsx main function kitty protocol', () => { ); vi.mocked(parseArguments).mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, promptInteractive: false, } as any); // eslint-disable-line @typescript-eslint/no-explicit-any @@ -603,6 +617,9 @@ describe('gemini.tsx main function kitty protocol', () => { }); vi.mocked(parseArguments).mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, promptInteractive: false, } as any); // eslint-disable-line @typescript-eslint/no-explicit-any @@ -622,14 +639,17 @@ describe('gemini.tsx main function kitty protocol', () => { const mockConfig = createMockConfig({ isInteractive: () => false, getQuestion: () => '', - getSandbox: () => ({ command: 'docker', image: 'test-image' }), + getSandbox: () => + createMockSandboxConfig({ command: 'docker', image: 'test-image' }), }); vi.mocked(loadCliConfig).mockResolvedValue(mockConfig); - vi.mocked(loadSandboxConfig).mockResolvedValue({ - command: 'docker', - image: 'test-image', - }); + vi.mocked(loadSandboxConfig).mockResolvedValue( + createMockSandboxConfig({ + command: 'docker', + image: 'test-image', + }), + ); process.env['GEMINI_API_KEY'] = 'test-key'; try { @@ -670,6 +690,9 @@ describe('gemini.tsx main function kitty protocol', () => { ); vi.mocked(parseArguments).mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, promptInteractive: false, } as any); // eslint-disable-line @typescript-eslint/no-explicit-any vi.mocked(loadCliConfig).mockResolvedValue( @@ -725,6 +748,9 @@ describe('gemini.tsx main function kitty protocol', () => { ); vi.mocked(parseArguments).mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, promptInteractive: false, resume: 'session-id', } as any); // eslint-disable-line @typescript-eslint/no-explicit-any @@ -781,6 +807,9 @@ describe('gemini.tsx main function kitty protocol', () => { ); vi.mocked(parseArguments).mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, promptInteractive: false, resume: 'latest', } as unknown as CliArgs); @@ -831,6 +860,9 @@ describe('gemini.tsx main function kitty protocol', () => { ); vi.mocked(parseArguments).mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, promptInteractive: false, } as any); // eslint-disable-line @typescript-eslint/no-explicit-any vi.mocked(loadCliConfig).mockResolvedValue( @@ -881,6 +913,9 @@ describe('gemini.tsx main function kitty protocol', () => { ); vi.mocked(parseArguments).mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, promptInteractive: false, } as any); // eslint-disable-line @typescript-eslint/no-explicit-any vi.mocked(loadCliConfig).mockResolvedValue( @@ -955,6 +990,9 @@ describe('gemini.tsx main function exit codes', () => { }), ); vi.mocked(parseArguments).mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, promptInteractive: true, } as unknown as CliArgs); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -971,10 +1009,12 @@ describe('gemini.tsx main function exit codes', () => { it('should exit with 41 for auth failure during sandbox setup', async () => { vi.stubEnv('SANDBOX', ''); - vi.mocked(loadSandboxConfig).mockResolvedValue({ - command: 'docker', - image: 'test-image', - }); + vi.mocked(loadSandboxConfig).mockResolvedValue( + createMockSandboxConfig({ + command: 'docker', + image: 'test-image', + }), + ); vi.mocked(loadCliConfig).mockResolvedValue( createMockConfig({ refreshAuth: vi.fn().mockRejectedValue(new Error('Auth failed')), @@ -1014,6 +1054,9 @@ describe('gemini.tsx main function exit codes', () => { }), ); vi.mocked(parseArguments).mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, resume: 'invalid-session', } as unknown as CliArgs); @@ -1055,7 +1098,11 @@ describe('gemini.tsx main function exit codes', () => { merged: { security: { auth: {} }, ui: {} }, }), ); - vi.mocked(parseArguments).mockResolvedValue({} as unknown as CliArgs); + vi.mocked(parseArguments).mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, + } as unknown as CliArgs); // eslint-disable-next-line @typescript-eslint/no-explicit-any (process.stdin as any).isTTY = true; @@ -1090,7 +1137,11 @@ describe('gemini.tsx main function exit codes', () => { merged: { security: { auth: { selectedType: undefined } }, ui: {} }, }), ); - vi.mocked(parseArguments).mockResolvedValue({} as unknown as CliArgs); + vi.mocked(parseArguments).mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, + } as unknown as CliArgs); runNonInteractiveSpy.mockImplementation(() => Promise.resolve()); @@ -1160,7 +1211,12 @@ describe('project hooks loading based on trust', () => { const configModule = await import('./config/config.js'); loadCliConfig = vi.mocked(configModule.loadCliConfig); parseArguments = vi.mocked(configModule.parseArguments); - parseArguments.mockResolvedValue({ startupMessages: [] }); + parseArguments.mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, + startupMessages: [], + }); const settingsModule = await import('./config/settings.js'); loadSettings = vi.mocked(settingsModule.loadSettings); diff --git a/packages/cli/src/utils/sandbox.test.ts b/packages/cli/src/utils/sandbox.test.ts index fa562f7ad6..ef972a4a0b 100644 --- a/packages/cli/src/utils/sandbox.test.ts +++ b/packages/cli/src/utils/sandbox.test.ts @@ -10,6 +10,7 @@ import os from 'node:os'; import fs from 'node:fs'; import { start_sandbox } from './sandbox.js'; import { FatalSandboxError, type SandboxConfig } from '@google/gemini-cli-core'; +import { createMockSandboxConfig } from '@google/gemini-cli-test-utils'; import { EventEmitter } from 'node:events'; const { mockedHomedir, mockedGetContainerPath } = vi.hoisted(() => ({ @@ -137,10 +138,10 @@ describe('sandbox', () => { describe('start_sandbox', () => { it('should handle macOS seatbelt (sandbox-exec)', async () => { vi.mocked(os.platform).mockReturnValue('darwin'); - const config: SandboxConfig = { + const config: SandboxConfig = createMockSandboxConfig({ command: 'sandbox-exec', image: 'some-image', - }; + }); interface MockProcess extends EventEmitter { stdout: EventEmitter; @@ -173,19 +174,19 @@ describe('sandbox', () => { it('should throw FatalSandboxError if seatbelt profile is missing', async () => { vi.mocked(os.platform).mockReturnValue('darwin'); vi.mocked(fs.existsSync).mockReturnValue(false); - const config: SandboxConfig = { + const config: SandboxConfig = createMockSandboxConfig({ command: 'sandbox-exec', image: 'some-image', - }; + }); await expect(start_sandbox(config)).rejects.toThrow(FatalSandboxError); }); it('should handle Docker execution', async () => { - const config: SandboxConfig = { + const config: SandboxConfig = createMockSandboxConfig({ command: 'docker', image: 'gemini-cli-sandbox', - }; + }); // Mock image check to return true (image exists) interface MockProcessWithStdout extends EventEmitter { @@ -231,10 +232,10 @@ describe('sandbox', () => { }); it('should pull image if missing', async () => { - const config: SandboxConfig = { + const config: SandboxConfig = createMockSandboxConfig({ command: 'docker', image: 'missing-image', - }; + }); // 1. Image check fails interface MockProcessWithStdout extends EventEmitter { @@ -300,10 +301,10 @@ describe('sandbox', () => { }); it('should throw if image pull fails', async () => { - const config: SandboxConfig = { + const config: SandboxConfig = createMockSandboxConfig({ command: 'docker', image: 'missing-image', - }; + }); // 1. Image check fails interface MockProcessWithStdout extends EventEmitter { @@ -338,10 +339,10 @@ describe('sandbox', () => { }); it('should mount volumes correctly', async () => { - const config: SandboxConfig = { + const config: SandboxConfig = createMockSandboxConfig({ command: 'docker', image: 'gemini-cli-sandbox', - }; + }); process.env['SANDBOX_MOUNTS'] = '/host/path:/container/path:ro'; vi.mocked(fs.existsSync).mockReturnValue(true); // For mount path check @@ -394,11 +395,130 @@ describe('sandbox', () => { ); }); - it('should pass through GOOGLE_GEMINI_BASE_URL and GOOGLE_VERTEX_BASE_URL', async () => { - const config: SandboxConfig = { + it('should handle allowedPaths in Docker', async () => { + const config: SandboxConfig = createMockSandboxConfig({ command: 'docker', image: 'gemini-cli-sandbox', - }; + allowedPaths: ['/extra/path'], + }); + vi.mocked(fs.existsSync).mockReturnValue(true); + + // Mock image check to return true + interface MockProcessWithStdout extends EventEmitter { + stdout: EventEmitter; + } + const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout; + mockImageCheckProcess.stdout = new EventEmitter(); + vi.mocked(spawn).mockImplementationOnce(() => { + setTimeout(() => { + mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id')); + mockImageCheckProcess.emit('close', 0); + }, 1); + return mockImageCheckProcess as unknown as ReturnType; + }); + + const mockSpawnProcess = new EventEmitter() as unknown as ReturnType< + typeof spawn + >; + mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => { + if (event === 'close') { + setTimeout(() => cb(0), 10); + } + return mockSpawnProcess; + }); + vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess); + + await start_sandbox(config); + + expect(spawn).toHaveBeenCalledWith( + 'docker', + expect.arrayContaining(['--volume', '/extra/path:/extra/path:ro']), + expect.any(Object), + ); + }); + + it('should handle networkAccess: false in Docker', async () => { + const config: SandboxConfig = createMockSandboxConfig({ + command: 'docker', + image: 'gemini-cli-sandbox', + networkAccess: false, + }); + + // Mock image check + interface MockProcessWithStdout extends EventEmitter { + stdout: EventEmitter; + } + const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout; + mockImageCheckProcess.stdout = new EventEmitter(); + vi.mocked(spawn).mockImplementationOnce(() => { + setTimeout(() => { + mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id')); + mockImageCheckProcess.emit('close', 0); + }, 1); + return mockImageCheckProcess as unknown as ReturnType; + }); + + const mockSpawnProcess = new EventEmitter() as unknown as ReturnType< + typeof spawn + >; + mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => { + if (event === 'close') { + setTimeout(() => cb(0), 10); + } + return mockSpawnProcess; + }); + vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess); + + await start_sandbox(config); + + expect(execSync).toHaveBeenCalledWith( + expect.stringContaining('network create --internal gemini-cli-sandbox'), + expect.any(Object), + ); + expect(spawn).toHaveBeenCalledWith( + 'docker', + expect.arrayContaining(['--network', 'gemini-cli-sandbox']), + expect.any(Object), + ); + }); + + it('should handle allowedPaths in macOS seatbelt', async () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + const config: SandboxConfig = createMockSandboxConfig({ + command: 'sandbox-exec', + image: 'some-image', + allowedPaths: ['/Users/user/extra'], + }); + vi.mocked(fs.existsSync).mockReturnValue(true); + + interface MockProcess extends EventEmitter { + stdout: EventEmitter; + stderr: EventEmitter; + } + const mockSpawnProcess = new EventEmitter() as MockProcess; + mockSpawnProcess.stdout = new EventEmitter(); + mockSpawnProcess.stderr = new EventEmitter(); + vi.mocked(spawn).mockReturnValue( + mockSpawnProcess as unknown as ReturnType, + ); + + const promise = start_sandbox(config); + setTimeout(() => mockSpawnProcess.emit('close', 0), 10); + await promise; + + // Check that the extra path is passed as an INCLUDE_DIR_X argument + expect(spawn).toHaveBeenCalledWith( + 'sandbox-exec', + expect.arrayContaining(['INCLUDE_DIR_0=/Users/user/extra']), + expect.any(Object), + ); + }); + + it('should pass through GOOGLE_GEMINI_BASE_URL and GOOGLE_VERTEX_BASE_URL', async () => { + const config: SandboxConfig = createMockSandboxConfig({ + command: 'docker', + image: 'gemini-cli-sandbox', + }); process.env['GOOGLE_GEMINI_BASE_URL'] = 'http://gemini.proxy'; process.env['GOOGLE_VERTEX_BASE_URL'] = 'http://vertex.proxy'; @@ -442,10 +562,10 @@ describe('sandbox', () => { }); it('should handle user creation on Linux if needed', async () => { - const config: SandboxConfig = { + const config: SandboxConfig = createMockSandboxConfig({ command: 'docker', image: 'gemini-cli-sandbox', - }; + }); process.env['SANDBOX_SET_UID_GID'] = 'true'; vi.mocked(os.platform).mockReturnValue('linux'); vi.mocked(execSync).mockImplementation((cmd) => { @@ -508,10 +628,10 @@ describe('sandbox', () => { it('should run lxc exec with correct args for a running container', async () => { process.env['TEST_LXC_LIST_OUTPUT'] = LXC_RUNNING; - const config: SandboxConfig = { + const config: SandboxConfig = createMockSandboxConfig({ command: 'lxc', image: 'gemini-sandbox', - }; + }); const mockSpawnProcess = new EventEmitter() as unknown as ReturnType< typeof spawn @@ -542,10 +662,10 @@ describe('sandbox', () => { it('should throw FatalSandboxError if lxc list fails', async () => { process.env['TEST_LXC_LIST_OUTPUT'] = 'throw'; - const config: SandboxConfig = { + const config: SandboxConfig = createMockSandboxConfig({ command: 'lxc', image: 'gemini-sandbox', - }; + }); await expect(start_sandbox(config)).rejects.toThrow( /Failed to query LXC container/, @@ -554,20 +674,20 @@ describe('sandbox', () => { it('should throw FatalSandboxError if container is not running', async () => { process.env['TEST_LXC_LIST_OUTPUT'] = LXC_STOPPED; - const config: SandboxConfig = { + const config: SandboxConfig = createMockSandboxConfig({ command: 'lxc', image: 'gemini-sandbox', - }; + }); await expect(start_sandbox(config)).rejects.toThrow(/is not running/); }); it('should throw FatalSandboxError if container is not found in list', async () => { process.env['TEST_LXC_LIST_OUTPUT'] = '[]'; - const config: SandboxConfig = { + const config: SandboxConfig = createMockSandboxConfig({ command: 'lxc', image: 'gemini-sandbox', - }; + }); await expect(start_sandbox(config)).rejects.toThrow(/not found/); }); @@ -577,10 +697,10 @@ describe('sandbox', () => { describe('gVisor (runsc)', () => { it('should use docker with --runtime=runsc on Linux', async () => { vi.mocked(os.platform).mockReturnValue('linux'); - const config: SandboxConfig = { + const config: SandboxConfig = createMockSandboxConfig({ command: 'runsc', image: 'gemini-cli-sandbox', - }; + }); // Mock image check interface MockProcessWithStdout extends EventEmitter { diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index df9a88cc4c..dbd2ec64e3 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -7,9 +7,9 @@ import { exec, execFile, - execFileSync, execSync, spawn, + spawnSync, type ChildProcess, } from 'node:child_process'; import path from 'node:path'; @@ -114,6 +114,22 @@ export async function start_sandbox( } } + // Add custom allowed paths from config + if (config.allowedPaths) { + for (const hostPath of config.allowedPaths) { + if ( + hostPath && + path.isAbsolute(hostPath) && + fs.existsSync(hostPath) + ) { + const realDir = fs.realpathSync(hostPath); + if (!includedDirs.includes(realDir) && realDir !== targetDir) { + includedDirs.push(realDir); + } + } + } + } + for (let i = 0; i < MAX_INCLUDE_DIRS; i++) { let dirPath = '/dev/null'; // Default to a safe path that won't cause issues @@ -217,6 +233,7 @@ export async function start_sandbox( // runsc uses docker with --runtime=runsc const command = config.command === 'runsc' ? 'docker' : config.command; + if (!command) throw new FatalSandboxError('Sandbox command is required'); debugLogger.log(`hopping into sandbox (command: ${command}) ...`); @@ -230,6 +247,9 @@ export async function start_sandbox( const isCustomProjectSandbox = fs.existsSync(projectSandboxDockerfile); const image = config.image; + if (!image) throw new FatalSandboxError('Sandbox image is required'); + if (!/^[a-zA-Z0-9_.:/-]+$/.test(image)) + throw new FatalSandboxError('Invalid sandbox image name'); const workdir = path.resolve(process.cwd()); const containerWorkdir = getContainerPath(workdir); @@ -392,6 +412,19 @@ export async function start_sandbox( } } + // mount paths listed in config.allowedPaths + if (config.allowedPaths) { + for (const hostPath of config.allowedPaths) { + if (hostPath && path.isAbsolute(hostPath) && fs.existsSync(hostPath)) { + const containerPath = getContainerPath(hostPath); + debugLogger.log( + `Config allowedPath: ${hostPath} -> ${containerPath} (ro)`, + ); + args.push('--volume', `${hostPath}:${containerPath}:ro`); + } + } + } + // expose env-specified ports on the sandbox ports().forEach((p) => args.push('--publish', `${p}:${p}`)); @@ -425,21 +458,27 @@ export async function start_sandbox( args.push('--env', `NO_PROXY=${noProxy}`); args.push('--env', `no_proxy=${noProxy}`); } + } - // if using proxy, switch to internal networking through proxy - if (proxy) { - execSync( - `${command} network inspect ${SANDBOX_NETWORK_NAME} || ${command} network create --internal ${SANDBOX_NETWORK_NAME}`, - ); - args.push('--network', SANDBOX_NETWORK_NAME); + // handle network access and proxy configuration + if (!config.networkAccess || proxyCommand) { + const isInternal = !config.networkAccess || !!proxyCommand; + const networkFlags = isInternal ? '--internal' : ''; + + execSync( + `${command} network inspect ${SANDBOX_NETWORK_NAME} || ${command} network create ${networkFlags} ${SANDBOX_NETWORK_NAME}`, + { stdio: 'ignore' }, + ); + args.push('--network', SANDBOX_NETWORK_NAME); + + if (proxyCommand) { // if proxy command is set, create a separate network w/ host access (i.e. non-internal) // we will run proxy in its own container connected to both host network and internal network // this allows proxy to work even on rootless podman on macos with host<->vm<->container isolation - if (proxyCommand) { - execSync( - `${command} network inspect ${SANDBOX_PROXY_NAME} || ${command} network create ${SANDBOX_PROXY_NAME}`, - ); - } + execSync( + `${command} network inspect ${SANDBOX_PROXY_NAME} || ${command} network create ${SANDBOX_PROXY_NAME}`, + { stdio: 'ignore' }, + ); } } @@ -833,136 +872,180 @@ async function start_lxc_sandbox( ); } - // Bind-mount the working directory into the container at the same path. - // Using "lxc config device add" is idempotent when the device name matches. - const deviceName = `gemini-workspace-${randomBytes(4).toString('hex')}`; + const devicesToRemove: string[] = []; + const removeDevices = () => { + for (const deviceName of devicesToRemove) { + try { + spawnSync( + 'lxc', + ['config', 'device', 'remove', containerName, deviceName], + { timeout: 1000, killSignal: 'SIGKILL', stdio: 'ignore' }, + ); + } catch { + // Best-effort cleanup; ignore errors on exit. + } + } + }; + try { - await execFileAsync('lxc', [ - 'config', - 'device', - 'add', - containerName, - deviceName, - 'disk', - `source=${workdir}`, - `path=${workdir}`, - ]); - debugLogger.log( - `mounted workspace '${workdir}' into container as device '${deviceName}'`, - ); - } catch (err) { - throw new FatalSandboxError( - `Failed to mount workspace into LXC container '${containerName}': ${err instanceof Error ? err.message : String(err)}`, - ); - } + // Bind-mount the working directory into the container at the same path. + // Using "lxc config device add" is idempotent when the device name matches. + const workspaceDeviceName = `gemini-workspace-${randomBytes(4).toString( + 'hex', + )}`; + devicesToRemove.push(workspaceDeviceName); - // Remove the workspace device from the container when the process exits. - // Only the 'exit' event is needed — the CLI's cleanup.ts already handles - // SIGINT and SIGTERM by calling process.exit(), which fires 'exit'. - const removeDevice = () => { try { - execFileSync( - 'lxc', - ['config', 'device', 'remove', containerName, deviceName], - { timeout: 2000 }, + await execFileAsync('lxc', [ + 'config', + 'device', + 'add', + containerName, + workspaceDeviceName, + 'disk', + `source=${workdir}`, + `path=${workdir}`, + ]); + debugLogger.log( + `mounted workspace '${workdir}' into container as device '${workspaceDeviceName}'`, + ); + } catch (err) { + throw new FatalSandboxError( + `Failed to mount workspace into LXC container '${containerName}': ${err instanceof Error ? err.message : String(err)}`, ); - } catch { - // Best-effort cleanup; ignore errors on exit. } - }; - process.on('exit', removeDevice); - // Build the environment variable arguments for `lxc exec`. - const envArgs: string[] = []; - const envVarsToForward: Record = { - GEMINI_API_KEY: process.env['GEMINI_API_KEY'], - GOOGLE_API_KEY: process.env['GOOGLE_API_KEY'], - GOOGLE_GEMINI_BASE_URL: process.env['GOOGLE_GEMINI_BASE_URL'], - GOOGLE_VERTEX_BASE_URL: process.env['GOOGLE_VERTEX_BASE_URL'], - GOOGLE_GENAI_USE_VERTEXAI: process.env['GOOGLE_GENAI_USE_VERTEXAI'], - GOOGLE_GENAI_USE_GCA: process.env['GOOGLE_GENAI_USE_GCA'], - GOOGLE_CLOUD_PROJECT: process.env['GOOGLE_CLOUD_PROJECT'], - GOOGLE_CLOUD_LOCATION: process.env['GOOGLE_CLOUD_LOCATION'], - GEMINI_MODEL: process.env['GEMINI_MODEL'], - TERM: process.env['TERM'], - COLORTERM: process.env['COLORTERM'], - GEMINI_CLI_IDE_SERVER_PORT: process.env['GEMINI_CLI_IDE_SERVER_PORT'], - GEMINI_CLI_IDE_WORKSPACE_PATH: process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'], - TERM_PROGRAM: process.env['TERM_PROGRAM'], - }; - for (const [key, value] of Object.entries(envVarsToForward)) { - if (value) { - envArgs.push('--env', `${key}=${value}`); - } - } - - // Forward SANDBOX_ENV key=value pairs - if (process.env['SANDBOX_ENV']) { - for (let env of process.env['SANDBOX_ENV'].split(',')) { - if ((env = env.trim())) { - if (env.includes('=')) { - envArgs.push('--env', env); - } else { - throw new FatalSandboxError( - 'SANDBOX_ENV must be a comma-separated list of key=value pairs', - ); + // Add custom allowed paths from config + if (config.allowedPaths) { + for (const hostPath of config.allowedPaths) { + if (hostPath && path.isAbsolute(hostPath) && fs.existsSync(hostPath)) { + const allowedDeviceName = `gemini-allowed-${randomBytes(4).toString( + 'hex', + )}`; + devicesToRemove.push(allowedDeviceName); + try { + await execFileAsync('lxc', [ + 'config', + 'device', + 'add', + containerName, + allowedDeviceName, + 'disk', + `source=${hostPath}`, + `path=${hostPath}`, + 'readonly=true', + ]); + debugLogger.log( + `mounted allowed path '${hostPath}' into container as device '${allowedDeviceName}' (ro)`, + ); + } catch (err) { + debugLogger.warn( + `Failed to mount allowed path '${hostPath}' into LXC container: ${err instanceof Error ? err.message : String(err)}`, + ); + } } } } - } - // Forward NODE_OPTIONS (e.g. from --inspect flags) - const existingNodeOptions = process.env['NODE_OPTIONS'] || ''; - const allNodeOptions = [ - ...(existingNodeOptions ? [existingNodeOptions] : []), - ...nodeArgs, - ].join(' '); - if (allNodeOptions.length > 0) { - envArgs.push('--env', `NODE_OPTIONS=${allNodeOptions}`); - } + // Remove the devices from the container when the process exits. + // Only the 'exit' event is needed — the CLI's cleanup.ts already handles + // SIGINT and SIGTERM by calling process.exit(), which fires 'exit'. + process.on('exit', removeDevices); - // Mark that we're running inside an LXC sandbox. - envArgs.push('--env', `SANDBOX=${containerName}`); - - // Build the command entrypoint (same logic as Docker path). - const finalEntrypoint = entrypoint(workdir, cliArgs); - - // Build the full lxc exec command args. - const args = [ - 'exec', - containerName, - '--cwd', - workdir, - ...envArgs, - '--', - ...finalEntrypoint, - ]; - - debugLogger.log(`lxc exec args: ${args.join(' ')}`); - - process.stdin.pause(); - const sandboxProcess = spawn('lxc', args, { - stdio: 'inherit', - }); - - return new Promise((resolve, reject) => { - sandboxProcess.on('error', (err) => { - coreEvents.emitFeedback('error', 'LXC sandbox process error', err); - reject(err); - }); - - sandboxProcess.on('close', (code, signal) => { - process.stdin.resume(); - process.off('exit', removeDevice); - removeDevice(); - if (code !== 0 && code !== null) { - debugLogger.log( - `LXC sandbox process exited with code: ${code}, signal: ${signal}`, - ); + // Build the environment variable arguments for `lxc exec`. + const envArgs: string[] = []; + const envVarsToForward: Record = { + GEMINI_API_KEY: process.env['GEMINI_API_KEY'], + GOOGLE_API_KEY: process.env['GOOGLE_API_KEY'], + GOOGLE_GEMINI_BASE_URL: process.env['GOOGLE_GEMINI_BASE_URL'], + GOOGLE_VERTEX_BASE_URL: process.env['GOOGLE_VERTEX_BASE_URL'], + GOOGLE_GENAI_USE_VERTEXAI: process.env['GOOGLE_GENAI_USE_VERTEXAI'], + GOOGLE_GENAI_USE_GCA: process.env['GOOGLE_GENAI_USE_GCA'], + GOOGLE_CLOUD_PROJECT: process.env['GOOGLE_CLOUD_PROJECT'], + GOOGLE_CLOUD_LOCATION: process.env['GOOGLE_CLOUD_LOCATION'], + GEMINI_MODEL: process.env['GEMINI_MODEL'], + TERM: process.env['TERM'], + COLORTERM: process.env['COLORTERM'], + GEMINI_CLI_IDE_SERVER_PORT: process.env['GEMINI_CLI_IDE_SERVER_PORT'], + GEMINI_CLI_IDE_WORKSPACE_PATH: + process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'], + TERM_PROGRAM: process.env['TERM_PROGRAM'], + }; + for (const [key, value] of Object.entries(envVarsToForward)) { + if (value) { + envArgs.push('--env', `${key}=${value}`); } - resolve(code ?? 1); + } + + // Forward SANDBOX_ENV key=value pairs + if (process.env['SANDBOX_ENV']) { + for (let env of process.env['SANDBOX_ENV'].split(',')) { + if ((env = env.trim())) { + if (env.includes('=')) { + envArgs.push('--env', env); + } else { + throw new FatalSandboxError( + 'SANDBOX_ENV must be a comma-separated list of key=value pairs', + ); + } + } + } + } + + // Forward NODE_OPTIONS (e.g. from --inspect flags) + const existingNodeOptions = process.env['NODE_OPTIONS'] || ''; + const allNodeOptions = [ + ...(existingNodeOptions ? [existingNodeOptions] : []), + ...nodeArgs, + ].join(' '); + if (allNodeOptions.length > 0) { + envArgs.push('--env', `NODE_OPTIONS=${allNodeOptions}`); + } + + // Mark that we're running inside an LXC sandbox. + envArgs.push('--env', `SANDBOX=${containerName}`); + + // Build the command entrypoint (same logic as Docker path). + const finalEntrypoint = entrypoint(workdir, cliArgs); + + // Build the full lxc exec command args. + const args = [ + 'exec', + containerName, + '--cwd', + workdir, + ...envArgs, + '--', + ...finalEntrypoint, + ]; + + debugLogger.log(`lxc exec args: ${args.join(' ')}`); + + process.stdin.pause(); + const sandboxProcess = spawn('lxc', args, { + stdio: 'inherit', }); - }); + + return await new Promise((resolve, reject) => { + sandboxProcess.on('error', (err) => { + coreEvents.emitFeedback('error', 'LXC sandbox process error', err); + reject(err); + }); + + sandboxProcess.on('close', (code, signal) => { + process.stdin.resume(); + if (code !== 0 && code !== null) { + debugLogger.log( + `LXC sandbox process exited with code: ${code}, signal: ${signal}`, + ); + } + resolve(code ?? 1); + }); + }); + } finally { + process.off('exit', removeDevices); + removeDevices(); + } } // Helper functions to ensure sandbox image is present diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 822898b444..1eca5d5a35 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -19,6 +19,7 @@ import { type ConfigParameters, type SandboxConfig, } from './config.js'; +import { createMockSandboxConfig } from '@google/gemini-cli-test-utils'; import { DEFAULT_MAX_ATTEMPTS } from '../utils/retry.js'; import { ExperimentFlags } from '../code_assist/experiments/flagNames.js'; import { debugLogger } from '../utils/debugLogger.js'; @@ -247,10 +248,10 @@ vi.mock('../code_assist/experiments/experiments.js'); describe('Server Config (config.ts)', () => { const MODEL = DEFAULT_GEMINI_MODEL; - const SANDBOX: SandboxConfig = { + const SANDBOX: SandboxConfig = createMockSandboxConfig({ command: 'docker', image: 'gemini-cli-sandbox', - }; + }); const TARGET_DIR = '/path/to/target'; const DEBUG_MODE = false; const QUESTION = 'test question'; @@ -1566,14 +1567,62 @@ describe('Server Config (config.ts)', () => { expect(browserConfig.customConfig.sessionMode).toBe('persistent'); }); }); + + describe('Sandbox Configuration', () => { + it('should default sandbox settings when not provided', () => { + const config = new Config({ + ...baseParams, + sandbox: undefined, + }); + + expect(config.getSandboxEnabled()).toBe(false); + expect(config.getSandboxAllowedPaths()).toEqual([]); + expect(config.getSandboxNetworkAccess()).toBe(false); + }); + + it('should store provided sandbox settings', () => { + const sandbox: SandboxConfig = { + enabled: true, + allowedPaths: ['/tmp/foo', '/var/bar'], + networkAccess: true, + command: 'docker', + image: 'my-image', + }; + const config = new Config({ + ...baseParams, + sandbox, + }); + + expect(config.getSandboxEnabled()).toBe(true); + expect(config.getSandboxAllowedPaths()).toEqual(['/tmp/foo', '/var/bar']); + expect(config.getSandboxNetworkAccess()).toBe(true); + expect(config.getSandbox()?.command).toBe('docker'); + expect(config.getSandbox()?.image).toBe('my-image'); + }); + + it('should partially override default sandbox settings', () => { + const config = new Config({ + ...baseParams, + sandbox: { + enabled: true, + allowedPaths: ['/only/this'], + networkAccess: false, + } as SandboxConfig, + }); + + expect(config.getSandboxEnabled()).toBe(true); + expect(config.getSandboxAllowedPaths()).toEqual(['/only/this']); + expect(config.getSandboxNetworkAccess()).toBe(false); + }); + }); }); describe('GemmaModelRouterSettings', () => { const MODEL = DEFAULT_GEMINI_MODEL; - const SANDBOX: SandboxConfig = { + const SANDBOX: SandboxConfig = createMockSandboxConfig({ command: 'docker', image: 'gemini-cli-sandbox', - }; + }); const TARGET_DIR = '/path/to/target'; const DEBUG_MODE = false; const QUESTION = 'test question'; @@ -1950,10 +1999,10 @@ describe('isYoloModeDisabled', () => { describe('BaseLlmClient Lifecycle', () => { const MODEL = 'gemini-pro'; - const SANDBOX: SandboxConfig = { + const SANDBOX: SandboxConfig = createMockSandboxConfig({ command: 'docker', image: 'gemini-cli-sandbox', - }; + }); const TARGET_DIR = '/path/to/target'; const DEBUG_MODE = false; const QUESTION = 'test question'; @@ -2005,10 +2054,10 @@ describe('BaseLlmClient Lifecycle', () => { describe('Generation Config Merging (HACK)', () => { const MODEL = 'gemini-pro'; - const SANDBOX: SandboxConfig = { + const SANDBOX: SandboxConfig = createMockSandboxConfig({ command: 'docker', image: 'gemini-cli-sandbox', - }; + }); const TARGET_DIR = '/path/to/target'; const DEBUG_MODE = false; const QUESTION = 'test question'; @@ -2311,10 +2360,10 @@ describe('Config getHooks', () => { describe('LocalLiteRtLmClient Lifecycle', () => { const MODEL = 'gemini-pro'; - const SANDBOX: SandboxConfig = { + const SANDBOX: SandboxConfig = createMockSandboxConfig({ command: 'docker', image: 'gemini-cli-sandbox', - }; + }); const TARGET_DIR = '/path/to/target'; const DEBUG_MODE = false; const QUESTION = 'test question'; @@ -2629,6 +2678,9 @@ describe('Config Quota & Preview Model Access', () => { usageStatisticsEnabled: false, embeddingModel: 'gemini-embedding', sandbox: { + enabled: true, + allowedPaths: [], + networkAccess: false, command: 'docker', image: 'gemini-cli-sandbox', }, @@ -3264,3 +3316,39 @@ describe('Model Persistence Bug Fix (#19864)', () => { expect(config.getModel()).toBe(PREVIEW_GEMINI_3_1_MODEL); }); }); + +describe('ConfigSchema validation', () => { + it('should validate a valid sandbox config', async () => { + const validConfig = { + sandbox: { + enabled: true, + allowedPaths: ['/tmp'], + networkAccess: false, + command: 'docker', + image: 'node:20', + }, + }; + + const { ConfigSchema } = await import('./config.js'); + const result = ConfigSchema.safeParse(validConfig); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.sandbox?.enabled).toBe(true); + } + }); + + it('should apply defaults in ConfigSchema', async () => { + const minimalConfig = { + sandbox: {}, + }; + + const { ConfigSchema } = await import('./config.js'); + const result = ConfigSchema.safeParse(minimalConfig); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.sandbox?.enabled).toBe(false); + expect(result.data.sandbox?.allowedPaths).toEqual([]); + expect(result.data.sandbox?.networkAccess).toBe(false); + } + }); +}); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index a07264f430..33839ff75f 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -8,6 +8,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { inspect } from 'node:util'; import process from 'node:process'; +import { z } from 'zod'; import { AuthType, createContentGenerator, @@ -96,7 +97,6 @@ import type { import { ModelAvailabilityService } from '../availability/modelAvailabilityService.js'; import { ModelRouterService } from '../routing/modelRouterService.js'; import { OutputFormat } from '../output/types.js'; -//import { type AgentLoopContext } from './agent-loop-context.js'; import { ModelConfigService, type ModelConfig, @@ -451,10 +451,36 @@ export enum AuthProviderType { } export interface SandboxConfig { - command: 'docker' | 'podman' | 'sandbox-exec' | 'runsc' | 'lxc'; - image: string; + enabled: boolean; + allowedPaths?: string[]; + networkAccess?: boolean; + command?: 'docker' | 'podman' | 'sandbox-exec' | 'runsc' | 'lxc'; + image?: string; } +export const ConfigSchema = z.object({ + sandbox: z + .object({ + enabled: z.boolean().default(false), + allowedPaths: z.array(z.string()).default([]), + networkAccess: z.boolean().default(false), + command: z + .enum(['docker', 'podman', 'sandbox-exec', 'runsc', 'lxc']) + .optional(), + image: z.string().optional(), + }) + .superRefine((data, ctx) => { + if (data.enabled && !data.command) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Sandbox command is required when sandbox is enabled', + path: ['command'], + }); + } + }) + .optional(), +}); + /** * Callbacks for checking MCP server enablement status. * These callbacks are provided by the CLI package to bridge @@ -956,7 +982,6 @@ export class Config implements McpContext, AgentLoopContext { this.truncateToolOutputThreshold = params.truncateToolOutputThreshold ?? DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD; - // // TODO(joshualitt): Re-evaluate the todo tool for 3 family. this.useWriteTodos = isPreviewModel(this.model) ? false : (params.useWriteTodos ?? true); @@ -1617,6 +1642,18 @@ export class Config implements McpContext, AgentLoopContext { return this.sandbox; } + getSandboxEnabled(): boolean { + return this.sandbox?.enabled ?? false; + } + + getSandboxAllowedPaths(): string[] { + return this.sandbox?.allowedPaths ?? []; + } + + getSandboxNetworkAccess(): boolean { + return this.sandbox?.networkAccess ?? false; + } + isRestrictiveSandbox(): boolean { const sandboxConfig = this.getSandbox(); const seatbeltProfile = process.env['SEATBELT_PROFILE']; diff --git a/packages/core/src/services/sandboxManager.test.ts b/packages/core/src/services/sandboxManager.test.ts new file mode 100644 index 0000000000..bac8a8a55c --- /dev/null +++ b/packages/core/src/services/sandboxManager.test.ts @@ -0,0 +1,111 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { NoopSandboxManager } from './sandboxManager.js'; + +describe('NoopSandboxManager', () => { + const sandboxManager = new NoopSandboxManager(); + + it('should pass through the command and arguments unchanged', async () => { + const req = { + command: 'ls', + args: ['-la'], + cwd: '/tmp', + env: { PATH: '/usr/bin' }, + }; + + const result = await sandboxManager.prepareCommand(req); + + expect(result.program).toBe('ls'); + expect(result.args).toEqual(['-la']); + }); + + it('should sanitize the environment variables', async () => { + const req = { + command: 'echo', + args: ['hello'], + cwd: '/tmp', + env: { + PATH: '/usr/bin', + GITHUB_TOKEN: 'ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + MY_SECRET: 'super-secret', + SAFE_VAR: 'is-safe', + }, + }; + + const result = await sandboxManager.prepareCommand(req); + + expect(result.env['PATH']).toBe('/usr/bin'); + expect(result.env['SAFE_VAR']).toBe('is-safe'); + expect(result.env['GITHUB_TOKEN']).toBeUndefined(); + expect(result.env['MY_SECRET']).toBeUndefined(); + }); + + it('should force environment variable redaction even if not requested in config', async () => { + const req = { + command: 'echo', + args: ['hello'], + cwd: '/tmp', + env: { + API_KEY: 'sensitive-key', + }, + config: { + sanitizationConfig: { + enableEnvironmentVariableRedaction: false, + }, + }, + }; + + const result = await sandboxManager.prepareCommand(req); + + expect(result.env['API_KEY']).toBeUndefined(); + }); + + it('should respect allowedEnvironmentVariables in config', async () => { + const req = { + command: 'echo', + args: ['hello'], + cwd: '/tmp', + env: { + MY_TOKEN: 'secret-token', + OTHER_SECRET: 'another-secret', + }, + config: { + sanitizationConfig: { + allowedEnvironmentVariables: ['MY_TOKEN'], + }, + }, + }; + + const result = await sandboxManager.prepareCommand(req); + + expect(result.env['MY_TOKEN']).toBe('secret-token'); + expect(result.env['OTHER_SECRET']).toBeUndefined(); + }); + + it('should respect blockedEnvironmentVariables in config', async () => { + const req = { + command: 'echo', + args: ['hello'], + cwd: '/tmp', + env: { + SAFE_VAR: 'safe-value', + BLOCKED_VAR: 'blocked-value', + }, + config: { + sanitizationConfig: { + blockedEnvironmentVariables: ['BLOCKED_VAR'], + }, + }, + }; + + const result = await sandboxManager.prepareCommand(req); + + expect(result.env['SAFE_VAR']).toBe('safe-value'); + expect(result.env['BLOCKED_VAR']).toBeUndefined(); + }); +}); diff --git a/packages/core/src/services/sandboxManager.ts b/packages/core/src/services/sandboxManager.ts new file mode 100644 index 0000000000..458e15260e --- /dev/null +++ b/packages/core/src/services/sandboxManager.ts @@ -0,0 +1,78 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + sanitizeEnvironment, + type EnvironmentSanitizationConfig, +} from './environmentSanitization.js'; + +/** + * Request for preparing a command to run in a sandbox. + */ +export interface SandboxRequest { + /** The program to execute. */ + command: string; + /** Arguments for the program. */ + args: string[]; + /** The working directory. */ + cwd: string; + /** Environment variables to be passed to the program. */ + env: NodeJS.ProcessEnv; + /** Optional sandbox-specific configuration. */ + config?: { + sanitizationConfig?: Partial; + }; +} + +/** + * A command that has been prepared for sandboxed execution. + */ +export interface SandboxedCommand { + /** The program or wrapper to execute. */ + program: string; + /** Final arguments for the program. */ + args: string[]; + /** Sanitized environment variables. */ + env: NodeJS.ProcessEnv; +} + +/** + * Interface for a service that prepares commands for sandboxed execution. + */ +export interface SandboxManager { + /** + * Prepares a command to run in a sandbox, including environment sanitization. + */ + prepareCommand(req: SandboxRequest): Promise; +} + +/** + * A no-op implementation of SandboxManager that silently passes commands + * through while applying environment sanitization. + */ +export class NoopSandboxManager implements SandboxManager { + /** + * Prepares a command by sanitizing the environment and passing through + * the original program and arguments. + */ + async prepareCommand(req: SandboxRequest): Promise { + const sanitizationConfig: EnvironmentSanitizationConfig = { + allowedEnvironmentVariables: + req.config?.sanitizationConfig?.allowedEnvironmentVariables ?? [], + blockedEnvironmentVariables: + req.config?.sanitizationConfig?.blockedEnvironmentVariables ?? [], + enableEnvironmentVariableRedaction: true, // Forced for safety + }; + + const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig); + + return { + program: req.command, + args: req.args, + env: sanitizedEnv, + }; + } +} diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index d92f395706..e53c018745 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -30,6 +30,7 @@ import { sanitizeEnvironment, type EnvironmentSanitizationConfig, } from './environmentSanitization.js'; +import { NoopSandboxManager } from './sandboxManager.js'; import { killProcessGroup } from '../utils/process-utils.js'; const { Terminal } = pkg; @@ -326,6 +327,15 @@ export class ShellExecutionService { shouldUseNodePty: boolean, shellExecutionConfig: ShellExecutionConfig, ): Promise { + const sandboxManager = new NoopSandboxManager(); + const { env: sanitizedEnv } = await sandboxManager.prepareCommand({ + command: commandToExecute, + args: [], + env: process.env, + cwd, + config: shellExecutionConfig, + }); + if (shouldUseNodePty) { const ptyInfo = await getPty(); if (ptyInfo) { @@ -337,6 +347,7 @@ export class ShellExecutionService { abortSignal, shellExecutionConfig, ptyInfo, + sanitizedEnv, ); } catch (_e) { // Fallback to child_process @@ -695,6 +706,7 @@ export class ShellExecutionService { abortSignal: AbortSignal, shellExecutionConfig: ShellExecutionConfig, ptyInfo: PtyImplementation, + sanitizedEnv: Record, ): Promise { if (!ptyInfo) { // This should not happen, but as a safeguard... @@ -724,10 +736,7 @@ export class ShellExecutionService { cols, rows, env: { - ...sanitizeEnvironment( - process.env, - shellExecutionConfig.sanitizationConfig, - ), + ...sanitizedEnv, GEMINI_CLI: '1', TERM: 'xterm-256color', PAGER: shellExecutionConfig.pager ?? 'cat', diff --git a/packages/test-utils/src/index.ts b/packages/test-utils/src/index.ts index c1f2f09d3e..583cbc8a8b 100644 --- a/packages/test-utils/src/index.ts +++ b/packages/test-utils/src/index.ts @@ -6,3 +6,4 @@ export * from './file-system-test-helpers.js'; export * from './test-rig.js'; +export * from './mock-utils.js'; diff --git a/packages/test-utils/src/mock-utils.ts b/packages/test-utils/src/mock-utils.ts new file mode 100644 index 0000000000..6815eb8a32 --- /dev/null +++ b/packages/test-utils/src/mock-utils.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { SandboxConfig } from '@google/gemini-cli-core'; + +export function createMockSandboxConfig( + overrides?: Partial, +): SandboxConfig { + return { + enabled: true, + allowedPaths: [], + networkAccess: false, + ...overrides, + }; +} diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 27ac0bf51d..64f8776768 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -1299,7 +1299,7 @@ "title": "Sandbox", "description": "Sandbox execution environment. Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile, or specify an explicit sandbox command (e.g., \"docker\", \"podman\", \"lxc\").", "markdownDescription": "Sandbox execution environment. Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile, or specify an explicit sandbox command (e.g., \"docker\", \"podman\", \"lxc\").\n\n- Category: `Tools`\n- Requires restart: `yes`", - "$ref": "#/$defs/BooleanOrString" + "$ref": "#/$defs/BooleanOrStringOrObject" }, "shell": { "title": "Shell", @@ -2431,14 +2431,45 @@ } ] }, - "BooleanOrString": { - "description": "Accepts either a boolean flag or a string command name.", + "BooleanOrStringOrObject": { + "description": "Accepts either a boolean flag, a string command name, or a configuration object.", "anyOf": [ { "type": "boolean" }, { "type": "string" + }, + { + "type": "object", + "description": "Sandbox configuration object.", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "Enables or disables the sandbox." + }, + "command": { + "type": "string", + "description": "The sandbox command to use (docker, podman, sandbox-exec, runsc, lxc).", + "enum": ["docker", "podman", "sandbox-exec", "runsc", "lxc"] + }, + "image": { + "type": "string", + "description": "The sandbox image to use." + }, + "allowedPaths": { + "type": "array", + "description": "A list of absolute host paths that should be accessible within the sandbox.", + "items": { + "type": "string" + } + }, + "networkAccess": { + "type": "boolean", + "description": "Whether the sandbox should have internet access." + } + } } ] },