From 1425e9497514df92c3f1c4eb6bffe08ebf4f00ae Mon Sep 17 00:00:00 2001 From: mkorwel Date: Fri, 13 Mar 2026 16:53:45 +0000 Subject: [PATCH 01/13] 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 00000000000..bc9174e4954 --- /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 bfdd6fdf42e..cf70f335221 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 00000000000..5c769768518 --- /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 00000000000..51c57a86b9b --- /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 00000000000..50cce11ea74 --- /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 00000000000..04b2b14eab9 --- /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 + } + } +} From 7814427a1e376c9f885e14c6939c940a04a3d518 Mon Sep 17 00:00:00 2001 From: mkorwel Date: Fri, 13 Mar 2026 17:17:26 +0000 Subject: [PATCH 02/13] feat(core,cli): harden windows sandboxing and add platform-agnostic tests --- eslint.config.js | 2 +- packages/cli/src/config/sandboxConfig.test.ts | 7 ++ .../core/scripts/compile-windows-sandbox.js | 2 + packages/core/src/services/sandboxManager.ts | 2 + .../sandboxedFileSystemService.test.ts | 113 ++++++++++++++++++ .../src/services/shellExecutionService.ts | 13 +- .../services/windowsSandboxManager.test.ts | 94 ++++++++------- .../src/services/windowsSandboxManager.ts | 25 ++-- 8 files changed, 207 insertions(+), 51 deletions(-) create mode 100644 packages/core/src/services/sandboxedFileSystemService.test.ts diff --git a/eslint.config.js b/eslint.config.js index d3a267f30a7..d879a5da8ee 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -303,7 +303,7 @@ export default tseslint.config( }, }, { - files: ['./scripts/**/*.js', 'esbuild.config.js'], + files: ['./scripts/**/*.js', 'packages/*/scripts/**/*.js', 'esbuild.config.js'], languageOptions: { globals: { ...globals.node, diff --git a/packages/cli/src/config/sandboxConfig.test.ts b/packages/cli/src/config/sandboxConfig.test.ts index cfe1fed660b..3ec0e6a5bb1 100644 --- a/packages/cli/src/config/sandboxConfig.test.ts +++ b/packages/cli/src/config/sandboxConfig.test.ts @@ -338,6 +338,8 @@ describe('loadSandboxConfig', () => { sandbox: { enabled: true, command: 'podman', + allowedPaths: [], + networkAccess: false, }, }, }, @@ -353,6 +355,8 @@ describe('loadSandboxConfig', () => { sandbox: { enabled: true, image: 'custom/image', + allowedPaths: [], + networkAccess: false, }, }, }, @@ -367,6 +371,8 @@ describe('loadSandboxConfig', () => { tools: { sandbox: { enabled: false, + allowedPaths: [], + networkAccess: false, }, }, }, @@ -382,6 +388,7 @@ describe('loadSandboxConfig', () => { sandbox: { enabled: true, allowedPaths: ['/settings-path'], + networkAccess: false, }, }, }, diff --git a/packages/core/scripts/compile-windows-sandbox.js b/packages/core/scripts/compile-windows-sandbox.js index bc9174e4954..a52987c24e8 100644 --- a/packages/core/scripts/compile-windows-sandbox.js +++ b/packages/core/scripts/compile-windows-sandbox.js @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +/* eslint-env node */ + import { spawnSync } from 'node:child_process'; import path from 'node:path'; import fs from 'node:fs'; diff --git a/packages/core/src/services/sandboxManager.ts b/packages/core/src/services/sandboxManager.ts index 458e15260eb..ae843682931 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.test.ts b/packages/core/src/services/sandboxedFileSystemService.test.ts new file mode 100644 index 00000000000..d18fcce962c --- /dev/null +++ b/packages/core/src/services/sandboxedFileSystemService.test.ts @@ -0,0 +1,113 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SandboxedFileSystemService } from './sandboxedFileSystemService.js'; +import type { + SandboxManager, + SandboxRequest, + SandboxedCommand, +} from './sandboxManager.js'; +import { spawn } from 'node:child_process'; +import { EventEmitter } from 'node:events'; + +vi.mock('node:child_process', () => ({ + spawn: vi.fn(), +})); + +class MockSandboxManager implements SandboxManager { + async prepareCommand(req: SandboxRequest): Promise { + return { + program: 'sandbox.exe', + args: ['0', req.cwd, req.command, ...req.args], + env: req.env || {}, + }; + } +} + +describe('SandboxedFileSystemService', () => { + let sandboxManager: MockSandboxManager; + let service: SandboxedFileSystemService; + const cwd = '/test/cwd'; + + beforeEach(() => { + sandboxManager = new MockSandboxManager(); + service = new SandboxedFileSystemService(sandboxManager, cwd); + vi.clearAllMocks(); + }); + + it('should read a file through the sandbox', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mockChild = new EventEmitter() as any; + mockChild.stdout = new EventEmitter(); + mockChild.stderr = new EventEmitter(); + + vi.mocked(spawn).mockReturnValue(mockChild); + + const readPromise = service.readTextFile('/test/file.txt'); + + // Use setImmediate to ensure events are emitted after the promise starts executing + setImmediate(() => { + mockChild.stdout.emit('data', Buffer.from('file content')); + mockChild.emit('close', 0); + }); + + const content = await readPromise; + expect(content).toBe('file content'); + expect(spawn).toHaveBeenCalledWith( + 'sandbox.exe', + ['0', cwd, '__read', '/test/file.txt'], + expect.any(Object), + ); + }); + + it('should write a file through the sandbox', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mockChild = new EventEmitter() as any; + mockChild.stdin = { + write: vi.fn(), + end: vi.fn(), + }; + mockChild.stderr = new EventEmitter(); + + vi.mocked(spawn).mockReturnValue(mockChild); + + const writePromise = service.writeTextFile('/test/file.txt', 'new content'); + + setImmediate(() => { + mockChild.emit('close', 0); + }); + + await writePromise; + expect(mockChild.stdin.write).toHaveBeenCalledWith('new content'); + expect(mockChild.stdin.end).toHaveBeenCalled(); + expect(spawn).toHaveBeenCalledWith( + 'sandbox.exe', + ['0', cwd, '__write', '/test/file.txt'], + expect.any(Object), + ); + }); + + it('should reject if sandbox command fails', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mockChild = new EventEmitter() as any; + mockChild.stdout = new EventEmitter(); + mockChild.stderr = new EventEmitter(); + + vi.mocked(spawn).mockReturnValue(mockChild); + + const readPromise = service.readTextFile('/test/file.txt'); + + setImmediate(() => { + mockChild.stderr.emit('data', Buffer.from('access denied')); + mockChild.emit('close', 1); + }); + + await expect(readPromise).rejects.toThrow( + 'Sandbox Error: Command failed with exit code 1. Details: access denied', + ); + }); +}); diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index f8d2e728d21..77d74d2761f 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -31,7 +31,8 @@ import { sanitizeEnvironment, type EnvironmentSanitizationConfig, } from './environmentSanitization.js'; -import { NoopSandboxManager } from './sandboxManager.js'; +import { NoopSandboxManager, type SandboxManager } from './sandboxManager.js'; +import type { SandboxConfig } from '../config/config.js'; import { killProcessGroup } from '../utils/process-utils.js'; import { ExecutionLifecycleService, @@ -94,6 +95,8 @@ export interface ShellExecutionConfig { disableDynamicLineTrimming?: boolean; scrollback?: number; maxSerializedLines?: number; + sandboxManager?: SandboxManager; + sandboxConfig?: SandboxConfig; } /** @@ -274,13 +277,17 @@ export class ShellExecutionService { shouldUseNodePty: boolean, shellExecutionConfig: ShellExecutionConfig, ): Promise { - const sandboxManager = new NoopSandboxManager(); + const sandboxManager = + shellExecutionConfig.sandboxManager ?? new NoopSandboxManager(); const { env: sanitizedEnv } = await sandboxManager.prepareCommand({ command: commandToExecute, args: [], env: process.env, cwd, - config: shellExecutionConfig, + config: { + ...shellExecutionConfig, + ...(shellExecutionConfig.sandboxConfig || {}), + }, }); if (shouldUseNodePty) { diff --git a/packages/core/src/services/windowsSandboxManager.test.ts b/packages/core/src/services/windowsSandboxManager.test.ts index 50cce11ea74..6bec183410c 100644 --- a/packages/core/src/services/windowsSandboxManager.test.ts +++ b/packages/core/src/services/windowsSandboxManager.test.ts @@ -7,48 +7,62 @@ 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 manager = new WindowsSandboxManager('win32'); + + it('should prepare a GeminiSandbox.exe command', async () => { + const req: SandboxRequest = { + command: 'whoami', + args: ['/groups'], + cwd: '/test/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(['0', '/test/cwd', 'whoami', '/groups']); + }); + + it('should handle networkAccess from config', async () => { + const req: SandboxRequest = { + command: 'whoami', + args: [], + cwd: '/test/cwd', + env: {}, + config: { + networkAccess: true, + }, + }; + + const result = await manager.prepareCommand(req); + expect(result.args[0]).toBe('1'); + }); + + it('should sanitize environment variables', async () => { + const req: SandboxRequest = { + command: 'test', + args: [], + cwd: '/test/cwd', + env: { + API_KEY: 'secret', + PATH: '/usr/bin', + }, + config: { + sanitizationConfig: { + allowedEnvironmentVariables: ['PATH'], + blockedEnvironmentVariables: ['API_KEY'], + enableEnvironmentVariableRedaction: true, }, - }; + }, + }; - const result = await manager.prepareCommand(req); - expect(result.args[0]).toBe('1'); - }, - ); + const result = await manager.prepareCommand(req); + expect(result.env['PATH']).toBe('/usr/bin'); + expect(result.env['API_KEY']).toBeUndefined(); + }); }); diff --git a/packages/core/src/services/windowsSandboxManager.ts b/packages/core/src/services/windowsSandboxManager.ts index 04b2b14eab9..c1d11a0db97 100644 --- a/packages/core/src/services/windowsSandboxManager.ts +++ b/packages/core/src/services/windowsSandboxManager.ts @@ -28,14 +28,20 @@ const __dirname = path.dirname(__filename); */ export class WindowsSandboxManager implements SandboxManager { private readonly helperPath: string; + private readonly platform: string; private initialized = false; - constructor() { + constructor(platform: string = process.platform) { + this.platform = platform; this.helperPath = path.resolve(__dirname, 'scripts', 'GeminiSandbox.exe'); } private ensureInitialized(): void { if (this.initialized) return; + if (this.platform !== 'win32') { + this.initialized = true; + return; + } if (!fs.existsSync(this.helperPath)) { // If the exe doesn't exist, we try to compile it from the .cs file @@ -60,13 +66,15 @@ export class WindowsSandboxManager implements SandboxManager { ), ]; - let compiled = false; for (const csc of cscPaths) { - const result = spawnSync(csc, ['/out:' + this.helperPath, sourcePath], { - stdio: 'ignore', - }); + const result = spawnSync( + csc, + ['/out:' + this.helperPath, sourcePath], + { + stdio: 'ignore', + }, + ); if (result.status === 0) { - compiled = true; break; } } @@ -128,11 +136,14 @@ export class WindowsSandboxManager implements SandboxManager { * Grants "Low Mandatory Level" access to a path using icacls. */ private grantLowIntegrityAccess(targetPath: string): void { + if (this.platform !== 'win32') { + return; + } try { spawnSync('icacls', [targetPath, '/setintegritylevel', 'Low'], { stdio: 'ignore', }); - } catch (e) { + } catch (_e) { // Best effort } } From d7fb4bf464d1d1f1ab76a55de147d33ef7f25496 Mon Sep 17 00:00:00 2001 From: mkorwel Date: Fri, 13 Mar 2026 18:28:15 +0000 Subject: [PATCH 03/13] feat(core): address review comments and fix strict sandbox shell selection --- .../src/services/scripts/GeminiSandbox.cs | 15 +++++++++- .../src/services/shellExecutionService.ts | 29 +++++++++++++++++-- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/packages/core/src/services/scripts/GeminiSandbox.cs b/packages/core/src/services/scripts/GeminiSandbox.cs index 51c57a86b9b..2f1bd26cd04 100644 --- a/packages/core/src/services/scripts/GeminiSandbox.cs +++ b/packages/core/src/services/scripts/GeminiSandbox.cs @@ -273,7 +273,12 @@ static int Main(string[] args) { si.hStdOutput = GetStdHandle(-11); si.hStdError = GetStdHandle(-12); - string commandLine = string.Join(" ", args, 2, args.Length - 2); + List quotedArgs = new List(); + for (int i = 2; i < args.Length; i++) { + quotedArgs.Add(QuoteArgument(args[i])); + } + string commandLine = string.Join(" ", quotedArgs.ToArray()); + 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()); @@ -299,6 +304,14 @@ static int Main(string[] args) { return (int)exitCode; } + private static string QuoteArgument(string arg) { + if (string.IsNullOrEmpty(arg)) return "\"\""; + if (arg.IndexOfAny(new char[] { ' ', '\t', '\n', '\v', '\"' }) == -1) return arg; + + string escaped = arg.Replace("\"", "\\\""); + return "\"" + escaped + "\""; + } + private static int RunInImpersonation(IntPtr hToken, Func action) { using (WindowsIdentity.Impersonate(hToken)) { return action(); diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index 77d74d2761f..d7d30dd44d7 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -279,6 +279,14 @@ export class ShellExecutionService { ): Promise { const sandboxManager = shellExecutionConfig.sandboxManager ?? new NoopSandboxManager(); + + // Strict sandbox on Windows (network disabled) requires cmd.exe + const isStrictSandbox = + os.platform() === 'win32' && + shellExecutionConfig.sandboxConfig?.enabled && + shellExecutionConfig.sandboxConfig?.command === 'windows-native' && + !shellExecutionConfig.sandboxConfig?.networkAccess; + const { env: sanitizedEnv } = await sandboxManager.prepareCommand({ command: commandToExecute, args: [], @@ -302,6 +310,7 @@ export class ShellExecutionService { shellExecutionConfig, ptyInfo, sanitizedEnv, + isStrictSandbox, ); } catch (_e) { // Fallback to child_process @@ -316,6 +325,7 @@ export class ShellExecutionService { abortSignal, shellExecutionConfig.sanitizationConfig, shouldUseNodePty, + isStrictSandbox, ); } @@ -356,10 +366,18 @@ export class ShellExecutionService { abortSignal: AbortSignal, sanitizationConfig: EnvironmentSanitizationConfig, isInteractive: boolean, + isStrictSandbox?: boolean, ): ShellExecutionHandle { try { const isWindows = os.platform() === 'win32'; - const { executable, argsPrefix, shell } = getShellConfiguration(); + let { executable, argsPrefix, shell } = getShellConfiguration(); + + if (isStrictSandbox) { + shell = 'cmd'; + argsPrefix = ['/c']; + executable = 'cmd.exe'; + } + const guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell); const spawnArgs = [...argsPrefix, guardedCommand]; @@ -690,6 +708,7 @@ export class ShellExecutionService { shellExecutionConfig: ShellExecutionConfig, ptyInfo: PtyImplementation, sanitizedEnv: Record, + isStrictSandbox?: boolean, ): Promise { if (!ptyInfo) { // This should not happen, but as a safeguard... @@ -700,7 +719,13 @@ export class ShellExecutionService { try { const cols = shellExecutionConfig.terminalWidth ?? 80; const rows = shellExecutionConfig.terminalHeight ?? 30; - const { executable, argsPrefix, shell } = getShellConfiguration(); + let { executable, argsPrefix, shell } = getShellConfiguration(); + + if (isStrictSandbox) { + shell = 'cmd'; + argsPrefix = ['/c']; + executable = 'cmd.exe'; + } const resolvedExecutable = await resolveExecutable(executable); if (!resolvedExecutable) { From f08fad9b87c690161ff26176b436826e7d36af49 Mon Sep 17 00:00:00 2001 From: mkorwel Date: Wed, 18 Mar 2026 11:51:19 -0700 Subject: [PATCH 04/13] feat(windows-sandbox): address review comments, fix shell integration, and harden security --- docs/cli/sandbox.md | 77 ++++- docs/reference/configuration.md | 14 +- packages/cli/src/config/sandboxConfig.ts | 19 +- packages/cli/src/config/settingsSchema.ts | 22 +- packages/core/src/config/config.ts | 2 +- .../src/services/scripts/GeminiSandbox.cs | 318 ++++++++++-------- .../src/services/shellExecutionService.ts | 181 +++++----- .../src/services/windowsSandboxManager.ts | 34 +- 8 files changed, 434 insertions(+), 233 deletions(-) diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index ec7e88f624a..11dbc17f09a 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -50,7 +50,82 @@ Cross-platform sandboxing with complete process isolation. **Note**: Requires building the sandbox image locally or using a published image from your organization's registry. -### 3. gVisor / runsc (Linux only) +### 3. Windows Native Sandbox (Windows only) + +Built-in sandboxing for Windows using Restricted Tokens and Job Objects. This +method provides process isolation without requiring Docker or other container +runtimes. + +**Prerequisites:** + +- Windows 10/11 or Windows Server. +- No additional software required (uses a built-in C# helper). + +**How it works:** + +The Windows native sandbox leverages: + +- **Restricted Tokens**: Strips administrator privileges and high-level SIDs + from the process. +- **Job Objects**: Ensures the entire process tree is terminated when the parent + session ends. +- **Mandatory Integrity Levels (Low)**: Restricts the process to "Low" + integrity, preventing it from writing to most of the system and workspace by + default. + +**Enabling Windows Native Sandbox:** + +```json +{ + "tools": { + "sandbox": { + "enabled": true, + "command": "windows-native" + } + } +} +``` + +Or via environment variable: + +```bash +$env:GEMINI_SANDBOX="windows-native" +``` + +**Permissions:** + +By default, the Windows native sandbox is restricted. If you need it to write to +specific directories, you must add them to `allowedPaths`: + +```json +{ + "tools": { + "sandbox": { + "enabled": true, + "command": "windows-native", + "allowedPaths": ["C:\\path\\to\\output"] + } + } +} +``` + +**Network Access:** + +Network access is disabled by default in "Strict" mode. To enable it: + +```json +{ + "tools": { + "sandbox": { + "enabled": true, + "command": "windows-native", + "networkAccess": true + } + } +} +``` + +### 4. gVisor / runsc (Linux only) Strongest isolation available: runs containers inside a user-space kernel via [gVisor](https://github.com/google/gvisor). gVisor intercepts all container diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 4e0e9856d91..65dfe47c9b2 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -775,10 +775,22 @@ their corresponding top-level category object in your `settings.json` file. - **`tools.sandbox`** (string): - **Description:** Sandbox execution environment. Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile, or - specify an explicit sandbox command (e.g., "docker", "podman", "lxc"). + specify an explicit sandbox command (e.g., "docker", "podman", "lxc", + "windows-native"). - **Default:** `undefined` - **Requires restart:** Yes +- **`tools.sandboxAllowedPaths`** (array): + - **Description:** List of additional paths that the sandbox is allowed to + access. + - **Default:** `[]` + - **Requires restart:** Yes + +- **`tools.sandboxNetworkAccess`** (boolean): + - **Description:** Whether the sandbox is allowed to access the network. + - **Default:** `false` + - **Requires restart:** Yes + - **`tools.shell.enableInteractiveShell`** (boolean): - **Description:** Use node-pty for an interactive shell experience. Fallback to child_process still applies. diff --git a/packages/cli/src/config/sandboxConfig.ts b/packages/cli/src/config/sandboxConfig.ts index cce5033f1a2..3cdef142d7c 100644 --- a/packages/cli/src/config/sandboxConfig.ts +++ b/packages/cli/src/config/sandboxConfig.ts @@ -29,6 +29,7 @@ const VALID_SANDBOX_COMMANDS = [ 'sandbox-exec', 'runsc', 'lxc', + 'windows-native', ]; function isSandboxCommand( @@ -73,8 +74,15 @@ function getSandboxCommand( 'gVisor (runsc) sandboxing is only supported on Linux', ); } - // confirm that specified command exists - if (!commandExists.sync(sandbox)) { + // windows-native is only supported on Windows + if (sandbox === 'windows-native' && os.platform() !== 'win32') { + throw new FatalSandboxError( + 'Windows native sandboxing is only supported on Windows', + ); + } + + // confirm that specified command exists (unless it's built-in) + if (sandbox !== 'windows-native' && !commandExists.sync(sandbox)) { throw new FatalSandboxError( `Missing sandbox command '${sandbox}' (from GEMINI_SANDBOX)`, ); @@ -147,7 +155,12 @@ export async function loadSandboxConfig( customImage ?? packageJson?.config?.sandboxImageUri; - return command && image + const isNative = + command === 'windows-native' || + command === 'sandbox-exec' || + command === 'lxc'; + + return command && (image || isNative) ? { enabled: true, allowedPaths, networkAccess, command, image } : undefined; } diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 7d47d66e324..e69340c6f75 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1289,10 +1289,30 @@ const SETTINGS_SCHEMA = { description: oneLine` Sandbox execution environment. Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile, - or specify an explicit sandbox command (e.g., "docker", "podman", "lxc"). + or specify an explicit sandbox command (e.g., "docker", "podman", "lxc", "windows-native"). `, showInDialog: false, }, + sandboxAllowedPaths: { + type: 'array', + label: 'Sandbox Allowed Paths', + category: 'Tools', + requiresRestart: true, + default: [] as string[], + description: + 'List of additional paths that the sandbox is allowed to access.', + showInDialog: true, + items: { type: 'string' }, + }, + sandboxNetworkAccess: { + type: 'boolean', + label: 'Sandbox Network Access', + category: 'Tools', + requiresRestart: true, + default: false, + description: 'Whether the sandbox is allowed to access the network.', + showInDialog: true, + }, shell: { type: 'object', label: 'Shell', diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index cf70f335221..2cee2f793cb 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1010,7 +1010,7 @@ export class Config implements McpContext, AgentLoopContext { this._sandboxManager = new NoopSandboxManager(); } - if (this.sandbox?.enabled && this._sandboxManager) { + if (!(this._sandboxManager instanceof NoopSandboxManager)) { this.fileSystemService = new SandboxedFileSystemService( this._sandboxManager, this.cwd, diff --git a/packages/core/src/services/scripts/GeminiSandbox.cs b/packages/core/src/services/scripts/GeminiSandbox.cs index 2f1bd26cd04..b56e2772d61 100644 --- a/packages/core/src/services/scripts/GeminiSandbox.cs +++ b/packages/core/src/services/scripts/GeminiSandbox.cs @@ -126,6 +126,9 @@ public enum JobObjectInfoClass { [DllImport("advapi32.dll", SetLastError = true)] public static extern bool SetTokenInformation(IntPtr TokenHandle, int TokenInformationClass, IntPtr TokenInformation, uint TokenInformationLength); + [DllImport("kernel32.dll", SetLastError = true)] + public static extern IntPtr LocalFree(IntPtr hMem); + public const uint TOKEN_DUPLICATE = 0x0002; public const uint TOKEN_QUERY = 0x0008; public const uint TOKEN_ASSIGN_PRIMARY = 0x0001; @@ -150,166 +153,207 @@ static int Main(string[] args) { 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 hToken = IntPtr.Zero; + IntPtr hRestrictedToken = IntPtr.Zero; + IntPtr hJob = IntPtr.Zero; 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 networkSid = IntPtr.Zero; + IntPtr restrictedSid = IntPtr.Zero; + IntPtr lowIntegritySid = IntPtr.Zero; + + try { + // 1. Setup Token + IntPtr hCurrentProcess = GetCurrentProcess(); + 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 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. + uint sidCount = 0; + uint restrictCount = 0; + + // "networkAccess == false" implies Strict Sandbox Level 1. + if (!networkAccess) { + 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); + } - 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; - } + // 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); + } + } - // 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); - } + 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; + } - // 3. Handle Internal Commands or External Process - if (command == "__read") { - string path = args[3]; - return RunInImpersonation(hRestrictedToken, () => { + // 2. Set Integrity Level to Low + 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); 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); + Marshal.StructureToPtr(tml, pTml, false); + SetTokenInformation(hRestrictedToken, TokenIntegrityLevel, pTml, (uint)tmlSize); + } finally { + 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; } - 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); + }); + } 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; } - 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); - - List quotedArgs = new List(); - for (int i = 2; i < args.Length; i++) { - quotedArgs.Add(QuoteArgument(args[i])); - } - string commandLine = string.Join(" ", quotedArgs.ToArray()); + }); + } - 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; - } + // 4. Setup Job Object for external process + 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); + try { + Marshal.StructureToPtr(limitInfo, pLimit, false); + SetInformationJobObject(hJob, JobObjectInfoClass.ExtendedLimitInformation, pLimit, (uint)limitSize); + } finally { + Marshal.FreeHGlobal(pLimit); + } + } - if (hJob != IntPtr.Zero) { - AssignProcessToJobObject(hJob, pi.hProcess); - } + // 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 = ""; + for (int i = 2; i < args.Length; i++) { + if (i > 2) commandLine += " "; + commandLine += QuoteArgument(args[i]); + } - ResumeThread(pi.hThread); - WaitForSingleObject(pi.hProcess, INFINITE); + 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; + } - uint exitCode = 0; - GetExitCodeProcess(pi.hProcess, out exitCode); + try { + if (hJob != IntPtr.Zero) { + AssignProcessToJobObject(hJob, pi.hProcess); + } - CloseHandle(pi.hProcess); - CloseHandle(pi.hThread); - CloseHandle(hRestrictedToken); - CloseHandle(hToken); - if (hJob != IntPtr.Zero) CloseHandle(hJob); + ResumeThread(pi.hThread); + WaitForSingleObject(pi.hProcess, INFINITE); - return (int)exitCode; + uint exitCode = 0; + GetExitCodeProcess(pi.hProcess, out exitCode); + return (int)exitCode; + } finally { + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + } + } catch (Exception e) { + Console.Error.WriteLine("Unexpected error: " + e.Message); + return 1; + } finally { + if (hRestrictedToken != IntPtr.Zero) CloseHandle(hRestrictedToken); + if (hToken != IntPtr.Zero) CloseHandle(hToken); + if (hJob != IntPtr.Zero) CloseHandle(hJob); + if (pSidsToDisable != IntPtr.Zero) Marshal.FreeHGlobal(pSidsToDisable); + if (pSidsToRestrict != IntPtr.Zero) Marshal.FreeHGlobal(pSidsToRestrict); + if (networkSid != IntPtr.Zero) LocalFree(networkSid); + if (restrictedSid != IntPtr.Zero) LocalFree(restrictedSid); + if (lowIntegritySid != IntPtr.Zero) LocalFree(lowIntegritySid); + } } private static string QuoteArgument(string arg) { if (string.IsNullOrEmpty(arg)) return "\"\""; - if (arg.IndexOfAny(new char[] { ' ', '\t', '\n', '\v', '\"' }) == -1) return arg; - string escaped = arg.Replace("\"", "\\\""); - return "\"" + escaped + "\""; + bool hasSpace = arg.IndexOfAny(new char[] { ' ', '\t' }) != -1; + if (!hasSpace && arg.IndexOf('\"') == -1) return arg; + + // Windows command line escaping for arguments is complex. + // Rule: Backslashes only need escaping if they precede a double quote or the end of the string. + System.Text.StringBuilder sb = new System.Text.StringBuilder(); + sb.Append('\"'); + for (int i = 0; i < arg.Length; i++) { + int backslashCount = 0; + while (i < arg.Length && arg[i] == '\\') { + backslashCount++; + i++; + } + + if (i == arg.Length) { + // Escape backslashes before the closing double quote + sb.Append('\\', backslashCount * 2); + } else if (arg[i] == '\"') { + // Escape backslashes before a literal double quote + sb.Append('\\', backslashCount * 2 + 1); + sb.Append('\"'); + } else { + // Backslashes don't need escaping here + sb.Append('\\', backslashCount); + sb.Append(arg[i]); + } + } + sb.Append('\"'); + return sb.ToString(); } private static int RunInImpersonation(IntPtr hToken, Func action) { diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index d7d30dd44d7..e1fb0e0da4d 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -280,37 +280,112 @@ export class ShellExecutionService { const sandboxManager = shellExecutionConfig.sandboxManager ?? new NoopSandboxManager(); - // Strict sandbox on Windows (network disabled) requires cmd.exe + // 1. Determine Shell Configuration + const isWindows = os.platform() === 'win32'; const isStrictSandbox = - os.platform() === 'win32' && + isWindows && shellExecutionConfig.sandboxConfig?.enabled && shellExecutionConfig.sandboxConfig?.command === 'windows-native' && !shellExecutionConfig.sandboxConfig?.networkAccess; - const { env: sanitizedEnv } = await sandboxManager.prepareCommand({ - command: commandToExecute, - args: [], - env: process.env, + let { executable, argsPrefix, shell } = getShellConfiguration(); + if (isStrictSandbox) { + shell = 'cmd'; + argsPrefix = ['/c']; + executable = 'cmd.exe'; + } + + const guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell); + const spawnArgs = [...argsPrefix, guardedCommand]; + + // 2. Prepare Environment + const gitConfigKeys: string[] = []; + if (!shouldUseNodePty) { + for (const key in process.env) { + if (key.startsWith('GIT_CONFIG_')) { + gitConfigKeys.push(key); + } + } + } + + const sanitizationConfig = { + ...shellExecutionConfig.sanitizationConfig, + allowedEnvironmentVariables: [ + ...(shellExecutionConfig.sanitizationConfig + .allowedEnvironmentVariables || []), + ...gitConfigKeys, + ], + }; + + const sanitizedEnv = sanitizeEnvironment(process.env, sanitizationConfig); + + const baseEnv: Record = { + ...sanitizedEnv, + [GEMINI_CLI_IDENTIFICATION_ENV_VAR]: + GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE, + TERM: 'xterm-256color', + PAGER: shellExecutionConfig.pager ?? 'cat', + GIT_PAGER: shellExecutionConfig.pager ?? 'cat', + }; + + if (!shouldUseNodePty) { + // Ensure all GIT_CONFIG_* variables are preserved even if they were redacted + for (const key of gitConfigKeys) { + baseEnv[key] = process.env[key]; + } + + const gitConfigCount = parseInt(baseEnv['GIT_CONFIG_COUNT'] || '0', 10); + const newKey = `GIT_CONFIG_KEY_${gitConfigCount}`; + const newValue = `GIT_CONFIG_VALUE_${gitConfigCount}`; + + // Ensure these new keys are allowed through sanitization + sanitizationConfig.allowedEnvironmentVariables.push( + 'GIT_CONFIG_COUNT', + newKey, + newValue, + ); + + Object.assign(baseEnv, { + GIT_TERMINAL_PROMPT: '0', + GIT_ASKPASS: '', + SSH_ASKPASS: '', + GH_PROMPT_DISABLED: '1', + GCM_INTERACTIVE: 'never', + DISPLAY: '', + DBUS_SESSION_BUS_ADDRESS: '', + GIT_CONFIG_COUNT: (gitConfigCount + 1).toString(), + [newKey]: 'credential.helper', + [newValue]: '', + }); + } + + // 3. Prepare Sandboxed Command + const sandboxedCommand = await sandboxManager.prepareCommand({ + command: executable, + args: spawnArgs, + env: baseEnv, cwd, config: { ...shellExecutionConfig, ...(shellExecutionConfig.sandboxConfig || {}), + sanitizationConfig, }, }); + // 4. Execute if (shouldUseNodePty) { const ptyInfo = await getPty(); if (ptyInfo) { try { return await this.executeWithPty( - commandToExecute, + sandboxedCommand.program, + sandboxedCommand.args, cwd, onOutputEvent, abortSignal, shellExecutionConfig, ptyInfo, - sanitizedEnv, - isStrictSandbox, + sandboxedCommand.env, ); } catch (_e) { // Fallback to child_process @@ -319,13 +394,12 @@ export class ShellExecutionService { } return this.childProcessFallback( - commandToExecute, + sandboxedCommand.program, + sandboxedCommand.args, cwd, onOutputEvent, abortSignal, - shellExecutionConfig.sanitizationConfig, - shouldUseNodePty, - isStrictSandbox, + sandboxedCommand.env, ); } @@ -360,69 +434,15 @@ export class ShellExecutionService { } private static childProcessFallback( - commandToExecute: string, + executable: string, + spawnArgs: string[], cwd: string, onOutputEvent: (event: ShellOutputEvent) => void, abortSignal: AbortSignal, - sanitizationConfig: EnvironmentSanitizationConfig, - isInteractive: boolean, - isStrictSandbox?: boolean, + env: Record, ): ShellExecutionHandle { try { const isWindows = os.platform() === 'win32'; - let { executable, argsPrefix, shell } = getShellConfiguration(); - - if (isStrictSandbox) { - shell = 'cmd'; - argsPrefix = ['/c']; - executable = 'cmd.exe'; - } - - const guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell); - const spawnArgs = [...argsPrefix, guardedCommand]; - - // Specifically allow GIT_CONFIG_* variables to pass through sanitization - // in non-interactive mode so we can safely append our overrides. - const gitConfigKeys = !isInteractive - ? Object.keys(process.env).filter((k) => k.startsWith('GIT_CONFIG_')) - : []; - const sanitizedEnv = sanitizeEnvironment(process.env, { - ...sanitizationConfig, - allowedEnvironmentVariables: [ - ...(sanitizationConfig.allowedEnvironmentVariables || []), - ...gitConfigKeys, - ], - }); - - const env: NodeJS.ProcessEnv = { - ...sanitizedEnv, - [GEMINI_CLI_IDENTIFICATION_ENV_VAR]: - GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE, - TERM: 'xterm-256color', - PAGER: 'cat', - GIT_PAGER: 'cat', - }; - - if (!isInteractive) { - const gitConfigCount = parseInt( - sanitizedEnv['GIT_CONFIG_COUNT'] || '0', - 10, - ); - Object.assign(env, { - // Disable interactive prompts and session-linked credential helpers - // in non-interactive mode to prevent hangs in detached process groups. - GIT_TERMINAL_PROMPT: '0', - GIT_ASKPASS: '', - SSH_ASKPASS: '', - GH_PROMPT_DISABLED: '1', - GCM_INTERACTIVE: 'never', - DISPLAY: '', - DBUS_SESSION_BUS_ADDRESS: '', - GIT_CONFIG_COUNT: (gitConfigCount + 1).toString(), - [`GIT_CONFIG_KEY_${gitConfigCount}`]: 'credential.helper', - [`GIT_CONFIG_VALUE_${gitConfigCount}`]: '', - }); - } const child = cpSpawn(executable, spawnArgs, { cwd, @@ -701,14 +721,14 @@ export class ShellExecutionService { } private static async executeWithPty( - commandToExecute: string, + executable: string, + args: string[], cwd: string, onOutputEvent: (event: ShellOutputEvent) => void, abortSignal: AbortSignal, shellExecutionConfig: ShellExecutionConfig, ptyInfo: PtyImplementation, - sanitizedEnv: Record, - isStrictSandbox?: boolean, + env: Record, ): Promise { if (!ptyInfo) { // This should not happen, but as a safeguard... @@ -719,13 +739,6 @@ export class ShellExecutionService { try { const cols = shellExecutionConfig.terminalWidth ?? 80; const rows = shellExecutionConfig.terminalHeight ?? 30; - let { executable, argsPrefix, shell } = getShellConfiguration(); - - if (isStrictSandbox) { - shell = 'cmd'; - argsPrefix = ['/c']; - executable = 'cmd.exe'; - } const resolvedExecutable = await resolveExecutable(executable); if (!resolvedExecutable) { @@ -734,9 +747,6 @@ export class ShellExecutionService { ); } - const guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell); - const args = [...argsPrefix, guardedCommand]; - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const ptyProcess = ptyInfo.module.spawn(executable, args, { cwd, @@ -744,14 +754,13 @@ export class ShellExecutionService { cols, rows, env: { - ...sanitizedEnv, + ...env, GEMINI_CLI: '1', TERM: 'xterm-256color', - PAGER: shellExecutionConfig.pager ?? 'cat', - GIT_PAGER: shellExecutionConfig.pager ?? 'cat', }, handleFlowControl: true, }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion spawnedPty = ptyProcess as IPty; const ptyPid = Number(ptyProcess.pid); diff --git a/packages/core/src/services/windowsSandboxManager.ts b/packages/core/src/services/windowsSandboxManager.ts index c1d11a0db97..df69c95a92f 100644 --- a/packages/core/src/services/windowsSandboxManager.ts +++ b/packages/core/src/services/windowsSandboxManager.ts @@ -30,6 +30,7 @@ export class WindowsSandboxManager implements SandboxManager { private readonly helperPath: string; private readonly platform: string; private initialized = false; + private readonly lowIntegrityCache = new Set(); constructor(platform: string = process.platform) { this.platform = platform; @@ -139,10 +140,37 @@ export class WindowsSandboxManager implements SandboxManager { if (this.platform !== 'win32') { return; } + + const resolvedPath = path.resolve(targetPath); + if (this.lowIntegrityCache.has(resolvedPath)) { + return; + } + + // Never modify integrity levels for system directories + const systemRoot = process.env['SystemRoot'] || 'C:\\Windows'; + const programFiles = process.env['ProgramFiles'] || 'C:\\Program Files'; + const programFilesX86 = + process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'; + + if ( + resolvedPath.toLowerCase().startsWith(systemRoot.toLowerCase()) || + resolvedPath.toLowerCase().startsWith(programFiles.toLowerCase()) || + resolvedPath.toLowerCase().startsWith(programFilesX86.toLowerCase()) + ) { + return; + } + try { - spawnSync('icacls', [targetPath, '/setintegritylevel', 'Low'], { - stdio: 'ignore', - }); + const result = spawnSync( + 'icacls', + [resolvedPath, '/setintegritylevel', 'Low'], + { + stdio: 'ignore', + }, + ); + if (result.status === 0) { + this.lowIntegrityCache.add(resolvedPath); + } } catch (_e) { // Best effort } From 2431d75250b438a397bd077ee07928d850be001f Mon Sep 17 00:00:00 2001 From: mkorwel Date: Wed, 18 Mar 2026 12:07:17 -0700 Subject: [PATCH 05/13] feat(windows-sandbox): address final async review findings and fix eslint --- .../sandboxedFileSystemService.test.ts | 36 ++++---- .../src/services/scripts/GeminiSandbox.cs | 6 ++ .../src/services/windowsSandboxManager.ts | 82 +++++++++++-------- 3 files changed, 73 insertions(+), 51 deletions(-) diff --git a/packages/core/src/services/sandboxedFileSystemService.test.ts b/packages/core/src/services/sandboxedFileSystemService.test.ts index d18fcce962c..973c642436f 100644 --- a/packages/core/src/services/sandboxedFileSystemService.test.ts +++ b/packages/core/src/services/sandboxedFileSystemService.test.ts @@ -11,8 +11,9 @@ import type { SandboxRequest, SandboxedCommand, } from './sandboxManager.js'; -import { spawn } from 'node:child_process'; +import { spawn, type ChildProcess } from 'node:child_process'; import { EventEmitter } from 'node:events'; +import type { Writable } from 'node:stream'; vi.mock('node:child_process', () => ({ spawn: vi.fn(), @@ -40,10 +41,11 @@ describe('SandboxedFileSystemService', () => { }); it('should read a file through the sandbox', async () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const mockChild = new EventEmitter() as any; - mockChild.stdout = new EventEmitter(); - mockChild.stderr = new EventEmitter(); + const mockChild = new EventEmitter() as unknown as ChildProcess; + Object.assign(mockChild, { + stdout: new EventEmitter(), + stderr: new EventEmitter(), + }); vi.mocked(spawn).mockReturnValue(mockChild); @@ -65,13 +67,14 @@ describe('SandboxedFileSystemService', () => { }); it('should write a file through the sandbox', async () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const mockChild = new EventEmitter() as any; - mockChild.stdin = { - write: vi.fn(), - end: vi.fn(), - }; - mockChild.stderr = new EventEmitter(); + const mockChild = new EventEmitter() as unknown as ChildProcess; + Object.assign(mockChild, { + stdin: { + write: vi.fn(), + end: vi.fn(), + } as unknown as Writable, + stderr: new EventEmitter(), + }); vi.mocked(spawn).mockReturnValue(mockChild); @@ -92,10 +95,11 @@ describe('SandboxedFileSystemService', () => { }); it('should reject if sandbox command fails', async () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const mockChild = new EventEmitter() as any; - mockChild.stdout = new EventEmitter(); - mockChild.stderr = new EventEmitter(); + const mockChild = new EventEmitter() as unknown as ChildProcess; + Object.assign(mockChild, { + stdout: new EventEmitter(), + stderr: new EventEmitter(), + }); vi.mocked(spawn).mockReturnValue(mockChild); diff --git a/packages/core/src/services/scripts/GeminiSandbox.cs b/packages/core/src/services/scripts/GeminiSandbox.cs index b56e2772d61..78bd54144d8 100644 --- a/packages/core/src/services/scripts/GeminiSandbox.cs +++ b/packages/core/src/services/scripts/GeminiSandbox.cs @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + using System; using System.Runtime.InteropServices; using System.Collections.Generic; diff --git a/packages/core/src/services/windowsSandboxManager.ts b/packages/core/src/services/windowsSandboxManager.ts index df69c95a92f..f16181e4d4c 100644 --- a/packages/core/src/services/windowsSandboxManager.ts +++ b/packages/core/src/services/windowsSandboxManager.ts @@ -17,6 +17,7 @@ import { sanitizeEnvironment, type EnvironmentSanitizationConfig, } from './environmentSanitization.js'; +import { debugLogger } from '../utils/debugLogger.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -44,42 +45,49 @@ export class WindowsSandboxManager implements SandboxManager { 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', - ), - ]; - - for (const csc of cscPaths) { - const result = spawnSync( - csc, - ['/out:' + this.helperPath, sourcePath], - { - stdio: 'ignore', - }, - ); - if (result.status === 0) { - break; + try { + 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', + ), + ]; + + for (const csc of cscPaths) { + const result = spawnSync( + csc, + ['/out:' + this.helperPath, sourcePath], + { + stdio: 'ignore', + }, + ); + if (result.status === 0) { + break; + } } } } + } catch (e) { + debugLogger.log( + 'WindowsSandboxManager: Failed to initialize sandbox helper:', + e, + ); } this.initialized = true; @@ -171,8 +179,12 @@ export class WindowsSandboxManager implements SandboxManager { if (result.status === 0) { this.lowIntegrityCache.add(resolvedPath); } - } catch (_e) { - // Best effort + } catch (e) { + debugLogger.log( + 'WindowsSandboxManager: icacls failed for', + resolvedPath, + e, + ); } } } From 7268c120b8a23dff99e662e57299b919abb43dbd Mon Sep 17 00:00:00 2001 From: mkorwel Date: Wed, 18 Mar 2026 12:29:48 -0700 Subject: [PATCH 06/13] test(core): fix typescript errors in sandboxed filesystem tests --- .../core/src/services/sandboxedFileSystemService.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/src/services/sandboxedFileSystemService.test.ts b/packages/core/src/services/sandboxedFileSystemService.test.ts index 973c642436f..8c1bf9d968b 100644 --- a/packages/core/src/services/sandboxedFileSystemService.test.ts +++ b/packages/core/src/services/sandboxedFileSystemService.test.ts @@ -53,7 +53,7 @@ describe('SandboxedFileSystemService', () => { // Use setImmediate to ensure events are emitted after the promise starts executing setImmediate(() => { - mockChild.stdout.emit('data', Buffer.from('file content')); + mockChild.stdout!.emit('data', Buffer.from('file content')); mockChild.emit('close', 0); }); @@ -85,8 +85,8 @@ describe('SandboxedFileSystemService', () => { }); await writePromise; - expect(mockChild.stdin.write).toHaveBeenCalledWith('new content'); - expect(mockChild.stdin.end).toHaveBeenCalled(); + expect(mockChild.stdin!.write).toHaveBeenCalledWith('new content'); + expect(mockChild.stdin!.end).toHaveBeenCalled(); expect(spawn).toHaveBeenCalledWith( 'sandbox.exe', ['0', cwd, '__write', '/test/file.txt'], @@ -106,7 +106,7 @@ describe('SandboxedFileSystemService', () => { const readPromise = service.readTextFile('/test/file.txt'); setImmediate(() => { - mockChild.stderr.emit('data', Buffer.from('access denied')); + mockChild.stderr!.emit('data', Buffer.from('access denied')); mockChild.emit('close', 1); }); From 2b666506da694e6918978bac53531daea74b1c66 Mon Sep 17 00:00:00 2001 From: mkorwel Date: Wed, 18 Mar 2026 20:42:57 -0700 Subject: [PATCH 07/13] feat(windows-sandbox): fix lint errors and type mismatches --- packages/cli/src/config/config.ts | 6 +- packages/core/src/config/config.ts | 59 +++++++++---------- .../core/src/services/sandboxManager.test.ts | 14 ++--- packages/core/src/services/sandboxManager.ts | 22 +------ .../src/services/sandboxManagerFactory.ts | 45 ++++++++++++++ .../sandboxedFileSystemService.test.ts | 6 +- 6 files changed, 87 insertions(+), 65 deletions(-) create mode 100644 packages/core/src/services/sandboxManagerFactory.ts diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 3ab5ddb6ed6..db5fd98491d 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -703,12 +703,10 @@ export async function loadCliConfig( : specifiedModel || defaultModel; const sandboxConfig = await loadSandboxConfig(settings, argv); if (sandboxConfig) { + const existingPaths = sandboxConfig.allowedPaths || []; if (settings.tools.sandboxAllowedPaths?.length) { sandboxConfig.allowedPaths = [ - ...new Set([ - ...sandboxConfig.allowedPaths, - ...settings.tools.sandboxAllowedPaths, - ]), + ...new Set([...existingPaths, ...settings.tools.sandboxAllowedPaths]), ]; } if (settings.tools.sandboxNetworkAccess !== undefined) { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index d6de9d41f27..b440b35d4f1 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -6,7 +6,6 @@ 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'; @@ -43,11 +42,11 @@ import type { HookDefinition, HookEventName } from '../hooks/types.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { GitService } from '../services/gitService.js'; import { - createSandboxManager, type SandboxManager, NoopSandboxManager, } from '../services/sandboxManager.js'; -import { WindowsSandboxManager } from '../services/windowsSandboxManager.js'; +import { createSandboxManager } from '../services/sandboxManagerFactory.js'; +import { SandboxedFileSystemService } from '../services/sandboxedFileSystemService.js'; import { initializeTelemetry, DEFAULT_TELEMETRY_TARGET, @@ -78,7 +77,6 @@ import { StandardFileSystemService, type FileSystemService, } from '../services/fileSystemService.js'; -import { SandboxedFileSystemService } from '../services/sandboxedFileSystemService.js'; import { TrackerCreateTaskTool, TrackerUpdateTaskTool, @@ -469,8 +467,8 @@ export enum AuthProviderType { export interface SandboxConfig { enabled: boolean; - allowedPaths: string[]; - networkAccess: boolean; + allowedPaths?: string[]; + networkAccess?: boolean; command?: | 'docker' | 'podman' @@ -499,6 +497,15 @@ export const ConfigSchema = z.object({ .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(), }); @@ -882,7 +889,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, @@ -896,6 +902,21 @@ export class Config implements McpContext, AgentLoopContext { allowedPaths: [], networkAccess: false, }; + + this._sandboxManager = createSandboxManager(this.sandbox, params.targetDir); + + if ( + !(this._sandboxManager instanceof NoopSandboxManager) && + this.sandbox.enabled + ) { + this.fileSystemService = new SandboxedFileSystemService( + this._sandboxManager, + params.targetDir, + ); + } else { + this.fileSystemService = new StandardFileSystemService(); + } + this.targetDir = path.resolve(params.targetDir); this.folderTrust = params.folderTrust ?? false; this.workspaceContext = new WorkspaceContext(this.targetDir, []); @@ -1066,25 +1087,6 @@ 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._sandboxManager instanceof NoopSandboxManager)) { - 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, @@ -1214,12 +1216,7 @@ export class Config implements McpContext, AgentLoopContext { } } this._geminiClient = new GeminiClient(this); - this._sandboxManager = createSandboxManager( - params.toolSandboxing ?? false, - this.targetDir, - ); this.a2aClientManager = new A2AClientManager(this); - this.shellExecutionConfig.sandboxManager = this._sandboxManager; this.modelRouterService = new ModelRouterService(this); } diff --git a/packages/core/src/services/sandboxManager.test.ts b/packages/core/src/services/sandboxManager.test.ts index 1c351ce483c..d201314d9f6 100644 --- a/packages/core/src/services/sandboxManager.test.ts +++ b/packages/core/src/services/sandboxManager.test.ts @@ -6,13 +6,11 @@ import os from 'node:os'; import { describe, expect, it, vi } from 'vitest'; -import { - NoopSandboxManager, - LocalSandboxManager, - createSandboxManager, -} from './sandboxManager.js'; +import { NoopSandboxManager } from './sandboxManager.js'; +import { createSandboxManager } from './sandboxManagerFactory.js'; import { LinuxSandboxManager } from '../sandbox/linux/LinuxSandboxManager.js'; import { MacOsSandboxManager } from '../sandbox/macos/MacOsSandboxManager.js'; +import { WindowsSandboxManager } from './windowsSandboxManager.js'; describe('NoopSandboxManager', () => { const sandboxManager = new NoopSandboxManager(); @@ -121,20 +119,20 @@ describe('NoopSandboxManager', () => { describe('createSandboxManager', () => { it('should return NoopSandboxManager if sandboxing is disabled', () => { - const manager = createSandboxManager(false, '/workspace'); + const manager = createSandboxManager({ enabled: false }, '/workspace'); expect(manager).toBeInstanceOf(NoopSandboxManager); }); it.each([ { platform: 'linux', expected: LinuxSandboxManager }, { platform: 'darwin', expected: MacOsSandboxManager }, - { platform: 'win32', expected: LocalSandboxManager }, + { platform: 'win32', expected: WindowsSandboxManager }, ] as const)( 'should return $expected.name if sandboxing is enabled and platform is $platform', ({ platform, expected }) => { const osSpy = vi.spyOn(os, 'platform').mockReturnValue(platform); try { - const manager = createSandboxManager(true, '/workspace'); + const manager = createSandboxManager({ enabled: true }, '/workspace'); expect(manager).toBeInstanceOf(expected); } finally { osSpy.mockRestore(); diff --git a/packages/core/src/services/sandboxManager.ts b/packages/core/src/services/sandboxManager.ts index 734710290bf..8642edff119 100644 --- a/packages/core/src/services/sandboxManager.ts +++ b/packages/core/src/services/sandboxManager.ts @@ -4,14 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import os from 'node:os'; import { sanitizeEnvironment, getSecureSanitizationConfig, type EnvironmentSanitizationConfig, } from './environmentSanitization.js'; -import { LinuxSandboxManager } from '../sandbox/linux/LinuxSandboxManager.js'; -import { MacOsSandboxManager } from '../sandbox/macos/MacOsSandboxManager.js'; /** * Request for preparing a command to run in a sandbox. @@ -90,21 +87,4 @@ export class LocalSandboxManager implements SandboxManager { } } -/** - * Creates a sandbox manager based on the provided settings. - */ -export function createSandboxManager( - sandboxingEnabled: boolean, - workspace: string, -): SandboxManager { - if (sandboxingEnabled) { - if (os.platform() === 'linux') { - return new LinuxSandboxManager({ workspace }); - } - if (os.platform() === 'darwin') { - return new MacOsSandboxManager({ workspace }); - } - return new LocalSandboxManager(); - } - return new NoopSandboxManager(); -} +export { createSandboxManager } from './sandboxManagerFactory.js'; diff --git a/packages/core/src/services/sandboxManagerFactory.ts b/packages/core/src/services/sandboxManagerFactory.ts new file mode 100644 index 00000000000..fffc366da9f --- /dev/null +++ b/packages/core/src/services/sandboxManagerFactory.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import os from 'node:os'; +import { + type SandboxManager, + NoopSandboxManager, + LocalSandboxManager, +} from './sandboxManager.js'; +import { LinuxSandboxManager } from '../sandbox/linux/LinuxSandboxManager.js'; +import { MacOsSandboxManager } from '../sandbox/macos/MacOsSandboxManager.js'; +import { WindowsSandboxManager } from './windowsSandboxManager.js'; +import type { SandboxConfig } from '../config/config.js'; + +/** + * Creates a sandbox manager based on the provided settings. + */ +export function createSandboxManager( + sandbox: SandboxConfig | undefined, + workspace: string, +): SandboxManager { + const isWindows = os.platform() === 'win32'; + + if ( + isWindows && + (sandbox?.enabled || sandbox?.command === 'windows-native') + ) { + return new WindowsSandboxManager(); + } + + if (sandbox?.enabled) { + if (os.platform() === 'linux') { + return new LinuxSandboxManager({ workspace }); + } + if (os.platform() === 'darwin') { + return new MacOsSandboxManager({ workspace }); + } + return new LocalSandboxManager(); + } + + return new NoopSandboxManager(); +} diff --git a/packages/core/src/services/sandboxedFileSystemService.test.ts b/packages/core/src/services/sandboxedFileSystemService.test.ts index 8c1bf9d968b..47c997f4a8d 100644 --- a/packages/core/src/services/sandboxedFileSystemService.test.ts +++ b/packages/core/src/services/sandboxedFileSystemService.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { SandboxedFileSystemService } from './sandboxedFileSystemService.js'; import type { SandboxManager, @@ -40,6 +40,10 @@ describe('SandboxedFileSystemService', () => { vi.clearAllMocks(); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it('should read a file through the sandbox', async () => { const mockChild = new EventEmitter() as unknown as ChildProcess; Object.assign(mockChild, { From 16d91aad2e0430334884ee02878b652fe8d915fd Mon Sep 17 00:00:00 2001 From: mkorwel Date: Wed, 18 Mar 2026 21:16:59 -0700 Subject: [PATCH 08/13] feat(windows-sandbox): architectural hardening, spawnAsync refactor, and build fixes --- .geminiignore | 1 + docs/cli/settings.md | 2 + .../services/sandboxedFileSystemService.ts | 4 ++ .../src/services/windowsSandboxManager.ts | 67 +++++++++++-------- schemas/settings.schema.json | 21 +++++- scripts/copy_files.js | 2 +- 6 files changed, 66 insertions(+), 31 deletions(-) create mode 100644 .geminiignore diff --git a/.geminiignore b/.geminiignore new file mode 100644 index 00000000000..e40b6ba36e1 --- /dev/null +++ b/.geminiignore @@ -0,0 +1 @@ +packages/core/src/services/scripts/*.exe diff --git a/docs/cli/settings.md b/docs/cli/settings.md index eb9ba4158e7..efacec88751 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -115,6 +115,8 @@ they appear in the UI. | UI Label | Setting | Description | Default | | -------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| Sandbox Allowed Paths | `tools.sandboxAllowedPaths` | List of additional paths that the sandbox is allowed to access. | `[]` | +| Sandbox Network Access | `tools.sandboxNetworkAccess` | Whether the sandbox is allowed to access the network. | `false` | | Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` | | Show Color | `tools.shell.showColor` | Show color in shell output. | `false` | | Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` | diff --git a/packages/core/src/services/sandboxedFileSystemService.ts b/packages/core/src/services/sandboxedFileSystemService.ts index 5c769768518..1f3cbfea84c 100644 --- a/packages/core/src/services/sandboxedFileSystemService.ts +++ b/packages/core/src/services/sandboxedFileSystemService.ts @@ -26,6 +26,8 @@ export class SandboxedFileSystemService implements FileSystemService { }); return new Promise((resolve, reject) => { + // Direct spawn is necessary here for streaming large file contents. + const child = spawn(prepared.program, prepared.args, { cwd: this.cwd, env: prepared.env, @@ -65,6 +67,8 @@ export class SandboxedFileSystemService implements FileSystemService { }); return new Promise((resolve, reject) => { + // Direct spawn is necessary here for streaming large file contents. + const child = spawn(prepared.program, prepared.args, { cwd: this.cwd, env: prepared.env, diff --git a/packages/core/src/services/windowsSandboxManager.ts b/packages/core/src/services/windowsSandboxManager.ts index f16181e4d4c..b777e9baa4d 100644 --- a/packages/core/src/services/windowsSandboxManager.ts +++ b/packages/core/src/services/windowsSandboxManager.ts @@ -5,19 +5,19 @@ */ 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, +import type { + SandboxManager, + SandboxRequest, + SandboxedCommand, } from './sandboxManager.js'; import { sanitizeEnvironment, type EnvironmentSanitizationConfig, } from './environmentSanitization.js'; import { debugLogger } from '../utils/debugLogger.js'; +import { spawnAsync } from '../utils/shell-utils.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -38,7 +38,7 @@ export class WindowsSandboxManager implements SandboxManager { this.helperPath = path.resolve(__dirname, 'scripts', 'GeminiSandbox.exe'); } - private ensureInitialized(): void { + private async ensureInitialized(): Promise { if (this.initialized) return; if (this.platform !== 'win32') { this.initialized = true; @@ -67,18 +67,37 @@ export class WindowsSandboxManager implements SandboxManager { 'v4.0.30319', 'csc.exe', ), + // Added newer framework paths + path.join( + systemRoot, + 'Microsoft.NET', + 'Framework64', + 'v4.8', + 'csc.exe', + ), + path.join( + systemRoot, + 'Microsoft.NET', + 'Framework', + 'v4.8', + 'csc.exe', + ), + path.join( + systemRoot, + 'Microsoft.NET', + 'Framework64', + 'v3.5', + 'csc.exe', + ), ]; for (const csc of cscPaths) { - const result = spawnSync( - csc, - ['/out:' + this.helperPath, sourcePath], - { - stdio: 'ignore', - }, - ); - if (result.status === 0) { + try { + // We use spawnAsync but we don't need to capture output + await spawnAsync(csc, ['/out:' + this.helperPath, sourcePath]); break; + } catch (_e) { + // Try next path } } } @@ -97,7 +116,7 @@ export class WindowsSandboxManager implements SandboxManager { * Prepares a command for sandboxed execution on Windows. */ async prepareCommand(req: SandboxRequest): Promise { - this.ensureInitialized(); + await this.ensureInitialized(); const sanitizationConfig: EnvironmentSanitizationConfig = { allowedEnvironmentVariables: @@ -113,12 +132,12 @@ export class WindowsSandboxManager implements SandboxManager { // 1. Handle filesystem permissions for Low Integrity // Grant "Low Mandatory Level" write access to the CWD. - this.grantLowIntegrityAccess(req.cwd); + await 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); + await this.grantLowIntegrityAccess(allowedPath); } } @@ -144,7 +163,7 @@ export class WindowsSandboxManager implements SandboxManager { /** * Grants "Low Mandatory Level" access to a path using icacls. */ - private grantLowIntegrityAccess(targetPath: string): void { + private async grantLowIntegrityAccess(targetPath: string): Promise { if (this.platform !== 'win32') { return; } @@ -169,16 +188,8 @@ export class WindowsSandboxManager implements SandboxManager { } try { - const result = spawnSync( - 'icacls', - [resolvedPath, '/setintegritylevel', 'Low'], - { - stdio: 'ignore', - }, - ); - if (result.status === 0) { - this.lowIntegrityCache.add(resolvedPath); - } + await spawnAsync('icacls', [resolvedPath, '/setintegritylevel', 'Low']); + this.lowIntegrityCache.add(resolvedPath); } catch (e) { debugLogger.log( 'WindowsSandboxManager: icacls failed for', diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index f85a39bb352..684dd19e449 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -1924,10 +1924,27 @@ "properties": { "sandbox": { "title": "Sandbox", - "description": "Legacy full-process sandbox execution environment. Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile, or specify an explicit sandbox command (e.g., \"docker\", \"podman\", \"lxc\").", - "markdownDescription": "Legacy full-process sandbox execution environment. Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile, or specify an explicit sandbox command (e.g., \"docker\", \"podman\", \"lxc\").\n\n- Category: `Tools`\n- Requires restart: `yes`", + "description": "Legacy full-process sandbox execution environment. Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile, or specify an explicit sandbox command (e.g., \"docker\", \"podman\", \"lxc\", \"windows-native\").", + "markdownDescription": "Legacy full-process sandbox execution environment. Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile, or specify an explicit sandbox command (e.g., \"docker\", \"podman\", \"lxc\", \"windows-native\").\n\n- Category: `Tools`\n- Requires restart: `yes`", "$ref": "#/$defs/BooleanOrStringOrObject" }, + "sandboxAllowedPaths": { + "title": "Sandbox Allowed Paths", + "description": "List of additional paths that the sandbox is allowed to access.", + "markdownDescription": "List of additional paths that the sandbox is allowed to access.\n\n- Category: `Tools`\n- Requires restart: `yes`\n- Default: `[]`", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "sandboxNetworkAccess": { + "title": "Sandbox Network Access", + "description": "Whether the sandbox is allowed to access the network.", + "markdownDescription": "Whether the sandbox is allowed to access the network.\n\n- Category: `Tools`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, + "type": "boolean" + }, "shell": { "title": "Shell", "description": "Settings for shell execution.", diff --git a/scripts/copy_files.js b/scripts/copy_files.js index fc612fd144d..d02070362fe 100644 --- a/scripts/copy_files.js +++ b/scripts/copy_files.js @@ -26,7 +26,7 @@ import path from 'node:path'; const sourceDir = path.join('src'); const targetDir = path.join('dist', 'src'); -const extensionsToCopy = ['.md', '.json', '.sb', '.toml']; +const extensionsToCopy = ['.md', '.json', '.sb', '.toml', '.cs', '.exe']; function copyFilesRecursive(source, target) { if (!fs.existsSync(target)) { From 7fd61ced97f741d710d8e4193859556abef34ae9 Mon Sep 17 00:00:00 2001 From: mkorwel Date: Thu, 19 Mar 2026 11:31:30 -0700 Subject: [PATCH 09/13] style: apply prettier formatting to windows-sandbox related files --- packages/cli/src/ui/hooks/slashCommandProcessor.ts | 2 -- packages/core/src/services/sandboxedFileSystemService.ts | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 5b58c0d2024..9a7ec0b932d 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -505,9 +505,7 @@ export const useSlashCommandProcessor = ( const props = result.props as Record; if ( !props || - typeof props['name'] !== 'string' || - typeof props['displayName'] !== 'string' || !props['definition'] ) { diff --git a/packages/core/src/services/sandboxedFileSystemService.ts b/packages/core/src/services/sandboxedFileSystemService.ts index 1f3cbfea84c..376d6ceac91 100644 --- a/packages/core/src/services/sandboxedFileSystemService.ts +++ b/packages/core/src/services/sandboxedFileSystemService.ts @@ -27,7 +27,7 @@ export class SandboxedFileSystemService implements FileSystemService { return new Promise((resolve, reject) => { // Direct spawn is necessary here for streaming large file contents. - + const child = spawn(prepared.program, prepared.args, { cwd: this.cwd, env: prepared.env, @@ -68,7 +68,7 @@ export class SandboxedFileSystemService implements FileSystemService { return new Promise((resolve, reject) => { // Direct spawn is necessary here for streaming large file contents. - + const child = spawn(prepared.program, prepared.args, { cwd: this.cwd, env: prepared.env, From a4fe9374b3346cb04a63903a4295a50c96c71590 Mon Sep 17 00:00:00 2001 From: mkorwel Date: Thu, 19 Mar 2026 11:54:19 -0700 Subject: [PATCH 10/13] chore(windows-sandbox): improve diagnostics and error handling in sandboxed file system --- .../sandboxedFileSystemService.test.ts | 2 +- .../services/sandboxedFileSystemService.ts | 20 ++++++++++-- .../src/services/windowsSandboxManager.ts | 31 +++++++++++++++++-- 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/packages/core/src/services/sandboxedFileSystemService.test.ts b/packages/core/src/services/sandboxedFileSystemService.test.ts index 47c997f4a8d..f7adce966b4 100644 --- a/packages/core/src/services/sandboxedFileSystemService.test.ts +++ b/packages/core/src/services/sandboxedFileSystemService.test.ts @@ -115,7 +115,7 @@ describe('SandboxedFileSystemService', () => { }); await expect(readPromise).rejects.toThrow( - 'Sandbox Error: Command failed with exit code 1. Details: access denied', + "Sandbox Error: read_file failed for '/test/file.txt'. Exit code 1. Details: access denied", ); }); }); diff --git a/packages/core/src/services/sandboxedFileSystemService.ts b/packages/core/src/services/sandboxedFileSystemService.ts index 376d6ceac91..dda1fbb0d72 100644 --- a/packages/core/src/services/sandboxedFileSystemService.ts +++ b/packages/core/src/services/sandboxedFileSystemService.ts @@ -50,11 +50,19 @@ export class SandboxedFileSystemService implements FileSystemService { } else { reject( new Error( - `Sandbox Error: Command failed with exit code ${code}. ${error ? 'Details: ' + error : ''}`, + `Sandbox Error: read_file failed for '${filePath}'. Exit code ${code}. ${error ? 'Details: ' + error : ''}`, ), ); } }); + + child.on('error', (err) => { + reject( + new Error( + `Sandbox Error: Failed to spawn read_file for '${filePath}': ${err.message}`, + ), + ); + }); }); } @@ -88,11 +96,19 @@ export class SandboxedFileSystemService implements FileSystemService { } else { reject( new Error( - `Sandbox Error: Command failed with exit code ${code}. ${error ? 'Details: ' + error : ''}`, + `Sandbox Error: write_file failed for '${filePath}'. Exit code ${code}. ${error ? 'Details: ' + error : ''}`, ), ); } }); + + child.on('error', (err) => { + reject( + new Error( + `Sandbox Error: Failed to spawn write_file for '${filePath}': ${err.message}`, + ), + ); + }); }); } } diff --git a/packages/core/src/services/windowsSandboxManager.ts b/packages/core/src/services/windowsSandboxManager.ts index b777e9baa4d..dc39b9ee674 100644 --- a/packages/core/src/services/windowsSandboxManager.ts +++ b/packages/core/src/services/windowsSandboxManager.ts @@ -47,6 +47,9 @@ export class WindowsSandboxManager implements SandboxManager { try { if (!fs.existsSync(this.helperPath)) { + debugLogger.log( + `WindowsSandboxManager: Helper not found at ${this.helperPath}. Attempting to compile...`, + ); // 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)) { @@ -91,16 +94,40 @@ export class WindowsSandboxManager implements SandboxManager { ), ]; + let compiled = false; for (const csc of cscPaths) { try { + debugLogger.log( + `WindowsSandboxManager: Trying to compile using ${csc}...`, + ); // We use spawnAsync but we don't need to capture output await spawnAsync(csc, ['/out:' + this.helperPath, sourcePath]); + debugLogger.log( + `WindowsSandboxManager: Successfully compiled sandbox helper at ${this.helperPath}`, + ); + compiled = true; break; - } catch (_e) { - // Try next path + } catch (e) { + debugLogger.log( + `WindowsSandboxManager: Failed to compile using ${csc}: ${e instanceof Error ? e.message : String(e)}`, + ); } } + + if (!compiled) { + debugLogger.log( + 'WindowsSandboxManager: Failed to compile sandbox helper from any known CSC path.', + ); + } + } else { + debugLogger.log( + `WindowsSandboxManager: Source file not found at ${sourcePath}. Cannot compile helper.`, + ); } + } else { + debugLogger.log( + `WindowsSandboxManager: Found helper at ${this.helperPath}`, + ); } } catch (e) { debugLogger.log( From 4ace82e97704ab7e90b8ab1e9fa2ba9498a7b26d Mon Sep 17 00:00:00 2001 From: mkorwel Date: Thu, 19 Mar 2026 12:13:14 -0700 Subject: [PATCH 11/13] chore(windows-sandbox): fix eslint errors in sandboxed file system --- .../src/services/sandboxedFileSystemService.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/core/src/services/sandboxedFileSystemService.ts b/packages/core/src/services/sandboxedFileSystemService.ts index dda1fbb0d72..575fed49dd3 100644 --- a/packages/core/src/services/sandboxedFileSystemService.ts +++ b/packages/core/src/services/sandboxedFileSystemService.ts @@ -7,6 +7,8 @@ import { spawn } from 'node:child_process'; import { type FileSystemService } from './fileSystemService.js'; import { type SandboxManager } from './sandboxManager.js'; +import { debugLogger } from '../utils/debugLogger.js'; +import { isNodeError } from '../utils/errors.js'; /** * A FileSystemService implementation that performs operations through a sandbox. @@ -82,6 +84,18 @@ export class SandboxedFileSystemService implements FileSystemService { env: prepared.env, }); + child.stdin?.on('error', (err) => { + // Silently ignore EPIPE errors on stdin, they will be caught by the process error/close listeners + if (isNodeError(err) && err.code === 'EPIPE') { + return; + } + debugLogger.error( + `Sandbox Error: stdin error for '${filePath}': ${ + err instanceof Error ? err.message : String(err) + }`, + ); + }); + child.stdin?.write(content); child.stdin?.end(); From c2fb66de47bfbccf295ac83b784649c8b3cd937f Mon Sep 17 00:00:00 2001 From: mkorwel Date: Thu, 19 Mar 2026 14:00:39 -0700 Subject: [PATCH 12/13] test(windows-sandbox): fix sandboxed file system test mock with proper types --- .../services/sandboxedFileSystemService.test.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/core/src/services/sandboxedFileSystemService.test.ts b/packages/core/src/services/sandboxedFileSystemService.test.ts index f7adce966b4..d2ad8d1e8d3 100644 --- a/packages/core/src/services/sandboxedFileSystemService.test.ts +++ b/packages/core/src/services/sandboxedFileSystemService.test.ts @@ -72,11 +72,13 @@ describe('SandboxedFileSystemService', () => { it('should write a file through the sandbox', async () => { const mockChild = new EventEmitter() as unknown as ChildProcess; + const mockStdin = new EventEmitter(); + Object.assign(mockStdin, { + write: vi.fn(), + end: vi.fn(), + }); Object.assign(mockChild, { - stdin: { - write: vi.fn(), - end: vi.fn(), - } as unknown as Writable, + stdin: mockStdin as unknown as Writable, stderr: new EventEmitter(), }); @@ -89,8 +91,10 @@ describe('SandboxedFileSystemService', () => { }); await writePromise; - expect(mockChild.stdin!.write).toHaveBeenCalledWith('new content'); - expect(mockChild.stdin!.end).toHaveBeenCalled(); + expect( + (mockStdin as unknown as { write: vi.Mock }).write, + ).toHaveBeenCalledWith('new content'); + expect((mockStdin as unknown as { end: vi.Mock }).end).toHaveBeenCalled(); expect(spawn).toHaveBeenCalledWith( 'sandbox.exe', ['0', cwd, '__write', '/test/file.txt'], From 75f2eee68dbd44677726bdef068de4de2d195a0d Mon Sep 17 00:00:00 2001 From: mkorwel Date: Thu, 19 Mar 2026 14:08:28 -0700 Subject: [PATCH 13/13] test(windows-sandbox): fix TS compilation error in tests --- .../services/sandboxedFileSystemService.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/core/src/services/sandboxedFileSystemService.test.ts b/packages/core/src/services/sandboxedFileSystemService.test.ts index d2ad8d1e8d3..9983bcfca7d 100644 --- a/packages/core/src/services/sandboxedFileSystemService.test.ts +++ b/packages/core/src/services/sandboxedFileSystemService.test.ts @@ -4,7 +4,15 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mock, +} from 'vitest'; import { SandboxedFileSystemService } from './sandboxedFileSystemService.js'; import type { SandboxManager, @@ -92,9 +100,9 @@ describe('SandboxedFileSystemService', () => { await writePromise; expect( - (mockStdin as unknown as { write: vi.Mock }).write, + (mockStdin as unknown as { write: Mock }).write, ).toHaveBeenCalledWith('new content'); - expect((mockStdin as unknown as { end: vi.Mock }).end).toHaveBeenCalled(); + expect((mockStdin as unknown as { end: Mock }).end).toHaveBeenCalled(); expect(spawn).toHaveBeenCalledWith( 'sandbox.exe', ['0', cwd, '__write', '/test/file.txt'],