From f08fad9b87c690161ff26176b436826e7d36af49 Mon Sep 17 00:00:00 2001 From: mkorwel Date: Wed, 18 Mar 2026 11:51:19 -0700 Subject: [PATCH] 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 ec7e88f624..11dbc17f09 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 4e0e9856d9..65dfe47c9b 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 cce5033f1a..3cdef142d7 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 7d47d66e32..e69340c6f7 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 cf70f33522..2cee2f793c 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 2f1bd26cd0..b56e2772d6 100644 --- a/packages/core/src/services/scripts/GeminiSandbox.cs +++ b/packages/core/src/services/scripts/GeminiSandbox.cs @@ -126,6 +126,9 @@ public class GeminiSandbox { [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 @@ public class GeminiSandbox { 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; + IntPtr networkSid = IntPtr.Zero; + IntPtr restrictedSid = IntPtr.Zero; + IntPtr lowIntegritySid = IntPtr.Zero; - // "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); + 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); + 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); + } + + // 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; - } + 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, () => { + // 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); - } - } - return 0; - } catch (Exception e) { - Console.Error.WriteLine(e.Message); - return 1; + Marshal.StructureToPtr(tml, pTml, false); + SetTokenInformation(hRestrictedToken, TokenIntegrityLevel, pTml, (uint)tmlSize); + } finally { + Marshal.FreeHGlobal(pTml); } - }); - } else if (command == "__write") { - string path = args[3]; - return RunInImpersonation(hRestrictedToken, () => { + } + + // 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 + 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 { - 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; + Marshal.StructureToPtr(limitInfo, pLimit, false); + SetInformationJobObject(hJob, JobObjectInfoClass.ExtendedLimitInformation, pLimit, (uint)limitSize); + } finally { + Marshal.FreeHGlobal(pLimit); } - }); - } + } - // 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); - // 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]); + } - 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; + } - 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()); + try { + if (hJob != IntPtr.Zero) { + AssignProcessToJobObject(hJob, pi.hProcess); + } + + ResumeThread(pi.hThread); + WaitForSingleObject(pi.hProcess, INFINITE); + + 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); } - - 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 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 d7d30dd44d..e1fb0e0da4 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 c1d11a0db9..df69c95a92 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 }