mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-19 10:31:16 -07:00
feat(windows-sandbox): address review comments, fix shell integration, and harden security
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user