feat(core): implement native Windows sandboxing with restricted tokens

This commit is contained in:
mkorwel
2026-03-13 16:53:45 +00:00
parent aa000d7d30
commit 1425e94975
6 changed files with 776 additions and 15 deletions

View File

@@ -0,0 +1,119 @@
/**
* @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() {
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();

View File

@@ -6,6 +6,7 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { inspect } from 'node:util';
import process from 'node:process';
import { z } from 'zod';
@@ -41,6 +42,11 @@ import { LocalLiteRtLmClient } from '../core/localLiteRtLmClient.js';
import type { HookDefinition, HookEventName } from '../hooks/types.js';
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
import { GitService } from '../services/gitService.js';
import {
NoopSandboxManager,
type SandboxManager,
} from '../services/sandboxManager.js';
import { WindowsSandboxManager } from '../services/windowsSandboxManager.js';
import {
initializeTelemetry,
DEFAULT_TELEMETRY_TARGET,
@@ -70,6 +76,7 @@ import {
StandardFileSystemService,
type FileSystemService,
} from '../services/fileSystemService.js';
import { SandboxedFileSystemService } from '../services/sandboxedFileSystemService.js';
import {
TrackerCreateTaskTool,
TrackerUpdateTaskTool,
@@ -454,9 +461,15 @@ export enum AuthProviderType {
export interface SandboxConfig {
enabled: boolean;
allowedPaths?: string[];
networkAccess?: boolean;
command?: 'docker' | 'podman' | 'sandbox-exec' | 'runsc' | 'lxc';
allowedPaths: string[];
networkAccess: boolean;
command?:
| 'docker'
| 'podman'
| 'sandbox-exec'
| 'runsc'
| 'lxc'
| 'windows-native';
image?: string;
}
@@ -467,19 +480,17 @@ 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(),
})
.superRefine((data, ctx) => {
if (data.enabled && !data.command) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Sandbox command is required when sandbox is enabled',
path: ['command'],
});
}
})
.optional(),
});
@@ -684,6 +695,7 @@ export class Config implements McpContext, AgentLoopContext {
private readonly telemetrySettings: TelemetrySettings;
private readonly usageStatisticsEnabled: boolean;
private _geminiClient!: GeminiClient;
private readonly _sandboxManager: SandboxManager;
private baseLlmClient!: BaseLlmClient;
private localLiteRtLmClient?: LocalLiteRtLmClient;
private modelRouterService: ModelRouterService;
@@ -852,8 +864,19 @@ 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;
this.sandbox = params.sandbox
? {
enabled: params.sandbox.enabled ?? false,
allowedPaths: params.sandbox.allowedPaths ?? [],
networkAccess: params.sandbox.networkAccess ?? false,
command: params.sandbox.command,
image: params.sandbox.image,
}
: {
enabled: false,
allowedPaths: [],
networkAccess: false,
};
this.targetDir = path.resolve(params.targetDir);
this.folderTrust = params.folderTrust ?? false;
this.workspaceContext = new WorkspaceContext(this.targetDir, []);
@@ -977,12 +1000,33 @@ export class Config implements McpContext, AgentLoopContext {
this.useAlternateBuffer = params.useAlternateBuffer ?? false;
this.enableInteractiveShell = params.enableInteractiveShell ?? false;
this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? true;
if (
os.platform() === 'win32' &&
(this.sandbox?.enabled || this.sandbox?.command === 'windows-native')
) {
this._sandboxManager = new WindowsSandboxManager();
} else {
this._sandboxManager = new NoopSandboxManager();
}
if (this.sandbox?.enabled && this._sandboxManager) {
this.fileSystemService = new SandboxedFileSystemService(
this._sandboxManager,
this.cwd,
);
} else {
this.fileSystemService = new StandardFileSystemService();
}
this.shellExecutionConfig = {
terminalWidth: params.shellExecutionConfig?.terminalWidth ?? 80,
terminalHeight: params.shellExecutionConfig?.terminalHeight ?? 24,
showColor: params.shellExecutionConfig?.showColor ?? false,
pager: params.shellExecutionConfig?.pager ?? 'cat',
sanitizationConfig: this.sanitizationConfig,
sandboxManager: this._sandboxManager,
sandboxConfig: this.sandbox,
};
this.truncateToolOutputThreshold =
params.truncateToolOutputThreshold ??
@@ -1421,6 +1465,10 @@ export class Config implements McpContext, AgentLoopContext {
return this._geminiClient;
}
get sandboxManager(): SandboxManager {
return this._sandboxManager;
}
getSessionId(): string {
return this.promptId;
}

View File

@@ -0,0 +1,94 @@
/**
* @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';
/**
* 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<string> {
const prepared = await this.sandboxManager.prepareCommand({
command: '__read',
args: [filePath],
cwd: this.cwd,
env: process.env,
});
return new Promise((resolve, reject) => {
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: Command failed with exit code ${code}. ${error ? 'Details: ' + error : ''}`,
),
);
}
});
});
}
async writeTextFile(filePath: string, content: string): Promise<void> {
const prepared = await this.sandboxManager.prepareCommand({
command: '__write',
args: [filePath],
cwd: this.cwd,
env: process.env,
});
return new Promise((resolve, reject) => {
const child = spawn(prepared.program, prepared.args, {
cwd: this.cwd,
env: prepared.env,
});
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: Command failed with exit code ${code}. ${error ? 'Details: ' + error : ''}`,
),
);
}
});
});
}
}

View File

@@ -0,0 +1,307 @@
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);
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 <network:0|1> <cwd> <command> [args...]");
Console.WriteLine("Internal commands: __read <path>, __write <path>");
return 1;
}
bool networkAccess = args[0] == "1";
string cwd = args[1];
string command = args[2];
// 1. Setup Token
IntPtr hCurrentProcess = GetCurrentProcess();
IntPtr hToken;
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;
}
IntPtr hRestrictedToken;
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)) {
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);
}
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");
return 1;
}
// 2. Set Integrity Level to Low
IntPtr lowIntegritySid;
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);
Marshal.StructureToPtr(tml, pTml, false);
SetTokenInformation(hRestrictedToken, TokenIntegrityLevel, pTml, (uint)tmlSize);
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)) {
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()))
using (FileStream fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None))
using (StreamWriter writer = new StreamWriter(fs)) {
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
IntPtr 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);
Marshal.StructureToPtr(limitInfo, pLimit, false);
SetInformationJobObject(hJob, JobObjectInfoClass.ExtendedLimitInformation, pLimit, (uint)limitSize);
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 = string.Join(" ", args, 2, args.Length - 2);
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;
}
if (hJob != IntPtr.Zero) {
AssignProcessToJobObject(hJob, pi.hProcess);
}
ResumeThread(pi.hThread);
WaitForSingleObject(pi.hProcess, INFINITE);
uint exitCode = 0;
GetExitCodeProcess(pi.hProcess, out exitCode);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
CloseHandle(hRestrictedToken);
CloseHandle(hToken);
if (hJob != IntPtr.Zero) CloseHandle(hJob);
return (int)exitCode;
}
private static int RunInImpersonation(IntPtr hToken, Func<int> action) {
using (WindowsIdentity.Impersonate(hToken)) {
return action();
}
}
}

View File

@@ -0,0 +1,54 @@
/**
* @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';
import * as os from 'node:os';
describe('WindowsSandboxManager', () => {
const manager = new WindowsSandboxManager();
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,
},
};
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');
},
);
});

View File

@@ -0,0 +1,139 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
type SandboxManager,
type SandboxRequest,
type SandboxedCommand,
} from './sandboxManager.js';
import {
sanitizeEnvironment,
type EnvironmentSanitizationConfig,
} from './environmentSanitization.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 initialized = false;
constructor() {
this.helperPath = path.resolve(__dirname, 'scripts', 'GeminiSandbox.exe');
}
private ensureInitialized(): void {
if (this.initialized) return;
if (!fs.existsSync(this.helperPath)) {
// 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',
),
];
let compiled = false;
for (const csc of cscPaths) {
const result = spawnSync(csc, ['/out:' + this.helperPath, sourcePath], {
stdio: 'ignore',
});
if (result.status === 0) {
compiled = true;
break;
}
}
}
}
this.initialized = true;
}
/**
* Prepares a command for sandboxed execution on Windows.
*/
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
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.
this.grantLowIntegrityAccess(req.cwd);
// Grant "Low Mandatory Level" read access to allowedPaths.
if (req.config?.allowedPaths) {
for (const allowedPath of req.config.allowedPaths) {
this.grantLowIntegrityAccess(allowedPath);
}
}
// 2. Construct the helper command
// GeminiSandbox.exe <network:0|1> <cwd> <command> [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 grantLowIntegrityAccess(targetPath: string): void {
try {
spawnSync('icacls', [targetPath, '/setintegritylevel', 'Low'], {
stdio: 'ignore',
});
} catch (e) {
// Best effort
}
}
}