2026-03-19 15:25:22 -07:00
|
|
|
/**
|
|
|
|
|
* @license
|
|
|
|
|
* Copyright 2026 Google LLC
|
|
|
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import fs from 'node:fs';
|
|
|
|
|
import path from 'node:path';
|
2026-03-23 11:43:58 -04:00
|
|
|
import os from 'node:os';
|
2026-03-19 15:25:22 -07:00
|
|
|
import { fileURLToPath } from 'node:url';
|
2026-03-23 11:43:58 -04:00
|
|
|
import {
|
|
|
|
|
type SandboxManager,
|
|
|
|
|
type SandboxRequest,
|
|
|
|
|
type SandboxedCommand,
|
2026-03-24 04:04:17 +00:00
|
|
|
GOVERNANCE_FILES,
|
2026-03-23 11:43:58 -04:00
|
|
|
type GlobalSandboxOptions,
|
|
|
|
|
sanitizePaths,
|
2026-03-19 15:25:22 -07:00
|
|
|
} from './sandboxManager.js';
|
|
|
|
|
import {
|
|
|
|
|
sanitizeEnvironment,
|
2026-03-23 11:43:58 -04:00
|
|
|
getSecureSanitizationConfig,
|
2026-03-19 15:25:22 -07:00
|
|
|
} 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);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
private readonly lowIntegrityCache = new Set<string>();
|
|
|
|
|
|
2026-03-23 11:43:58 -04:00
|
|
|
constructor(private readonly options: GlobalSandboxOptions) {
|
2026-03-19 15:25:22 -07:00
|
|
|
this.helperPath = path.resolve(__dirname, 'scripts', 'GeminiSandbox.exe');
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 04:04:17 +00:00
|
|
|
/**
|
|
|
|
|
* Ensures a file or directory exists.
|
|
|
|
|
*/
|
|
|
|
|
private touch(filePath: string, isDirectory: boolean): void {
|
|
|
|
|
try {
|
|
|
|
|
// If it exists (even as a broken symlink), do nothing
|
|
|
|
|
if (fs.lstatSync(filePath)) return;
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore ENOENT
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isDirectory) {
|
|
|
|
|
fs.mkdirSync(filePath, { recursive: true });
|
|
|
|
|
} else {
|
|
|
|
|
const dir = path.dirname(filePath);
|
|
|
|
|
if (!fs.existsSync(dir)) {
|
|
|
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
|
|
|
}
|
|
|
|
|
fs.closeSync(fs.openSync(filePath, 'a'));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 15:25:22 -07:00
|
|
|
private async ensureInitialized(): Promise<void> {
|
|
|
|
|
if (this.initialized) return;
|
2026-03-23 11:43:58 -04:00
|
|
|
if (os.platform() !== 'win32') {
|
2026-03-19 15:25:22 -07:00
|
|
|
this.initialized = true;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)) {
|
|
|
|
|
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',
|
|
|
|
|
),
|
|
|
|
|
// 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',
|
|
|
|
|
),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
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) {
|
|
|
|
|
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(
|
|
|
|
|
'WindowsSandboxManager: Failed to initialize sandbox helper:',
|
|
|
|
|
e,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.initialized = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Prepares a command for sandboxed execution on Windows.
|
|
|
|
|
*/
|
|
|
|
|
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
|
|
|
|
|
await this.ensureInitialized();
|
|
|
|
|
|
2026-03-23 11:43:58 -04:00
|
|
|
const sanitizationConfig = getSecureSanitizationConfig(
|
|
|
|
|
req.policy?.sanitizationConfig,
|
|
|
|
|
);
|
2026-03-19 15:25:22 -07:00
|
|
|
|
|
|
|
|
const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig);
|
|
|
|
|
|
|
|
|
|
// 1. Handle filesystem permissions for Low Integrity
|
2026-03-23 11:43:58 -04:00
|
|
|
// Grant "Low Mandatory Level" write access to the workspace.
|
|
|
|
|
await this.grantLowIntegrityAccess(this.options.workspace);
|
2026-03-19 15:25:22 -07:00
|
|
|
|
|
|
|
|
// Grant "Low Mandatory Level" read access to allowedPaths.
|
2026-03-23 11:43:58 -04:00
|
|
|
const allowedPaths = sanitizePaths(req.policy?.allowedPaths) || [];
|
|
|
|
|
for (const allowedPath of allowedPaths) {
|
|
|
|
|
await this.grantLowIntegrityAccess(allowedPath);
|
2026-03-19 15:25:22 -07:00
|
|
|
}
|
|
|
|
|
|
2026-03-23 11:43:58 -04:00
|
|
|
// TODO: handle forbidden paths
|
|
|
|
|
|
2026-03-24 04:04:17 +00:00
|
|
|
// 2. Protected governance files
|
|
|
|
|
// These must exist on the host before running the sandbox to prevent
|
|
|
|
|
// the sandboxed process from creating them with Low integrity.
|
|
|
|
|
// By being created as Medium integrity, they are write-protected from Low processes.
|
|
|
|
|
for (const file of GOVERNANCE_FILES) {
|
|
|
|
|
const filePath = path.join(this.options.workspace, file.path);
|
|
|
|
|
this.touch(filePath, file.isDirectory);
|
|
|
|
|
|
|
|
|
|
// We resolve real paths to ensure protection for both the symlink and its target.
|
|
|
|
|
try {
|
|
|
|
|
const realPath = fs.realpathSync(filePath);
|
|
|
|
|
if (realPath !== filePath) {
|
|
|
|
|
// If it's a symlink, the target is already implicitly protected
|
|
|
|
|
// if it's outside the Low integrity workspace (likely Medium).
|
|
|
|
|
// If it's inside, we ensure it's not accidentally Low.
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore realpath errors
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. Construct the helper command
|
2026-03-19 15:25:22 -07:00
|
|
|
// GeminiSandbox.exe <network:0|1> <cwd> <command> [args...]
|
|
|
|
|
const program = this.helperPath;
|
|
|
|
|
|
|
|
|
|
// If the command starts with __, it's an internal command for the sandbox helper itself.
|
|
|
|
|
const args = [
|
2026-03-23 11:43:58 -04:00
|
|
|
req.policy?.networkAccess ? '1' : '0',
|
2026-03-19 15:25:22 -07:00
|
|
|
req.cwd,
|
|
|
|
|
req.command,
|
|
|
|
|
...req.args,
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
program,
|
|
|
|
|
args,
|
|
|
|
|
env: sanitizedEnv,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Grants "Low Mandatory Level" access to a path using icacls.
|
|
|
|
|
*/
|
|
|
|
|
private async grantLowIntegrityAccess(targetPath: string): Promise<void> {
|
2026-03-23 11:43:58 -04:00
|
|
|
if (os.platform() !== 'win32') {
|
2026-03-19 15:25:22 -07:00
|
|
|
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 {
|
|
|
|
|
await spawnAsync('icacls', [resolvedPath, '/setintegritylevel', 'Low']);
|
|
|
|
|
this.lowIntegrityCache.add(resolvedPath);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
debugLogger.log(
|
|
|
|
|
'WindowsSandboxManager: icacls failed for',
|
|
|
|
|
resolvedPath,
|
|
|
|
|
e,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|