chore(core): add build script for Windows sandbox helper

This commit is contained in:
mkorwel
2026-03-09 19:55:47 -07:00
parent 1cb703b405
commit 5c0b0f98ec
8 changed files with 190 additions and 20 deletions

View File

@@ -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;
}

View File

@@ -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',

View File

@@ -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);
}

View File

@@ -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": [

View File

@@ -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();

View File

@@ -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 : ''}`));
}
});
});

View File

@@ -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");

View File

@@ -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,