diff --git a/packages/cli/src/config/sandboxConfig.ts b/packages/cli/src/config/sandboxConfig.ts index e97f4d41a8..a37d8341c2 100644 --- a/packages/cli/src/config/sandboxConfig.ts +++ b/packages/cli/src/config/sandboxConfig.ts @@ -131,8 +131,26 @@ export async function loadSandboxConfig( process.env['GEMINI_SANDBOX_IMAGE_DEFAULT'] ?? packageJson?.config?.sandboxImageUri; + const networkAccess = + process.env['GEMINI_SANDBOX_NETWORK'] === 'true' || + settings.tools?.sandboxNetworkAccess === true; + + const allowedPathsEnv = process.env['GEMINI_SANDBOX_ALLOWED_PATHS'] + ?.split(',') + .map((p) => p.trim()) + .filter((p) => p.length > 0); + + const allowedPaths = + allowedPathsEnv ?? settings.tools?.sandboxAllowedPaths ?? []; + return command && (image || command === 'sandbox-exec' || command === 'windows-native') - ? { enabled: true, allowedPaths: [], networkAccess: false, 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 bd1f9d82a4..06fb468236 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1261,6 +1261,26 @@ const SETTINGS_SCHEMA = { `, showInDialog: false, }, + sandboxNetworkAccess: { + type: 'boolean', + label: 'Sandbox Network Access', + category: 'Tools', + requiresRestart: true, + default: false, + description: 'Whether the sandbox has outbound network access.', + showInDialog: true, + }, + sandboxAllowedPaths: { + type: 'array', + label: 'Sandbox Allowed Paths', + category: 'Tools', + requiresRestart: true, + default: [] as string[], + description: 'Additional host paths to allow the sandbox to access.', + showInDialog: true, + items: { type: 'string' }, + mergeStrategy: MergeStrategy.UNION, + }, shell: { type: 'object', label: 'Shell', diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index 1b138cf4b5..f6fc8cdf87 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -211,6 +211,28 @@ export async function start_sandbox( }); } + if (config.command === 'windows-native') { + debugLogger.log('using native windows sandboxing ...'); + // process.argv is [node, script, ...args] + // We want to skip the first element (node) when calling spawn(process.execPath, ...) + const finalArgv = cliArgs.slice(1); + + const child = spawn(process.execPath, finalArgv, { + stdio: 'inherit', + env: { + ...process.env, + SANDBOX: 'windows-native', + }, + }); + + return await new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', (code) => { + resolve(code ?? 1); + }); + }); + } + if (config.command === 'lxc') { return await start_lxc_sandbox(config, nodeArgs, cliArgs); } diff --git a/packages/core/package.json b/packages/core/package.json index 827c09bc61..59cd2181bc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -15,6 +15,7 @@ "format": "prettier --write .", "test": "vitest run", "test:ci": "vitest run", + "prepare": "node scripts/compile-windows-sandbox.js", "typecheck": "tsc --noEmit" }, "files": [ diff --git a/packages/core/scripts/compile-windows-sandbox.js b/packages/core/scripts/compile-windows-sandbox.js new file mode 100644 index 0000000000..d7aae88db9 --- /dev/null +++ b/packages/core/scripts/compile-windows-sandbox.js @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +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(): void { + if (os.platform() !== 'win32') { + return; + } + + const helperPath = path.resolve(__dirname, '../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; + } + + // Find csc.exe (C# Compiler) which is built into Windows .NET Framework + const systemRoot = process.env['SystemRoot'] || 'C:\\Windows'; + const cscPaths = [ + path.join(systemRoot, 'Microsoft.NET', 'Framework64', 'v4.0.30319', 'csc.exe'), + path.join(systemRoot, 'Microsoft.NET', 'Framework', 'v4.0.30319', 'csc.exe'), + ]; + + const csc = cscPaths.find(p => fs.existsSync(p)); + + 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...`); + const result = spawnSync(csc, [`/out:${helperPath}`, '/optimize', sourcePath], { + stdio: 'inherit', + }); + + if (result.status !== 0) { + console.error('Failed to compile Windows sandbox helper.'); + } else { + console.log('Successfully compiled GeminiSandbox.exe'); + } +} + +compileWindowsSandbox(); diff --git a/packages/core/src/services/sandboxedFileSystemService.ts b/packages/core/src/services/sandboxedFileSystemService.ts index 0976095fc0..1e058348dc 100644 --- a/packages/core/src/services/sandboxedFileSystemService.ts +++ b/packages/core/src/services/sandboxedFileSystemService.ts @@ -46,7 +46,7 @@ export class SandboxedFileSystemService implements FileSystemService { if (code === 0) { resolve(output); } else { - reject(new Error(`Failed to read file via sandbox: ${error || 'Unknown error'}`)); + reject(new Error(`Sandbox Error: Command failed with exit code ${code}. ${error ? 'Details: ' + error : ''}`)); } }); }); @@ -78,7 +78,7 @@ export class SandboxedFileSystemService implements FileSystemService { if (code === 0) { resolve(); } else { - reject(new Error(`Failed to write file via sandbox: ${error || 'Unknown error'}`)); + reject(new Error(`Sandbox Error: Command failed with exit code ${code}. ${error ? 'Details: ' + error : ''}`)); } }); }); diff --git a/packages/core/src/services/scripts/GeminiSandbox.cs b/packages/core/src/services/scripts/GeminiSandbox.cs index f16d6030a1..b78066c724 100644 --- a/packages/core/src/services/scripts/GeminiSandbox.cs +++ b/packages/core/src/services/scripts/GeminiSandbox.cs @@ -162,6 +162,12 @@ public class GeminiSandbox { IntPtr pSidsToDisable = IntPtr.Zero; uint sidCount = 0; + IntPtr pSidsToRestrict = IntPtr.Zero; + uint restrictCount = 0; + + // "networkAccess == false" implies Strict Sandbox Level 1. + // In Strict mode, we strip the Network SID and apply the Restricted Code SID. + // This blocks network access and restricts file reads, but requires cmd.exe. if (!networkAccess) { IntPtr networkSid; if (ConvertStringSidToSid("S-1-5-2", out networkSid)) { @@ -173,20 +179,21 @@ public class GeminiSandbox { saa.Attributes = 0; Marshal.StructureToPtr(saa, pSidsToDisable, false); } - } - IntPtr pSidsToRestrict = IntPtr.Zero; - uint restrictCount = 0; - IntPtr restrictedSid; - 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); + IntPtr restrictedSid; + // 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 networkAccess == true, we are in Elevated mode (Level 2). + // We only strip privileges (DISABLE_MAX_PRIVILEGE), allowing network and powershell. if (!CreateRestrictedToken(hToken, DISABLE_MAX_PRIVILEGE, sidCount, pSidsToDisable, 0, IntPtr.Zero, restrictCount, pSidsToRestrict, out hRestrictedToken)) { Console.Error.WriteLine("Failed to create restricted token"); diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index 170addabf4..b39e201c70 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -326,9 +326,26 @@ export class ShellExecutionService { try { const isWindows = os.platform() === 'win32'; const { executable, argsPrefix, shell } = getShellConfiguration(); - const guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell); - const spawnArgs = [...argsPrefix, guardedCommand]; + // In Strict Sandbox mode (networkAccess = false), PowerShell will fail to load + // its required system DLLs due to the Restricted Code SID. + // We must fallback to cmd.exe for strict sandboxing. + const isStrictSandbox = + shellExecutionConfig.sandboxConfig?.enabled && + shellExecutionConfig.sandboxConfig?.command === 'windows-native' && + !shellExecutionConfig.sandboxConfig?.networkAccess; + const finalShell = isStrictSandbox ? 'cmd' : shell; + let finalArgsPrefix: string[] = []; + if (finalShell === 'cmd') { + finalArgsPrefix = ['/c']; + } else { + finalArgsPrefix = argsPrefix; + } + + // We still use the original executable logic (e.g. searching for git) + // but the guard command formatting is based on the final shell. + const guardedCommand = ensurePromptvarsDisabled(commandToExecute, finalShell as ShellType); + const spawnArgs = [...finalArgsPrefix, guardedCommand]; const env = { ...process.env, [GEMINI_CLI_IDENTIFICATION_ENV_VAR]: @@ -456,6 +473,12 @@ export class ShellExecutionService { let combinedOutput = state.output; + const isSandboxError = code === 3221225781 || code === -1073741515; // 0xC0000135 + if (isSandboxError && shellExecutionConfig.sandboxConfig?.enabled) { + const sandboxMessage = `\\n[GEMINI_CLI_SANDBOX_ERROR: Command execution was blocked by the native Windows sandbox. This typically means the command attempted an unauthorized network request or file access.]`; + combinedOutput += sandboxMessage; + } + if (state.truncated) { const truncationMessage = `\n[GEMINI_CLI_WARNING: Output truncated. The buffer is limited to ${ MAX_CHILD_PROCESS_BUFFER_SIZE / (1024 * 1024) @@ -595,8 +618,21 @@ export class ShellExecutionService { const rows = shellExecutionConfig.terminalHeight ?? 30; const { executable, argsPrefix, shell } = getShellConfiguration(); - const guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell); - const args = [...argsPrefix, guardedCommand]; + const isStrictSandbox = + shellExecutionConfig.sandboxConfig?.enabled && + shellExecutionConfig.sandboxConfig?.command === 'windows-native' && + !shellExecutionConfig.sandboxConfig?.networkAccess; + + const finalShell = isStrictSandbox ? 'cmd' : shell; + let finalArgsPrefix: string[] = []; + if (finalShell === 'cmd') { + finalArgsPrefix = ['/c']; + } else { + finalArgsPrefix = argsPrefix; + } + + const guardedCommand = ensurePromptvarsDisabled(commandToExecute, finalShell as ShellType); + const args = [...finalArgsPrefix, guardedCommand]; const env = { ...process.env, @@ -846,6 +882,13 @@ export class ShellExecutionService { // Store exit info for late subscribers (e.g. backgrounding race condition) this.exitedPtyInfo.set(ptyProcess.pid, { exitCode, signal }); + + const isSandboxError = exitCode === 3221225781 || exitCode === -1073741515; // 0xC0000135 + let finalOutput = getFullBufferText(headlessTerminal); + if (isSandboxError && shellExecutionConfig.sandboxConfig?.enabled) { + finalOutput += `\n[GEMINI_CLI_SANDBOX_ERROR: Command execution was blocked by the native Windows sandbox. This typically means the command attempted an unauthorized network request or file access.]`; + } + setTimeout( () => { this.exitedPtyInfo.delete(ptyProcess.pid); @@ -869,7 +912,7 @@ export class ShellExecutionService { resolve({ rawOutput: finalBuffer, - output: getFullBufferText(headlessTerminal), + output: finalOutput, exitCode, signal: signal ?? null, error,