From 1cb703b405ba87064dc9106c85ec54c9c370f3ac Mon Sep 17 00:00:00 2001 From: mkorwel Date: Mon, 9 Mar 2026 19:27:28 -0700 Subject: [PATCH] feat(core): implement native Windows sandboxing with restricted tokens --- packages/cli/src/config/sandboxConfig.ts | 6 +- packages/core/src/config/config.ts | 28 +- packages/core/src/services/sandboxManager.ts | 2 + .../services/sandboxedFileSystemService.ts | 86 ++++++ .../src/services/scripts/GeminiSandbox.cs | 288 ++++++++++++++++++ .../src/services/shellExecutionService.ts | 4 + .../services/windowsSandboxManager.test.ts | 46 +++ .../src/services/windowsSandboxManager.ts | 125 ++++++++ 8 files changed, 580 insertions(+), 5 deletions(-) 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/cli/src/config/sandboxConfig.ts b/packages/cli/src/config/sandboxConfig.ts index 913464a6b0..e97f4d41a8 100644 --- a/packages/cli/src/config/sandboxConfig.ts +++ b/packages/cli/src/config/sandboxConfig.ts @@ -29,6 +29,7 @@ const VALID_SANDBOX_COMMANDS: ReadonlyArray = [ 'sandbox-exec', 'runsc', 'lxc', + 'windows-native', ]; function isSandboxCommand( @@ -95,6 +96,8 @@ function getSandboxCommand( // note: runsc is NOT auto-detected, it must be explicitly specified if (os.platform() === 'darwin' && commandExists.sync('sandbox-exec')) { return 'sandbox-exec'; + } else if (os.platform() === 'win32') { + return 'windows-native'; } else if (commandExists.sync('docker') && sandbox === true) { return 'docker'; } else if (commandExists.sync('podman') && sandbox === true) { @@ -128,7 +131,8 @@ export async function loadSandboxConfig( process.env['GEMINI_SANDBOX_IMAGE_DEFAULT'] ?? packageJson?.config?.sandboxImageUri; - return command && image + return command && + (image || command === 'sandbox-exec' || command === 'windows-native') ? { enabled: true, allowedPaths: [], networkAccess: false, command, image } : undefined; } diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 55eae40247..2ce34e7723 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'; @@ -45,6 +46,7 @@ import { NoopSandboxManager, type SandboxManager, } from '../services/sandboxManager.js'; +import { WindowsSandboxManager } from '../services/windowsSandboxManager.js'; import { initializeTelemetry, DEFAULT_TELEMETRY_TARGET, @@ -74,6 +76,7 @@ import { StandardFileSystemService, type FileSystemService, } from '../services/fileSystemService.js'; +import { SandboxedFileSystemService } from '../services/sandboxedFileSystemService.js'; import { TrackerCreateTaskTool, TrackerUpdateTaskTool, @@ -454,7 +457,7 @@ 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; } @@ -465,7 +468,7 @@ 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(), }) @@ -835,7 +838,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, @@ -979,6 +981,7 @@ export class Config implements McpContext, AgentLoopContext { pager: params.shellExecutionConfig?.pager ?? 'cat', sanitizationConfig: this.sanitizationConfig, sandboxManager: this.sandboxManager, + sandboxConfig: this.sandbox, }; this.truncateToolOutputThreshold = params.truncateToolOutputThreshold ?? @@ -1094,7 +1097,24 @@ export class Config implements McpContext, AgentLoopContext { } } this._geminiClient = new GeminiClient(this); - this._sandboxManager = new NoopSandboxManager(); + 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.sandboxManager = this._sandboxManager; this.modelRouterService = new ModelRouterService(this); diff --git a/packages/core/src/services/sandboxManager.ts b/packages/core/src/services/sandboxManager.ts index 4f31527d8d..eb8e13e41f 100644 --- a/packages/core/src/services/sandboxManager.ts +++ b/packages/core/src/services/sandboxManager.ts @@ -24,6 +24,8 @@ export interface SandboxRequest { /** Optional sandbox-specific configuration. */ config?: { sanitizationConfig?: Partial; + allowedPaths?: string[]; + networkAccess?: boolean; }; } diff --git a/packages/core/src/services/sandboxedFileSystemService.ts b/packages/core/src/services/sandboxedFileSystemService.ts new file mode 100644 index 0000000000..0976095fc0 --- /dev/null +++ b/packages/core/src/services/sandboxedFileSystemService.ts @@ -0,0 +1,86 @@ +/** + * @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(`Failed to read file via sandbox: ${error || 'Unknown 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(`Failed to write file via sandbox: ${error || 'Unknown 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..f16d6030a1 --- /dev/null +++ b/packages/core/src/services/scripts/GeminiSandbox.cs @@ -0,0 +1,288 @@ +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; + + 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 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); + } + + 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 { + Console.Write(File.ReadAllText(path)); + 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 (StreamWriter writer = new StreamWriter(File.Create(path))) { + writer.Write(reader.ReadToEnd()); + } + 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) { + WindowsIdentity.Impersonate(hToken); + int result = action(); + return result; + } +} diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index 5775c7f4b2..170addabf4 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -24,6 +24,7 @@ import { } from '../utils/terminalSerializer.js'; import { type EnvironmentSanitizationConfig } from './environmentSanitization.js'; import { type SandboxManager } from './sandboxManager.js'; +import type { SandboxConfig } from '../config/config.js'; import { killProcessGroup } from '../utils/process-utils.js'; const { Terminal } = pkg; @@ -101,6 +102,7 @@ export interface ShellExecutionConfig { defaultBg?: string; sanitizationConfig: EnvironmentSanitizationConfig; sandboxManager: SandboxManager; + sandboxConfig?: SandboxConfig; // Used for testing disableDynamicLineTrimming?: boolean; scrollback?: number; @@ -308,6 +310,8 @@ export class ShellExecutionService { env, config: { sanitizationConfig: shellExecutionConfig.sanitizationConfig, + allowedPaths: shellExecutionConfig.sandboxConfig?.allowedPaths, + networkAccess: shellExecutionConfig.sandboxConfig?.networkAccess, }, }); } diff --git a/packages/core/src/services/windowsSandboxManager.test.ts b/packages/core/src/services/windowsSandboxManager.test.ts new file mode 100644 index 0000000000..864c1f0587 --- /dev/null +++ b/packages/core/src/services/windowsSandboxManager.test.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { WindowsSandboxManager } from './windowsSandboxManager.js'; +import { 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..b0f0dfe2b2 --- /dev/null +++ b/packages/core/src/services/windowsSandboxManager.ts @@ -0,0 +1,125 @@ +/** + * @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 os from 'node:os'; +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 csc = path.join( + process.env['SystemRoot'] || 'C:\\Windows', + 'Microsoft.NET', + 'Framework64', + 'v4.0.30319', + 'csc.exe', + ); + if (fs.existsSync(csc)) { + spawnSync(csc, ['/out:' + this.helperPath, sourcePath], { stdio: 'ignore' }); + } + } + } + + 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', '(OI)(CI)Low'], { + stdio: 'ignore', + }); + } catch (e) { + // Best effort + } + } +}