feat(windows-sandbox): address review comments, fix shell integration, and harden security

This commit is contained in:
mkorwel
2026-03-18 11:51:19 -07:00
parent 4848908b91
commit f08fad9b87
8 changed files with 434 additions and 233 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -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',

View File

@@ -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,

View File

@@ -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<string> quotedArgs = new List<string>();
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<int> action) {

View File

@@ -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<string, string | undefined> = {
...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<string, string | undefined>,
): 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<string, string | undefined>,
isStrictSandbox?: boolean,
env: Record<string, string | undefined>,
): Promise<ShellExecutionHandle> {
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);

View File

@@ -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<string>();
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
}