diff --git a/eslint.config.js b/eslint.config.js index d3a267f30a..d879a5da8e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -303,7 +303,7 @@ export default tseslint.config( }, }, { - files: ['./scripts/**/*.js', 'esbuild.config.js'], + files: ['./scripts/**/*.js', 'packages/*/scripts/**/*.js', 'esbuild.config.js'], languageOptions: { globals: { ...globals.node, diff --git a/packages/cli/src/config/sandboxConfig.test.ts b/packages/cli/src/config/sandboxConfig.test.ts index cfe1fed660..3ec0e6a5bb 100644 --- a/packages/cli/src/config/sandboxConfig.test.ts +++ b/packages/cli/src/config/sandboxConfig.test.ts @@ -338,6 +338,8 @@ describe('loadSandboxConfig', () => { sandbox: { enabled: true, command: 'podman', + allowedPaths: [], + networkAccess: false, }, }, }, @@ -353,6 +355,8 @@ describe('loadSandboxConfig', () => { sandbox: { enabled: true, image: 'custom/image', + allowedPaths: [], + networkAccess: false, }, }, }, @@ -367,6 +371,8 @@ describe('loadSandboxConfig', () => { tools: { sandbox: { enabled: false, + allowedPaths: [], + networkAccess: false, }, }, }, @@ -382,6 +388,7 @@ describe('loadSandboxConfig', () => { sandbox: { enabled: true, allowedPaths: ['/settings-path'], + networkAccess: false, }, }, }, diff --git a/packages/core/scripts/compile-windows-sandbox.js b/packages/core/scripts/compile-windows-sandbox.js index bc9174e495..a52987c24e 100644 --- a/packages/core/scripts/compile-windows-sandbox.js +++ b/packages/core/scripts/compile-windows-sandbox.js @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +/* eslint-env node */ + import { spawnSync } from 'node:child_process'; import path from 'node:path'; import fs from 'node:fs'; diff --git a/packages/core/src/services/sandboxManager.ts b/packages/core/src/services/sandboxManager.ts index 458e15260e..ae84368293 100644 --- a/packages/core/src/services/sandboxManager.ts +++ b/packages/core/src/services/sandboxManager.ts @@ -24,6 +24,8 @@ export interface SandboxRequest { /** Optional sandbox-specific configuration. */ config?: { sanitizationConfig?: Partial; + allowedPaths?: string[]; + networkAccess?: boolean; }; } diff --git a/packages/core/src/services/sandboxedFileSystemService.test.ts b/packages/core/src/services/sandboxedFileSystemService.test.ts new file mode 100644 index 0000000000..d18fcce962 --- /dev/null +++ b/packages/core/src/services/sandboxedFileSystemService.test.ts @@ -0,0 +1,113 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SandboxedFileSystemService } from './sandboxedFileSystemService.js'; +import type { + SandboxManager, + SandboxRequest, + SandboxedCommand, +} from './sandboxManager.js'; +import { spawn } from 'node:child_process'; +import { EventEmitter } from 'node:events'; + +vi.mock('node:child_process', () => ({ + spawn: vi.fn(), +})); + +class MockSandboxManager implements SandboxManager { + async prepareCommand(req: SandboxRequest): Promise { + return { + program: 'sandbox.exe', + args: ['0', req.cwd, req.command, ...req.args], + env: req.env || {}, + }; + } +} + +describe('SandboxedFileSystemService', () => { + let sandboxManager: MockSandboxManager; + let service: SandboxedFileSystemService; + const cwd = '/test/cwd'; + + beforeEach(() => { + sandboxManager = new MockSandboxManager(); + service = new SandboxedFileSystemService(sandboxManager, cwd); + vi.clearAllMocks(); + }); + + it('should read a file through the sandbox', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mockChild = new EventEmitter() as any; + mockChild.stdout = new EventEmitter(); + mockChild.stderr = new EventEmitter(); + + vi.mocked(spawn).mockReturnValue(mockChild); + + const readPromise = service.readTextFile('/test/file.txt'); + + // Use setImmediate to ensure events are emitted after the promise starts executing + setImmediate(() => { + mockChild.stdout.emit('data', Buffer.from('file content')); + mockChild.emit('close', 0); + }); + + const content = await readPromise; + expect(content).toBe('file content'); + expect(spawn).toHaveBeenCalledWith( + 'sandbox.exe', + ['0', cwd, '__read', '/test/file.txt'], + expect.any(Object), + ); + }); + + it('should write a file through the sandbox', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mockChild = new EventEmitter() as any; + mockChild.stdin = { + write: vi.fn(), + end: vi.fn(), + }; + mockChild.stderr = new EventEmitter(); + + vi.mocked(spawn).mockReturnValue(mockChild); + + const writePromise = service.writeTextFile('/test/file.txt', 'new content'); + + setImmediate(() => { + mockChild.emit('close', 0); + }); + + await writePromise; + expect(mockChild.stdin.write).toHaveBeenCalledWith('new content'); + expect(mockChild.stdin.end).toHaveBeenCalled(); + expect(spawn).toHaveBeenCalledWith( + 'sandbox.exe', + ['0', cwd, '__write', '/test/file.txt'], + expect.any(Object), + ); + }); + + it('should reject if sandbox command fails', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mockChild = new EventEmitter() as any; + mockChild.stdout = new EventEmitter(); + mockChild.stderr = new EventEmitter(); + + vi.mocked(spawn).mockReturnValue(mockChild); + + const readPromise = service.readTextFile('/test/file.txt'); + + setImmediate(() => { + mockChild.stderr.emit('data', Buffer.from('access denied')); + mockChild.emit('close', 1); + }); + + await expect(readPromise).rejects.toThrow( + 'Sandbox Error: Command failed with exit code 1. Details: access denied', + ); + }); +}); diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index f8d2e728d2..77d74d2761 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -31,7 +31,8 @@ import { sanitizeEnvironment, type EnvironmentSanitizationConfig, } from './environmentSanitization.js'; -import { NoopSandboxManager } from './sandboxManager.js'; +import { NoopSandboxManager, type SandboxManager } from './sandboxManager.js'; +import type { SandboxConfig } from '../config/config.js'; import { killProcessGroup } from '../utils/process-utils.js'; import { ExecutionLifecycleService, @@ -94,6 +95,8 @@ export interface ShellExecutionConfig { disableDynamicLineTrimming?: boolean; scrollback?: number; maxSerializedLines?: number; + sandboxManager?: SandboxManager; + sandboxConfig?: SandboxConfig; } /** @@ -274,13 +277,17 @@ export class ShellExecutionService { shouldUseNodePty: boolean, shellExecutionConfig: ShellExecutionConfig, ): Promise { - const sandboxManager = new NoopSandboxManager(); + const sandboxManager = + shellExecutionConfig.sandboxManager ?? new NoopSandboxManager(); const { env: sanitizedEnv } = await sandboxManager.prepareCommand({ command: commandToExecute, args: [], env: process.env, cwd, - config: shellExecutionConfig, + config: { + ...shellExecutionConfig, + ...(shellExecutionConfig.sandboxConfig || {}), + }, }); if (shouldUseNodePty) { diff --git a/packages/core/src/services/windowsSandboxManager.test.ts b/packages/core/src/services/windowsSandboxManager.test.ts index 50cce11ea7..6bec183410 100644 --- a/packages/core/src/services/windowsSandboxManager.test.ts +++ b/packages/core/src/services/windowsSandboxManager.test.ts @@ -7,48 +7,62 @@ import { describe, it, expect } from 'vitest'; import { WindowsSandboxManager } from './windowsSandboxManager.js'; import type { SandboxRequest } from './sandboxManager.js'; -import * as os from 'node:os'; describe('WindowsSandboxManager', () => { - const manager = new WindowsSandboxManager(); + const manager = new WindowsSandboxManager('win32'); - it.skipIf(os.platform() !== 'win32')( - 'should prepare a GeminiSandbox.exe command', - async () => { - const req: SandboxRequest = { - command: 'whoami', - args: ['/groups'], - cwd: process.cwd(), - env: { TEST_VAR: 'test_value' }, - config: { - networkAccess: false, + it('should prepare a GeminiSandbox.exe command', async () => { + const req: SandboxRequest = { + command: 'whoami', + args: ['/groups'], + cwd: '/test/cwd', + env: { TEST_VAR: 'test_value' }, + config: { + networkAccess: false, + }, + }; + + const result = await manager.prepareCommand(req); + + expect(result.program).toContain('GeminiSandbox.exe'); + expect(result.args).toEqual(['0', '/test/cwd', 'whoami', '/groups']); + }); + + it('should handle networkAccess from config', async () => { + const req: SandboxRequest = { + command: 'whoami', + args: [], + cwd: '/test/cwd', + env: {}, + config: { + networkAccess: true, + }, + }; + + const result = await manager.prepareCommand(req); + expect(result.args[0]).toBe('1'); + }); + + it('should sanitize environment variables', async () => { + const req: SandboxRequest = { + command: 'test', + args: [], + cwd: '/test/cwd', + env: { + API_KEY: 'secret', + PATH: '/usr/bin', + }, + config: { + sanitizationConfig: { + allowedEnvironmentVariables: ['PATH'], + blockedEnvironmentVariables: ['API_KEY'], + enableEnvironmentVariableRedaction: true, }, - }; + }, + }; - const result = await manager.prepareCommand(req); - - expect(result.program).toContain('GeminiSandbox.exe'); - expect(result.args).toEqual( - expect.arrayContaining(['0', process.cwd(), 'whoami', '/groups']), - ); - }, - ); - - it.skipIf(os.platform() !== 'win32')( - 'should handle networkAccess from config', - async () => { - const req: SandboxRequest = { - command: 'whoami', - args: [], - cwd: process.cwd(), - env: {}, - config: { - networkAccess: true, - }, - }; - - const result = await manager.prepareCommand(req); - expect(result.args[0]).toBe('1'); - }, - ); + const result = await manager.prepareCommand(req); + expect(result.env['PATH']).toBe('/usr/bin'); + expect(result.env['API_KEY']).toBeUndefined(); + }); }); diff --git a/packages/core/src/services/windowsSandboxManager.ts b/packages/core/src/services/windowsSandboxManager.ts index 04b2b14eab..c1d11a0db9 100644 --- a/packages/core/src/services/windowsSandboxManager.ts +++ b/packages/core/src/services/windowsSandboxManager.ts @@ -28,14 +28,20 @@ const __dirname = path.dirname(__filename); */ export class WindowsSandboxManager implements SandboxManager { private readonly helperPath: string; + private readonly platform: string; private initialized = false; - constructor() { + constructor(platform: string = process.platform) { + this.platform = platform; this.helperPath = path.resolve(__dirname, 'scripts', 'GeminiSandbox.exe'); } private ensureInitialized(): void { if (this.initialized) return; + if (this.platform !== 'win32') { + this.initialized = true; + return; + } if (!fs.existsSync(this.helperPath)) { // If the exe doesn't exist, we try to compile it from the .cs file @@ -60,13 +66,15 @@ export class WindowsSandboxManager implements SandboxManager { ), ]; - let compiled = false; for (const csc of cscPaths) { - const result = spawnSync(csc, ['/out:' + this.helperPath, sourcePath], { - stdio: 'ignore', - }); + const result = spawnSync( + csc, + ['/out:' + this.helperPath, sourcePath], + { + stdio: 'ignore', + }, + ); if (result.status === 0) { - compiled = true; break; } } @@ -128,11 +136,14 @@ export class WindowsSandboxManager implements SandboxManager { * Grants "Low Mandatory Level" access to a path using icacls. */ private grantLowIntegrityAccess(targetPath: string): void { + if (this.platform !== 'win32') { + return; + } try { spawnSync('icacls', [targetPath, '/setintegritylevel', 'Low'], { stdio: 'ignore', }); - } catch (e) { + } catch (_e) { // Best effort } }