From 1425e9497514df92c3f1c4eb6bffe08ebf4f00ae Mon Sep 17 00:00:00 2001 From: mkorwel Date: Fri, 13 Mar 2026 16:53:45 +0000 Subject: [PATCH] feat(core): implement native Windows sandboxing with restricted tokens --- .../core/scripts/compile-windows-sandbox.js | 119 +++++++ packages/core/src/config/config.ts | 78 ++++- .../services/sandboxedFileSystemService.ts | 94 ++++++ .../src/services/scripts/GeminiSandbox.cs | 307 ++++++++++++++++++ .../services/windowsSandboxManager.test.ts | 54 +++ .../src/services/windowsSandboxManager.ts | 139 ++++++++ 6 files changed, 776 insertions(+), 15 deletions(-) create mode 100644 packages/core/scripts/compile-windows-sandbox.js create mode 100644 packages/core/src/services/sandboxedFileSystemService.ts create mode 100644 packages/core/src/services/scripts/GeminiSandbox.cs create mode 100644 packages/core/src/services/windowsSandboxManager.test.ts create mode 100644 packages/core/src/services/windowsSandboxManager.ts diff --git a/packages/core/scripts/compile-windows-sandbox.js b/packages/core/scripts/compile-windows-sandbox.js new file mode 100644 index 0000000000..bc9174e495 --- /dev/null +++ b/packages/core/scripts/compile-windows-sandbox.js @@ -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(); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index bfdd6fdf42..cf70f33522 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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; } diff --git a/packages/core/src/services/sandboxedFileSystemService.ts b/packages/core/src/services/sandboxedFileSystemService.ts new file mode 100644 index 0000000000..5c76976851 --- /dev/null +++ b/packages/core/src/services/sandboxedFileSystemService.ts @@ -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 { + 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 { + 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 : ''}`, + ), + ); + } + }); + }); + } +} diff --git a/packages/core/src/services/scripts/GeminiSandbox.cs b/packages/core/src/services/scripts/GeminiSandbox.cs new file mode 100644 index 0000000000..51c57a86b9 --- /dev/null +++ b/packages/core/src/services/scripts/GeminiSandbox.cs @@ -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 [args...]"); + Console.WriteLine("Internal commands: __read , __write "); + 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 action) { + using (WindowsIdentity.Impersonate(hToken)) { + return action(); + } + } +} diff --git a/packages/core/src/services/windowsSandboxManager.test.ts b/packages/core/src/services/windowsSandboxManager.test.ts new file mode 100644 index 0000000000..50cce11ea7 --- /dev/null +++ b/packages/core/src/services/windowsSandboxManager.test.ts @@ -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'); + }, + ); +}); diff --git a/packages/core/src/services/windowsSandboxManager.ts b/packages/core/src/services/windowsSandboxManager.ts new file mode 100644 index 0000000000..04b2b14eab --- /dev/null +++ b/packages/core/src/services/windowsSandboxManager.ts @@ -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 { + 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 [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 + } + } +}