diff --git a/.geminiignore b/.geminiignore new file mode 100644 index 0000000000..e40b6ba36e --- /dev/null +++ b/.geminiignore @@ -0,0 +1 @@ +packages/core/src/services/scripts/*.exe diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index d05950419b..b34433a878 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -50,7 +50,25 @@ Cross-platform sandboxing with complete process isolation. **Note**: Requires building the sandbox image locally or using a published image from your organization's registry. -### 3. gVisor / runsc (Linux only) +### 3. Windows Native Sandbox (Windows only) + +... **Troubleshooting and Side Effects:** + +The Windows Native sandbox uses the `icacls` command to set a "Low Mandatory +Level" on files and directories it needs to write to. + +- **Persistence**: These integrity level changes are persistent on the + filesystem. Even after the sandbox session ends, files created or modified by + the sandbox will retain their "Low" integrity level. +- **Manual Reset**: If you need to reset the integrity level of a file or + directory, you can use: + ```powershell + icacls "C:\path\to\dir" /setintegritylevel Medium + ``` +- **System Folders**: The sandbox manager automatically skips setting integrity + levels on system folders (like `C:\Windows`) for safety. + +### 4. gVisor / runsc (Linux only) Strongest isolation available: runs containers inside a user-space kernel via [gVisor](https://github.com/google/gvisor). gVisor intercepts all container diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 853e46fc0a..85373f1034 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -117,6 +117,8 @@ they appear in the UI. | UI Label | Setting | Description | Default | | -------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| Sandbox Allowed Paths | `tools.sandboxAllowedPaths` | List of additional paths that the sandbox is allowed to access. | `[]` | +| Sandbox Network Access | `tools.sandboxNetworkAccess` | Whether the sandbox is allowed to access the network. | `false` | | Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` | | Show Color | `tools.shell.showColor` | Show color in shell output. | `false` | | Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` | diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 2606890b0a..81a05bf51c 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -1276,10 +1276,21 @@ their corresponding top-level category object in your `settings.json` file. - **Description:** Legacy full-process 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"). + "lxc", "windows-native"). - **Default:** `undefined` - **Requires restart:** Yes +- **`tools.sandboxAllowedPaths`** (array): + - **Description:** List of additional paths that the sandbox is allowed to + access. + - **Default:** `[]` + - **Requires restart:** Yes + +- **`tools.sandboxNetworkAccess`** (boolean): + - **Description:** Whether the sandbox is allowed to access the network. + - **Default:** `false` + - **Requires restart:** Yes + - **`tools.shell.enableInteractiveShell`** (boolean): - **Description:** Use node-pty for an interactive shell experience. Fallback to child_process still applies. diff --git a/eslint.config.js b/eslint.config.js index 99b1b28f4b..76230fdfe5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -319,7 +319,12 @@ export default tseslint.config( }, }, { - files: ['./scripts/**/*.js', 'esbuild.config.js', 'packages/core/scripts/**/*.{js,mjs}'], + files: [ + './scripts/**/*.js', + 'packages/*/scripts/**/*.js', + 'esbuild.config.js', + 'packages/core/scripts/**/*.{js,mjs}', + ], languageOptions: { globals: { ...globals.node, diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 777950c0ca..3c74fd05bd 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -702,6 +702,19 @@ export async function loadCliConfig( ? defaultModel : specifiedModel || defaultModel; const sandboxConfig = await loadSandboxConfig(settings, argv); + if (sandboxConfig) { + const existingPaths = sandboxConfig.allowedPaths || []; + if (settings.tools.sandboxAllowedPaths?.length) { + sandboxConfig.allowedPaths = [ + ...new Set([...existingPaths, ...settings.tools.sandboxAllowedPaths]), + ]; + } + if (settings.tools.sandboxNetworkAccess !== undefined) { + sandboxConfig.networkAccess = + sandboxConfig.networkAccess || settings.tools.sandboxNetworkAccess; + } + } + const screenReader = argv.screenReader !== undefined ? argv.screenReader 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/cli/src/config/sandboxConfig.ts b/packages/cli/src/config/sandboxConfig.ts index 59a9685f70..1a047760d3 100644 --- a/packages/cli/src/config/sandboxConfig.ts +++ b/packages/cli/src/config/sandboxConfig.ts @@ -29,6 +29,7 @@ const VALID_SANDBOX_COMMANDS = [ 'sandbox-exec', 'runsc', 'lxc', + 'windows-native', ]; function isSandboxCommand( @@ -75,8 +76,15 @@ function getSandboxCommand( 'gVisor (runsc) sandboxing is only supported on Linux', ); } - // confirm that specified command exists - if (!commandExists.sync(sandbox)) { + // windows-native is only supported on Windows + if (sandbox === 'windows-native' && os.platform() !== 'win32') { + throw new FatalSandboxError( + 'Windows native sandboxing is only supported on Windows', + ); + } + + // confirm that specified command exists (unless it's built-in) + if (sandbox !== 'windows-native' && !commandExists.sync(sandbox)) { throw new FatalSandboxError( `Missing sandbox command '${sandbox}' (from GEMINI_SANDBOX)`, ); @@ -149,7 +157,12 @@ export async function loadSandboxConfig( customImage ?? packageJson?.config?.sandboxImageUri; - return command && image + const isNative = + command === 'windows-native' || + command === 'sandbox-exec' || + command === 'lxc'; + + return command && (image || isNative) ? { enabled: true, allowedPaths, networkAccess, command, image } : undefined; } diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 77e1bb0c09..de8fe65c46 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1358,10 +1358,30 @@ const SETTINGS_SCHEMA = { description: oneLine` Legacy full-process 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"). + or specify an explicit sandbox command (e.g., "docker", "podman", "lxc", "windows-native"). `, showInDialog: false, }, + sandboxAllowedPaths: { + type: 'array', + label: 'Sandbox Allowed Paths', + category: 'Tools', + requiresRestart: true, + default: [] as string[], + description: + 'List of additional paths that the sandbox is allowed to access.', + showInDialog: true, + items: { type: 'string' }, + }, + sandboxNetworkAccess: { + type: 'boolean', + label: 'Sandbox Network Access', + category: 'Tools', + requiresRestart: true, + default: false, + description: 'Whether the sandbox is allowed to access the network.', + showInDialog: true, + }, shell: { type: 'object', label: 'Shell', diff --git a/packages/core/scripts/compile-windows-sandbox.js b/packages/core/scripts/compile-windows-sandbox.js new file mode 100644 index 0000000000..a52987c24e --- /dev/null +++ b/packages/core/scripts/compile-windows-sandbox.js @@ -0,0 +1,121 @@ +/** + * @license + * Copyright 2026 Google LLC + * 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'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** + * Compiles the GeminiSandbox C# helper on Windows. + * This is used to provide native restricted token sandboxing. + */ +function compileWindowsSandbox() { + if (os.platform() !== 'win32') { + return; + } + + const srcHelperPath = path.resolve( + __dirname, + '../src/services/scripts/GeminiSandbox.exe', + ); + const distHelperPath = path.resolve( + __dirname, + '../dist/src/services/scripts/GeminiSandbox.exe', + ); + const sourcePath = path.resolve( + __dirname, + '../src/services/scripts/GeminiSandbox.cs', + ); + + if (!fs.existsSync(sourcePath)) { + console.error(`Sandbox source not found at ${sourcePath}`); + return; + } + + // Ensure directories exist + [srcHelperPath, distHelperPath].forEach((p) => { + const dir = path.dirname(p); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + }); + + // Find csc.exe (C# Compiler) which is built into Windows .NET Framework + const systemRoot = process.env['SystemRoot'] || 'C:\\Windows'; + const cscPaths = [ + 'csc.exe', // Try in PATH first + path.join( + systemRoot, + 'Microsoft.NET', + 'Framework64', + 'v4.0.30319', + 'csc.exe', + ), + path.join( + systemRoot, + 'Microsoft.NET', + 'Framework', + 'v4.0.30319', + 'csc.exe', + ), + ]; + + let csc = undefined; + for (const p of cscPaths) { + if (p === 'csc.exe') { + const result = spawnSync('where', ['csc.exe'], { stdio: 'ignore' }); + if (result.status === 0) { + csc = 'csc.exe'; + break; + } + } else if (fs.existsSync(p)) { + csc = p; + break; + } + } + + if (!csc) { + console.warn( + 'Windows C# compiler (csc.exe) not found. Native sandboxing will attempt to compile on first run.', + ); + return; + } + + console.log(`Compiling native Windows sandbox helper...`); + // Compile to src + let result = spawnSync( + csc, + [`/out:${srcHelperPath}`, '/optimize', sourcePath], + { + stdio: 'inherit', + }, + ); + + if (result.status === 0) { + console.log('Successfully compiled GeminiSandbox.exe to src'); + // Copy to dist if dist exists + const distDir = path.resolve(__dirname, '../dist'); + if (fs.existsSync(distDir)) { + const distScriptsDir = path.dirname(distHelperPath); + if (!fs.existsSync(distScriptsDir)) { + fs.mkdirSync(distScriptsDir, { recursive: true }); + } + fs.copyFileSync(srcHelperPath, distHelperPath); + console.log('Successfully copied GeminiSandbox.exe to dist'); + } + } else { + console.error('Failed to compile Windows sandbox helper.'); + } +} + +compileWindowsSandbox(); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index f9db411c9d..5bac6d086c 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -42,9 +42,11 @@ import type { HookDefinition, HookEventName } from '../hooks/types.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { GitService } from '../services/gitService.js'; import { - createSandboxManager, type SandboxManager, + NoopSandboxManager, } from '../services/sandboxManager.js'; +import { createSandboxManager } from '../services/sandboxManagerFactory.js'; +import { SandboxedFileSystemService } from '../services/sandboxedFileSystemService.js'; import { initializeTelemetry, DEFAULT_TELEMETRY_TARGET, @@ -467,7 +469,13 @@ export interface SandboxConfig { enabled: boolean; allowedPaths?: string[]; networkAccess?: boolean; - command?: 'docker' | 'podman' | 'sandbox-exec' | 'runsc' | 'lxc'; + command?: + | 'docker' + | 'podman' + | 'sandbox-exec' + | 'runsc' + | 'lxc' + | 'windows-native'; image?: string; } @@ -478,7 +486,14 @@ export const ConfigSchema = z.object({ allowedPaths: z.array(z.string()).default([]), networkAccess: z.boolean().default(false), command: z - .enum(['docker', 'podman', 'sandbox-exec', 'runsc', 'lxc']) + .enum([ + 'docker', + 'podman', + 'sandbox-exec', + 'runsc', + 'lxc', + 'windows-native', + ]) .optional(), image: z.string().optional(), }) @@ -876,7 +891,6 @@ export class Config implements McpContext, AgentLoopContext { this.approvedPlanPath = undefined; this.embeddingModel = params.embeddingModel ?? DEFAULT_GEMINI_EMBEDDING_MODEL; - this.fileSystemService = new StandardFileSystemService(); this.sandbox = params.sandbox ? { enabled: params.sandbox.enabled ?? false, @@ -890,6 +904,21 @@ export class Config implements McpContext, AgentLoopContext { allowedPaths: [], networkAccess: false, }; + + this._sandboxManager = createSandboxManager(this.sandbox, params.targetDir); + + if ( + !(this._sandboxManager instanceof NoopSandboxManager) && + this.sandbox.enabled + ) { + this.fileSystemService = new SandboxedFileSystemService( + this._sandboxManager, + params.targetDir, + ); + } else { + this.fileSystemService = new StandardFileSystemService(); + } + this.targetDir = path.resolve(params.targetDir); this.folderTrust = params.folderTrust ?? false; this.workspaceContext = new WorkspaceContext(this.targetDir, []); @@ -1072,7 +1101,8 @@ export class Config implements McpContext, AgentLoopContext { showColor: params.shellExecutionConfig?.showColor ?? false, pager: params.shellExecutionConfig?.pager ?? 'cat', sanitizationConfig: this.sanitizationConfig, - sandboxManager: this.sandboxManager, + sandboxManager: this._sandboxManager, + sandboxConfig: this.sandbox, }; this.truncateToolOutputThreshold = params.truncateToolOutputThreshold ?? @@ -1194,12 +1224,7 @@ export class Config implements McpContext, AgentLoopContext { } } this._geminiClient = new GeminiClient(this); - this._sandboxManager = createSandboxManager( - params.toolSandboxing ?? false, - this.targetDir, - ); this.a2aClientManager = new A2AClientManager(this); - this.shellExecutionConfig.sandboxManager = this._sandboxManager; this.modelRouterService = new ModelRouterService(this); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 47412dd73c..32572c86a0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -126,6 +126,8 @@ export * from './services/gitService.js'; export * from './services/FolderTrustDiscoveryService.js'; export * from './services/chatRecordingService.js'; export * from './services/fileSystemService.js'; +export * from './services/sandboxedFileSystemService.js'; +export * from './services/windowsSandboxManager.js'; export * from './services/sessionSummaryUtils.js'; export * from './services/contextManager.js'; export * from './services/trackerService.js'; diff --git a/packages/core/src/services/sandboxManager.test.ts b/packages/core/src/services/sandboxManager.test.ts index 1c351ce483..d201314d9f 100644 --- a/packages/core/src/services/sandboxManager.test.ts +++ b/packages/core/src/services/sandboxManager.test.ts @@ -6,13 +6,11 @@ import os from 'node:os'; import { describe, expect, it, vi } from 'vitest'; -import { - NoopSandboxManager, - LocalSandboxManager, - createSandboxManager, -} from './sandboxManager.js'; +import { NoopSandboxManager } from './sandboxManager.js'; +import { createSandboxManager } from './sandboxManagerFactory.js'; import { LinuxSandboxManager } from '../sandbox/linux/LinuxSandboxManager.js'; import { MacOsSandboxManager } from '../sandbox/macos/MacOsSandboxManager.js'; +import { WindowsSandboxManager } from './windowsSandboxManager.js'; describe('NoopSandboxManager', () => { const sandboxManager = new NoopSandboxManager(); @@ -121,20 +119,20 @@ describe('NoopSandboxManager', () => { describe('createSandboxManager', () => { it('should return NoopSandboxManager if sandboxing is disabled', () => { - const manager = createSandboxManager(false, '/workspace'); + const manager = createSandboxManager({ enabled: false }, '/workspace'); expect(manager).toBeInstanceOf(NoopSandboxManager); }); it.each([ { platform: 'linux', expected: LinuxSandboxManager }, { platform: 'darwin', expected: MacOsSandboxManager }, - { platform: 'win32', expected: LocalSandboxManager }, + { platform: 'win32', expected: WindowsSandboxManager }, ] as const)( 'should return $expected.name if sandboxing is enabled and platform is $platform', ({ platform, expected }) => { const osSpy = vi.spyOn(os, 'platform').mockReturnValue(platform); try { - const manager = createSandboxManager(true, '/workspace'); + const manager = createSandboxManager({ enabled: true }, '/workspace'); expect(manager).toBeInstanceOf(expected); } finally { osSpy.mockRestore(); diff --git a/packages/core/src/services/sandboxManager.ts b/packages/core/src/services/sandboxManager.ts index b48f010cea..8642edff11 100644 --- a/packages/core/src/services/sandboxManager.ts +++ b/packages/core/src/services/sandboxManager.ts @@ -4,14 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import os from 'node:os'; import { sanitizeEnvironment, getSecureSanitizationConfig, type EnvironmentSanitizationConfig, } from './environmentSanitization.js'; -import { LinuxSandboxManager } from '../sandbox/linux/LinuxSandboxManager.js'; -import { MacOsSandboxManager } from '../sandbox/macos/MacOsSandboxManager.js'; /** * Request for preparing a command to run in a sandbox. @@ -28,6 +25,8 @@ export interface SandboxRequest { /** Optional sandbox-specific configuration. */ config?: { sanitizationConfig?: Partial; + allowedPaths?: string[]; + networkAccess?: boolean; }; } @@ -88,21 +87,4 @@ export class LocalSandboxManager implements SandboxManager { } } -/** - * Creates a sandbox manager based on the provided settings. - */ -export function createSandboxManager( - sandboxingEnabled: boolean, - workspace: string, -): SandboxManager { - if (sandboxingEnabled) { - if (os.platform() === 'linux') { - return new LinuxSandboxManager({ workspace }); - } - if (os.platform() === 'darwin') { - return new MacOsSandboxManager({ workspace }); - } - return new LocalSandboxManager(); - } - return new NoopSandboxManager(); -} +export { createSandboxManager } from './sandboxManagerFactory.js'; diff --git a/packages/core/src/services/sandboxManagerFactory.ts b/packages/core/src/services/sandboxManagerFactory.ts new file mode 100644 index 0000000000..fffc366da9 --- /dev/null +++ b/packages/core/src/services/sandboxManagerFactory.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import os from 'node:os'; +import { + type SandboxManager, + NoopSandboxManager, + LocalSandboxManager, +} from './sandboxManager.js'; +import { LinuxSandboxManager } from '../sandbox/linux/LinuxSandboxManager.js'; +import { MacOsSandboxManager } from '../sandbox/macos/MacOsSandboxManager.js'; +import { WindowsSandboxManager } from './windowsSandboxManager.js'; +import type { SandboxConfig } from '../config/config.js'; + +/** + * Creates a sandbox manager based on the provided settings. + */ +export function createSandboxManager( + sandbox: SandboxConfig | undefined, + workspace: string, +): SandboxManager { + const isWindows = os.platform() === 'win32'; + + if ( + isWindows && + (sandbox?.enabled || sandbox?.command === 'windows-native') + ) { + return new WindowsSandboxManager(); + } + + if (sandbox?.enabled) { + if (os.platform() === 'linux') { + return new LinuxSandboxManager({ workspace }); + } + if (os.platform() === 'darwin') { + return new MacOsSandboxManager({ workspace }); + } + return new LocalSandboxManager(); + } + + return new NoopSandboxManager(); +} diff --git a/packages/core/src/services/sandboxedFileSystemService.test.ts b/packages/core/src/services/sandboxedFileSystemService.test.ts new file mode 100644 index 0000000000..9983bcfca7 --- /dev/null +++ b/packages/core/src/services/sandboxedFileSystemService.test.ts @@ -0,0 +1,133 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mock, +} from 'vitest'; +import { SandboxedFileSystemService } from './sandboxedFileSystemService.js'; +import type { + SandboxManager, + SandboxRequest, + SandboxedCommand, +} from './sandboxManager.js'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import type { Writable } from 'node:stream'; + +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(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should read a file through the sandbox', async () => { + const mockChild = new EventEmitter() as unknown as ChildProcess; + Object.assign(mockChild, { + stdout: new EventEmitter(), + 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 () => { + const mockChild = new EventEmitter() as unknown as ChildProcess; + const mockStdin = new EventEmitter(); + Object.assign(mockStdin, { + write: vi.fn(), + end: vi.fn(), + }); + Object.assign(mockChild, { + stdin: mockStdin as unknown as Writable, + 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( + (mockStdin as unknown as { write: Mock }).write, + ).toHaveBeenCalledWith('new content'); + expect((mockStdin as unknown as { end: Mock }).end).toHaveBeenCalled(); + expect(spawn).toHaveBeenCalledWith( + 'sandbox.exe', + ['0', cwd, '__write', '/test/file.txt'], + expect.any(Object), + ); + }); + + it('should reject if sandbox command fails', async () => { + const mockChild = new EventEmitter() as unknown as ChildProcess; + Object.assign(mockChild, { + stdout: new EventEmitter(), + 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: read_file failed for '/test/file.txt'. Exit code 1. Details: access denied", + ); + }); +}); diff --git a/packages/core/src/services/sandboxedFileSystemService.ts b/packages/core/src/services/sandboxedFileSystemService.ts new file mode 100644 index 0000000000..575fed49dd --- /dev/null +++ b/packages/core/src/services/sandboxedFileSystemService.ts @@ -0,0 +1,128 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { spawn } from 'node:child_process'; +import { type FileSystemService } from './fileSystemService.js'; +import { type SandboxManager } from './sandboxManager.js'; +import { debugLogger } from '../utils/debugLogger.js'; +import { isNodeError } from '../utils/errors.js'; + +/** + * A FileSystemService implementation that performs operations through a sandbox. + */ +export class SandboxedFileSystemService implements FileSystemService { + constructor( + private sandboxManager: SandboxManager, + private cwd: string, + ) {} + + async readTextFile(filePath: string): Promise { + const prepared = await this.sandboxManager.prepareCommand({ + command: '__read', + args: [filePath], + cwd: this.cwd, + env: process.env, + }); + + return new Promise((resolve, reject) => { + // Direct spawn is necessary here for streaming large file contents. + + const child = spawn(prepared.program, prepared.args, { + cwd: this.cwd, + env: prepared.env, + }); + + let output = ''; + let error = ''; + + child.stdout?.on('data', (data) => { + output += data.toString(); + }); + + child.stderr?.on('data', (data) => { + error += data.toString(); + }); + + child.on('close', (code) => { + if (code === 0) { + resolve(output); + } else { + reject( + new Error( + `Sandbox Error: read_file failed for '${filePath}'. Exit code ${code}. ${error ? 'Details: ' + error : ''}`, + ), + ); + } + }); + + child.on('error', (err) => { + reject( + new Error( + `Sandbox Error: Failed to spawn read_file for '${filePath}': ${err.message}`, + ), + ); + }); + }); + } + + async writeTextFile(filePath: string, content: string): Promise { + const prepared = await this.sandboxManager.prepareCommand({ + command: '__write', + args: [filePath], + cwd: this.cwd, + env: process.env, + }); + + return new Promise((resolve, reject) => { + // Direct spawn is necessary here for streaming large file contents. + + const child = spawn(prepared.program, prepared.args, { + cwd: this.cwd, + env: prepared.env, + }); + + child.stdin?.on('error', (err) => { + // Silently ignore EPIPE errors on stdin, they will be caught by the process error/close listeners + if (isNodeError(err) && err.code === 'EPIPE') { + return; + } + debugLogger.error( + `Sandbox Error: stdin error for '${filePath}': ${ + err instanceof Error ? err.message : String(err) + }`, + ); + }); + + child.stdin?.write(content); + child.stdin?.end(); + + let error = ''; + child.stderr?.on('data', (data) => { + error += data.toString(); + }); + + child.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject( + new Error( + `Sandbox Error: write_file failed for '${filePath}'. Exit code ${code}. ${error ? 'Details: ' + error : ''}`, + ), + ); + } + }); + + child.on('error', (err) => { + reject( + new Error( + `Sandbox Error: Failed to spawn write_file for '${filePath}': ${err.message}`, + ), + ); + }); + }); + } +} diff --git a/packages/core/src/services/scripts/GeminiSandbox.cs b/packages/core/src/services/scripts/GeminiSandbox.cs new file mode 100644 index 0000000000..8c3fc9de06 --- /dev/null +++ b/packages/core/src/services/scripts/GeminiSandbox.cs @@ -0,0 +1,370 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +using System; +using System.Runtime.InteropServices; +using System.Collections.Generic; +using System.Diagnostics; +using System.Security.Principal; +using System.IO; + +public class GeminiSandbox { + [StructLayout(LayoutKind.Sequential)] + public struct STARTUPINFO { + public uint cb; + public string lpReserved; + public string lpDesktop; + public string lpTitle; + public uint dwX; + public uint dwY; + public uint dwXSize; + public uint dwYSize; + public uint dwXCountChars; + public uint dwYCountChars; + public uint dwFillAttribute; + public uint dwFlags; + public ushort wShowWindow; + public ushort cbReserved2; + public IntPtr lpReserved2; + public IntPtr hStdInput; + public IntPtr hStdOutput; + public IntPtr hStdError; + } + + [StructLayout(LayoutKind.Sequential)] + public struct PROCESS_INFORMATION { + public IntPtr hProcess; + public IntPtr hThread; + public uint dwProcessId; + public uint dwThreadId; + } + + [StructLayout(LayoutKind.Sequential)] + public struct JOBOBJECT_BASIC_LIMIT_INFORMATION { + public Int64 PerProcessUserTimeLimit; + public Int64 PerJobUserTimeLimit; + public uint LimitFlags; + public UIntPtr MinimumWorkingSetSize; + public UIntPtr MaximumWorkingSetSize; + public uint ActiveProcessLimit; + public UIntPtr Affinity; + public uint PriorityClass; + public uint SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + public struct IO_COUNTERS { + public ulong ReadOperationCount; + public ulong WriteOperationCount; + public ulong OtherOperationCount; + public ulong ReadTransferCount; + public ulong WriteTransferCount; + public ulong OtherTransferCount; + } + + [StructLayout(LayoutKind.Sequential)] + public struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION { + public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation; + public IO_COUNTERS IoInfo; + public UIntPtr ProcessMemoryLimit; + public UIntPtr JobMemoryLimit; + public UIntPtr PeakProcessMemoryUsed; + public UIntPtr PeakJobMemoryUsed; + } + + [StructLayout(LayoutKind.Sequential)] + public struct SID_AND_ATTRIBUTES { + public IntPtr Sid; + public uint Attributes; + } + + [StructLayout(LayoutKind.Sequential)] + public struct TOKEN_MANDATORY_LABEL { + public SID_AND_ATTRIBUTES Label; + } + + public enum JobObjectInfoClass { + ExtendedLimitInformation = 9 + } + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern IntPtr GetCurrentProcess(); + + [DllImport("advapi32.dll", SetLastError = true)] + public static extern bool OpenProcessToken(IntPtr ProcessHandle, uint DesiredAccess, out IntPtr TokenHandle); + + [DllImport("advapi32.dll", SetLastError = true)] + public static extern bool CreateRestrictedToken(IntPtr ExistingTokenHandle, uint Flags, uint DisableSidCount, IntPtr SidsToDisable, uint DeletePrivilegeCount, IntPtr PrivilegesToDelete, uint RestrictedSidCount, IntPtr SidsToRestrict, out IntPtr NewTokenHandle); + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool CreateProcessAsUser(IntPtr hToken, string lpApplicationName, string lpCommandLine, IntPtr lpProcessAttributes, IntPtr lpThreadAttributes, bool bInheritHandles, uint dwCreationFlags, IntPtr lpEnvironment, string lpCurrentDirectory, ref STARTUPINFO lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string lpName); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool SetInformationJobObject(IntPtr hJob, JobObjectInfoClass JobObjectInfoClass, IntPtr lpJobObjectInfo, uint cbJobObjectInfoLength); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern uint ResumeThread(IntPtr hThread); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool CloseHandle(IntPtr hObject); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern IntPtr GetStdHandle(int nStdHandle); + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool ConvertStringSidToSid(string StringSid, out IntPtr Sid); + + [DllImport("advapi32.dll", SetLastError = true)] + public static extern bool SetTokenInformation(IntPtr TokenHandle, int TokenInformationClass, IntPtr TokenInformation, uint TokenInformationLength); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern IntPtr LocalFree(IntPtr hMem); + + public const uint TOKEN_DUPLICATE = 0x0002; + public const uint TOKEN_QUERY = 0x0008; + public const uint TOKEN_ASSIGN_PRIMARY = 0x0001; + public const uint TOKEN_ADJUST_DEFAULT = 0x0080; + public const uint DISABLE_MAX_PRIVILEGE = 0x1; + public const uint CREATE_SUSPENDED = 0x00000004; + public const uint CREATE_UNICODE_ENVIRONMENT = 0x00000400; + public const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000; + public const uint STARTF_USESTDHANDLES = 0x00000100; + public const int TokenIntegrityLevel = 25; + public const uint SE_GROUP_INTEGRITY = 0x00000020; + public const uint INFINITE = 0xFFFFFFFF; + + static int Main(string[] args) { + if (args.Length < 3) { + Console.WriteLine("Usage: GeminiSandbox.exe [args...]"); + Console.WriteLine("Internal commands: __read , __write "); + return 1; + } + + bool networkAccess = args[0] == "1"; + string cwd = args[1]; + string command = args[2]; + + IntPtr hToken = IntPtr.Zero; + IntPtr hRestrictedToken = IntPtr.Zero; + IntPtr hJob = IntPtr.Zero; + IntPtr pSidsToDisable = IntPtr.Zero; + IntPtr pSidsToRestrict = IntPtr.Zero; + IntPtr networkSid = IntPtr.Zero; + IntPtr restrictedSid = IntPtr.Zero; + IntPtr lowIntegritySid = IntPtr.Zero; + + try { + // 1. Setup Token + IntPtr hCurrentProcess = GetCurrentProcess(); + if (!OpenProcessToken(hCurrentProcess, TOKEN_DUPLICATE | TOKEN_QUERY | TOKEN_ASSIGN_PRIMARY | TOKEN_ADJUST_DEFAULT, out hToken)) { + Console.Error.WriteLine("Failed to open process token"); + return 1; + } + + uint sidCount = 0; + uint restrictCount = 0; + + // "networkAccess == false" implies Strict Sandbox Level 1. + if (!networkAccess) { + if (ConvertStringSidToSid("S-1-5-2", out networkSid)) { + sidCount = 1; + int saaSize = Marshal.SizeOf(typeof(SID_AND_ATTRIBUTES)); + pSidsToDisable = Marshal.AllocHGlobal(saaSize); + SID_AND_ATTRIBUTES saa = new SID_AND_ATTRIBUTES(); + saa.Sid = networkSid; + saa.Attributes = 0; + Marshal.StructureToPtr(saa, pSidsToDisable, false); + } + + // S-1-5-12 is Restricted Code SID + if (ConvertStringSidToSid("S-1-5-12", out restrictedSid)) { + restrictCount = 1; + int saaSize = Marshal.SizeOf(typeof(SID_AND_ATTRIBUTES)); + pSidsToRestrict = Marshal.AllocHGlobal(saaSize); + SID_AND_ATTRIBUTES saa = new SID_AND_ATTRIBUTES(); + saa.Sid = restrictedSid; + saa.Attributes = 0; + Marshal.StructureToPtr(saa, pSidsToRestrict, false); + } + } + + if (!CreateRestrictedToken(hToken, DISABLE_MAX_PRIVILEGE, sidCount, pSidsToDisable, 0, IntPtr.Zero, restrictCount, pSidsToRestrict, out hRestrictedToken)) { + Console.Error.WriteLine("Failed to create restricted token"); + return 1; + } + + // 2. Set Integrity Level to Low + if (ConvertStringSidToSid("S-1-16-4096", out lowIntegritySid)) { + TOKEN_MANDATORY_LABEL tml = new TOKEN_MANDATORY_LABEL(); + tml.Label.Sid = lowIntegritySid; + tml.Label.Attributes = SE_GROUP_INTEGRITY; + int tmlSize = Marshal.SizeOf(tml); + IntPtr pTml = Marshal.AllocHGlobal(tmlSize); + try { + Marshal.StructureToPtr(tml, pTml, false); + SetTokenInformation(hRestrictedToken, TokenIntegrityLevel, pTml, (uint)tmlSize); + } finally { + Marshal.FreeHGlobal(pTml); + } + } + + // 3. Handle Internal Commands or External Process + if (command == "__read") { + string path = args[3]; + return RunInImpersonation(hRestrictedToken, () => { + try { + using (FileStream fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) + using (StreamReader sr = new StreamReader(fs, System.Text.Encoding.UTF8)) { + char[] buffer = new char[4096]; + int bytesRead; + while ((bytesRead = sr.Read(buffer, 0, buffer.Length)) > 0) { + Console.Write(buffer, 0, bytesRead); + } + } + return 0; + } catch (Exception e) { + Console.Error.WriteLine(e.Message); + return 1; + } + }); + } else if (command == "__write") { + string path = args[3]; + return RunInImpersonation(hRestrictedToken, () => { + try { + using (StreamReader reader = new StreamReader(Console.OpenStandardInput(), System.Text.Encoding.UTF8)) + using (FileStream fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None)) + using (StreamWriter writer = new StreamWriter(fs, System.Text.Encoding.UTF8)) { + char[] buffer = new char[4096]; + int bytesRead; + while ((bytesRead = reader.Read(buffer, 0, buffer.Length)) > 0) { + writer.Write(buffer, 0, bytesRead); + } + } + return 0; + } catch (Exception e) { + Console.Error.WriteLine(e.Message); + return 1; + } + }); + } + + // 4. Setup Job Object for external process + hJob = CreateJobObject(IntPtr.Zero, null); + if (hJob != IntPtr.Zero) { + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limitInfo = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION(); + limitInfo.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + int limitSize = Marshal.SizeOf(limitInfo); + IntPtr pLimit = Marshal.AllocHGlobal(limitSize); + try { + Marshal.StructureToPtr(limitInfo, pLimit, false); + SetInformationJobObject(hJob, JobObjectInfoClass.ExtendedLimitInformation, pLimit, (uint)limitSize); + } finally { + Marshal.FreeHGlobal(pLimit); + } + } + + // 5. Launch Process + STARTUPINFO si = new STARTUPINFO(); + si.cb = (uint)Marshal.SizeOf(si); + si.dwFlags = STARTF_USESTDHANDLES; + si.hStdInput = GetStdHandle(-10); + si.hStdOutput = GetStdHandle(-11); + si.hStdError = GetStdHandle(-12); + + string commandLine = ""; + for (int i = 2; i < args.Length; i++) { + if (i > 2) commandLine += " "; + commandLine += QuoteArgument(args[i]); + } + + PROCESS_INFORMATION pi; + if (!CreateProcessAsUser(hRestrictedToken, null, commandLine, IntPtr.Zero, IntPtr.Zero, true, CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT, IntPtr.Zero, cwd, ref si, out pi)) { + Console.Error.WriteLine("Failed to create process. Error: " + Marshal.GetLastWin32Error()); + return 1; + } + + try { + if (hJob != IntPtr.Zero) { + AssignProcessToJobObject(hJob, pi.hProcess); + } + + ResumeThread(pi.hThread); + WaitForSingleObject(pi.hProcess, INFINITE); + + uint exitCode = 0; + GetExitCodeProcess(pi.hProcess, out exitCode); + return (int)exitCode; + } finally { + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + } + } catch (Exception e) { + Console.Error.WriteLine("Unexpected error: " + e.Message); + return 1; + } finally { + if (hRestrictedToken != IntPtr.Zero) CloseHandle(hRestrictedToken); + if (hToken != IntPtr.Zero) CloseHandle(hToken); + if (hJob != IntPtr.Zero) CloseHandle(hJob); + if (pSidsToDisable != IntPtr.Zero) Marshal.FreeHGlobal(pSidsToDisable); + if (pSidsToRestrict != IntPtr.Zero) Marshal.FreeHGlobal(pSidsToRestrict); + if (networkSid != IntPtr.Zero) LocalFree(networkSid); + if (restrictedSid != IntPtr.Zero) LocalFree(restrictedSid); + if (lowIntegritySid != IntPtr.Zero) LocalFree(lowIntegritySid); + } + } + + private static string QuoteArgument(string arg) { + if (string.IsNullOrEmpty(arg)) return "\"\""; + + bool hasSpace = arg.IndexOfAny(new char[] { ' ', '\t' }) != -1; + if (!hasSpace && arg.IndexOf('\"') == -1) return arg; + + // Windows command line escaping for arguments is complex. + // Rule: Backslashes only need escaping if they precede a double quote or the end of the string. + System.Text.StringBuilder sb = new System.Text.StringBuilder(); + sb.Append('\"'); + for (int i = 0; i < arg.Length; i++) { + int backslashCount = 0; + while (i < arg.Length && arg[i] == '\\') { + backslashCount++; + i++; + } + + if (i == arg.Length) { + // Escape backslashes before the closing double quote + sb.Append('\\', backslashCount * 2); + } else if (arg[i] == '\"') { + // Escape backslashes before a literal double quote + sb.Append('\\', backslashCount * 2 + 1); + sb.Append('\"'); + } else { + // Backslashes don't need escaping here + sb.Append('\\', backslashCount); + sb.Append(arg[i]); + } + } + sb.Append('\"'); + return sb.ToString(); + } + + private static int RunInImpersonation(IntPtr hToken, Func action) { + using (WindowsIdentity.Impersonate(hToken)) { + return action(); + } + } +} diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index 47601172ac..e96cf7e037 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -27,8 +27,12 @@ import { serializeTerminalToObject, type AnsiOutput, } from '../utils/terminalSerializer.js'; -import { type EnvironmentSanitizationConfig } from './environmentSanitization.js'; -import { type SandboxManager } from './sandboxManager.js'; +import { + sanitizeEnvironment, + type EnvironmentSanitizationConfig, +} from './environmentSanitization.js'; +import { NoopSandboxManager, type SandboxManager } from './sandboxManager.js'; +import type { SandboxConfig } from '../config/config.js'; import { killProcessGroup } from '../utils/process-utils.js'; import { ExecutionLifecycleService, @@ -92,6 +96,7 @@ export interface ShellExecutionConfig { disableDynamicLineTrimming?: boolean; scrollback?: number; maxSerializedLines?: number; + sandboxConfig?: SandboxConfig; } /** @@ -331,37 +336,119 @@ export class ShellExecutionService { } private static async prepareExecution( - executable: string, - args: string[], + commandToExecute: string, cwd: string, - env: NodeJS.ProcessEnv, shellExecutionConfig: ShellExecutionConfig, - sanitizationConfigOverride?: EnvironmentSanitizationConfig, + isInteractive: boolean, ): Promise<{ program: string; args: string[]; - env: NodeJS.ProcessEnv; + env: Record; cwd: string; }> { + const sandboxManager = + shellExecutionConfig.sandboxManager ?? new NoopSandboxManager(); + + // 1. Determine Shell Configuration + const isWindows = os.platform() === 'win32'; + const isStrictSandbox = + isWindows && + shellExecutionConfig.sandboxConfig?.enabled && + shellExecutionConfig.sandboxConfig?.command === 'windows-native' && + !shellExecutionConfig.sandboxConfig?.networkAccess; + + let { executable, argsPrefix, shell } = getShellConfiguration(); + if (isStrictSandbox) { + shell = 'cmd'; + argsPrefix = ['/c']; + executable = 'cmd.exe'; + } + const resolvedExecutable = (await resolveExecutable(executable)) ?? executable; - const prepared = await shellExecutionConfig.sandboxManager.prepareCommand({ + const guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell); + const spawnArgs = [...argsPrefix, guardedCommand]; + + // 2. Prepare Environment + const gitConfigKeys: string[] = []; + if (!isInteractive) { + for (const key in process.env) { + if (key.startsWith('GIT_CONFIG_')) { + gitConfigKeys.push(key); + } + } + } + + const sanitizationConfig = { + ...shellExecutionConfig.sanitizationConfig, + allowedEnvironmentVariables: [ + ...(shellExecutionConfig.sanitizationConfig + .allowedEnvironmentVariables || []), + ...gitConfigKeys, + ], + }; + + const sanitizedEnv = sanitizeEnvironment(process.env, sanitizationConfig); + + const baseEnv: Record = { + ...sanitizedEnv, + [GEMINI_CLI_IDENTIFICATION_ENV_VAR]: + GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE, + TERM: 'xterm-256color', + PAGER: shellExecutionConfig.pager ?? 'cat', + GIT_PAGER: shellExecutionConfig.pager ?? 'cat', + }; + + if (!isInteractive) { + // Ensure all GIT_CONFIG_* variables are preserved even if they were redacted + for (const key of gitConfigKeys) { + baseEnv[key] = process.env[key]; + } + + const gitConfigCount = parseInt(baseEnv['GIT_CONFIG_COUNT'] || '0', 10); + const newKey = `GIT_CONFIG_KEY_${gitConfigCount}`; + const newValue = `GIT_CONFIG_VALUE_${gitConfigCount}`; + + // Ensure these new keys are allowed through sanitization + sanitizationConfig.allowedEnvironmentVariables.push( + 'GIT_CONFIG_COUNT', + newKey, + newValue, + ); + + Object.assign(baseEnv, { + GIT_TERMINAL_PROMPT: '0', + GIT_ASKPASS: '', + SSH_ASKPASS: '', + GH_PROMPT_DISABLED: '1', + GCM_INTERACTIVE: 'never', + DISPLAY: '', + DBUS_SESSION_BUS_ADDRESS: '', + GIT_CONFIG_COUNT: (gitConfigCount + 1).toString(), + [newKey]: 'credential.helper', + [newValue]: '', + }); + } + + // 3. Prepare Sandboxed Command + const sandboxedCommand = await sandboxManager.prepareCommand({ command: resolvedExecutable, - args, + args: spawnArgs, + env: baseEnv, cwd, - env, config: { - sanitizationConfig: - sanitizationConfigOverride ?? shellExecutionConfig.sanitizationConfig, + ...shellExecutionConfig, + ...(shellExecutionConfig.sandboxConfig || {}), + sanitizationConfig, }, }); return { - program: prepared.program, - args: prepared.args, - env: prepared.env, - cwd: prepared.cwd ?? cwd, + program: sandboxedCommand.program, + args: sandboxedCommand.args, + env: sandboxedCommand.env, + cwd: sandboxedCommand.cwd ?? cwd, }; } @@ -375,70 +462,19 @@ export class ShellExecutionService { ): Promise { try { const isWindows = os.platform() === 'win32'; - const { executable, argsPrefix, shell } = getShellConfiguration(); - const guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell); - const spawnArgs = [...argsPrefix, guardedCommand]; - - // Specifically allow GIT_CONFIG_* variables to pass through sanitization - // in non-interactive mode so we can safely append our overrides. - const gitConfigKeys = !isInteractive - ? Object.keys(process.env).filter((k) => k.startsWith('GIT_CONFIG_')) - : []; - const localSanitizationConfig = { - ...shellExecutionConfig.sanitizationConfig, - allowedEnvironmentVariables: [ - ...(shellExecutionConfig.sanitizationConfig - .allowedEnvironmentVariables || []), - ...gitConfigKeys, - ], - }; - - const env = { - ...process.env, - [GEMINI_CLI_IDENTIFICATION_ENV_VAR]: - GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE, - TERM: 'xterm-256color', - PAGER: 'cat', - GIT_PAGER: 'cat', - }; const { program: finalExecutable, args: finalArgs, - env: sanitizedEnv, + env: finalEnv, cwd: finalCwd, } = await this.prepareExecution( - executable, - spawnArgs, + commandToExecute, cwd, - env, shellExecutionConfig, - localSanitizationConfig, + isInteractive, ); - const finalEnv = { ...sanitizedEnv }; - - if (!isInteractive) { - const gitConfigCount = parseInt( - finalEnv['GIT_CONFIG_COUNT'] || '0', - 10, - ); - Object.assign(finalEnv, { - // Disable interactive prompts and session-linked credential helpers - // in non-interactive mode to prevent hangs in detached process groups. - GIT_TERMINAL_PROMPT: '0', - GIT_ASKPASS: '', - SSH_ASKPASS: '', - GH_PROMPT_DISABLED: '1', - GCM_INTERACTIVE: 'never', - DISPLAY: '', - DBUS_SESSION_BUS_ADDRESS: '', - GIT_CONFIG_COUNT: (gitConfigCount + 1).toString(), - [`GIT_CONFIG_KEY_${gitConfigCount}`]: 'credential.helper', - [`GIT_CONFIG_VALUE_${gitConfigCount}`]: '', - }); - } - const child = cpSpawn(finalExecutable, finalArgs, { cwd: finalCwd, stdio: ['ignore', 'pipe', 'pipe'], @@ -732,32 +768,6 @@ export class ShellExecutionService { try { const cols = shellExecutionConfig.terminalWidth ?? 80; const rows = shellExecutionConfig.terminalHeight ?? 30; - const { executable, argsPrefix, shell } = getShellConfiguration(); - - const guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell); - const args = [...argsPrefix, guardedCommand]; - - const env = { - ...process.env, - GEMINI_CLI: '1', - TERM: 'xterm-256color', - PAGER: shellExecutionConfig.pager ?? 'cat', - GIT_PAGER: shellExecutionConfig.pager ?? 'cat', - }; - - // Specifically allow GIT_CONFIG_* variables to pass through sanitization - // so we can safely append our overrides if needed. - const gitConfigKeys = Object.keys(process.env).filter((k) => - k.startsWith('GIT_CONFIG_'), - ); - const localSanitizationConfig = { - ...shellExecutionConfig.sanitizationConfig, - allowedEnvironmentVariables: [ - ...(shellExecutionConfig.sanitizationConfig - ?.allowedEnvironmentVariables ?? []), - ...gitConfigKeys, - ], - }; const { program: finalExecutable, @@ -765,12 +775,10 @@ export class ShellExecutionService { env: finalEnv, cwd: finalCwd, } = await this.prepareExecution( - executable, - args, + commandToExecute, cwd, - env, shellExecutionConfig, - localSanitizationConfig, + true, ); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -782,6 +790,7 @@ export class ShellExecutionService { env: finalEnv, handleFlowControl: true, }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion spawnedPty = ptyProcess as IPty; const ptyPid = Number(ptyProcess.pid); diff --git a/packages/core/src/services/windowsSandboxManager.test.ts b/packages/core/src/services/windowsSandboxManager.test.ts new file mode 100644 index 0000000000..6bec183410 --- /dev/null +++ b/packages/core/src/services/windowsSandboxManager.test.ts @@ -0,0 +1,68 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { WindowsSandboxManager } from './windowsSandboxManager.js'; +import type { SandboxRequest } from './sandboxManager.js'; + +describe('WindowsSandboxManager', () => { + const manager = new WindowsSandboxManager('win32'); + + 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.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 new file mode 100644 index 0000000000..dc39b9ee67 --- /dev/null +++ b/packages/core/src/services/windowsSandboxManager.ts @@ -0,0 +1,228 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { + SandboxManager, + SandboxRequest, + SandboxedCommand, +} from './sandboxManager.js'; +import { + sanitizeEnvironment, + type EnvironmentSanitizationConfig, +} from './environmentSanitization.js'; +import { debugLogger } from '../utils/debugLogger.js'; +import { spawnAsync } from '../utils/shell-utils.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** + * A SandboxManager implementation for Windows that uses Restricted Tokens, + * Job Objects, and Low Integrity levels for process isolation. + * Uses a native C# helper to bypass PowerShell restrictions. + */ +export class WindowsSandboxManager implements SandboxManager { + private readonly helperPath: string; + private readonly platform: string; + private initialized = false; + private readonly lowIntegrityCache = new Set(); + + constructor(platform: string = process.platform) { + this.platform = platform; + this.helperPath = path.resolve(__dirname, 'scripts', 'GeminiSandbox.exe'); + } + + private async ensureInitialized(): Promise { + if (this.initialized) return; + if (this.platform !== 'win32') { + this.initialized = true; + return; + } + + try { + if (!fs.existsSync(this.helperPath)) { + debugLogger.log( + `WindowsSandboxManager: Helper not found at ${this.helperPath}. Attempting to compile...`, + ); + // If the exe doesn't exist, we try to compile it from the .cs file + const sourcePath = this.helperPath.replace(/\.exe$/, '.cs'); + if (fs.existsSync(sourcePath)) { + const systemRoot = process.env['SystemRoot'] || 'C:\\Windows'; + const cscPaths = [ + 'csc.exe', // Try in PATH first + path.join( + systemRoot, + 'Microsoft.NET', + 'Framework64', + 'v4.0.30319', + 'csc.exe', + ), + path.join( + systemRoot, + 'Microsoft.NET', + 'Framework', + 'v4.0.30319', + 'csc.exe', + ), + // Added newer framework paths + path.join( + systemRoot, + 'Microsoft.NET', + 'Framework64', + 'v4.8', + 'csc.exe', + ), + path.join( + systemRoot, + 'Microsoft.NET', + 'Framework', + 'v4.8', + 'csc.exe', + ), + path.join( + systemRoot, + 'Microsoft.NET', + 'Framework64', + 'v3.5', + 'csc.exe', + ), + ]; + + let compiled = false; + for (const csc of cscPaths) { + try { + debugLogger.log( + `WindowsSandboxManager: Trying to compile using ${csc}...`, + ); + // We use spawnAsync but we don't need to capture output + await spawnAsync(csc, ['/out:' + this.helperPath, sourcePath]); + debugLogger.log( + `WindowsSandboxManager: Successfully compiled sandbox helper at ${this.helperPath}`, + ); + compiled = true; + break; + } catch (e) { + debugLogger.log( + `WindowsSandboxManager: Failed to compile using ${csc}: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + + if (!compiled) { + debugLogger.log( + 'WindowsSandboxManager: Failed to compile sandbox helper from any known CSC path.', + ); + } + } else { + debugLogger.log( + `WindowsSandboxManager: Source file not found at ${sourcePath}. Cannot compile helper.`, + ); + } + } else { + debugLogger.log( + `WindowsSandboxManager: Found helper at ${this.helperPath}`, + ); + } + } catch (e) { + debugLogger.log( + 'WindowsSandboxManager: Failed to initialize sandbox helper:', + e, + ); + } + + this.initialized = true; + } + + /** + * Prepares a command for sandboxed execution on Windows. + */ + async prepareCommand(req: SandboxRequest): Promise { + await this.ensureInitialized(); + + const sanitizationConfig: EnvironmentSanitizationConfig = { + allowedEnvironmentVariables: + req.config?.sanitizationConfig?.allowedEnvironmentVariables ?? [], + blockedEnvironmentVariables: + req.config?.sanitizationConfig?.blockedEnvironmentVariables ?? [], + enableEnvironmentVariableRedaction: + req.config?.sanitizationConfig?.enableEnvironmentVariableRedaction ?? + true, + }; + + const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig); + + // 1. Handle filesystem permissions for Low Integrity + // Grant "Low Mandatory Level" write access to the CWD. + await this.grantLowIntegrityAccess(req.cwd); + + // Grant "Low Mandatory Level" read access to allowedPaths. + if (req.config?.allowedPaths) { + for (const allowedPath of req.config.allowedPaths) { + await this.grantLowIntegrityAccess(allowedPath); + } + } + + // 2. Construct the helper command + // GeminiSandbox.exe [args...] + const program = this.helperPath; + + // If the command starts with __, it's an internal command for the sandbox helper itself. + const args = [ + req.config?.networkAccess ? '1' : '0', + req.cwd, + req.command, + ...req.args, + ]; + + return { + program, + args, + env: sanitizedEnv, + }; + } + + /** + * Grants "Low Mandatory Level" access to a path using icacls. + */ + private async grantLowIntegrityAccess(targetPath: string): Promise { + if (this.platform !== 'win32') { + return; + } + + const resolvedPath = path.resolve(targetPath); + if (this.lowIntegrityCache.has(resolvedPath)) { + return; + } + + // Never modify integrity levels for system directories + const systemRoot = process.env['SystemRoot'] || 'C:\\Windows'; + const programFiles = process.env['ProgramFiles'] || 'C:\\Program Files'; + const programFilesX86 = + process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'; + + if ( + resolvedPath.toLowerCase().startsWith(systemRoot.toLowerCase()) || + resolvedPath.toLowerCase().startsWith(programFiles.toLowerCase()) || + resolvedPath.toLowerCase().startsWith(programFilesX86.toLowerCase()) + ) { + return; + } + + try { + await spawnAsync('icacls', [resolvedPath, '/setintegritylevel', 'Low']); + this.lowIntegrityCache.add(resolvedPath); + } catch (e) { + debugLogger.log( + 'WindowsSandboxManager: icacls failed for', + resolvedPath, + e, + ); + } + } +} diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index a6f507ae63..17409313ce 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -2251,10 +2251,27 @@ "properties": { "sandbox": { "title": "Sandbox", - "description": "Legacy full-process 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": "Legacy full-process 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`", + "description": "Legacy full-process 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\", \"windows-native\").", + "markdownDescription": "Legacy full-process 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\", \"windows-native\").\n\n- Category: `Tools`\n- Requires restart: `yes`", "$ref": "#/$defs/BooleanOrStringOrObject" }, + "sandboxAllowedPaths": { + "title": "Sandbox Allowed Paths", + "description": "List of additional paths that the sandbox is allowed to access.", + "markdownDescription": "List of additional paths that the sandbox is allowed to access.\n\n- Category: `Tools`\n- Requires restart: `yes`\n- Default: `[]`", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "sandboxNetworkAccess": { + "title": "Sandbox Network Access", + "description": "Whether the sandbox is allowed to access the network.", + "markdownDescription": "Whether the sandbox is allowed to access the network.\n\n- Category: `Tools`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, + "type": "boolean" + }, "shell": { "title": "Shell", "description": "Settings for shell execution.", diff --git a/scripts/copy_files.js b/scripts/copy_files.js index fc612fd144..d02070362f 100644 --- a/scripts/copy_files.js +++ b/scripts/copy_files.js @@ -26,7 +26,7 @@ import path from 'node:path'; const sourceDir = path.join('src'); const targetDir = path.join('dist', 'src'); -const extensionsToCopy = ['.md', '.json', '.sb', '.toml']; +const extensionsToCopy = ['.md', '.json', '.sb', '.toml', '.cs', '.exe']; function copyFilesRecursive(source, target) { if (!fs.existsSync(target)) {