feat(core,cli): harden windows sandboxing and add platform-agnostic tests

This commit is contained in:
mkorwel
2026-03-13 17:17:26 +00:00
parent 1425e94975
commit 7814427a1e
8 changed files with 206 additions and 50 deletions
+1 -1
View File
@@ -303,7 +303,7 @@ export default tseslint.config(
},
},
{
files: ['./scripts/**/*.js', 'esbuild.config.js'],
files: ['./scripts/**/*.js', 'packages/*/scripts/**/*.js', 'esbuild.config.js'],
languageOptions: {
globals: {
...globals.node,
@@ -338,6 +338,8 @@ describe('loadSandboxConfig', () => {
sandbox: {
enabled: true,
command: 'podman',
allowedPaths: [],
networkAccess: false,
},
},
},
@@ -353,6 +355,8 @@ describe('loadSandboxConfig', () => {
sandbox: {
enabled: true,
image: 'custom/image',
allowedPaths: [],
networkAccess: false,
},
},
},
@@ -367,6 +371,8 @@ describe('loadSandboxConfig', () => {
tools: {
sandbox: {
enabled: false,
allowedPaths: [],
networkAccess: false,
},
},
},
@@ -382,6 +388,7 @@ describe('loadSandboxConfig', () => {
sandbox: {
enabled: true,
allowedPaths: ['/settings-path'],
networkAccess: false,
},
},
},
@@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
/* eslint-env node */
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import fs from 'node:fs';
@@ -24,6 +24,8 @@ export interface SandboxRequest {
/** Optional sandbox-specific configuration. */
config?: {
sanitizationConfig?: Partial<EnvironmentSanitizationConfig>;
allowedPaths?: string[];
networkAccess?: boolean;
};
}
@@ -0,0 +1,113 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SandboxedFileSystemService } from './sandboxedFileSystemService.js';
import type {
SandboxManager,
SandboxRequest,
SandboxedCommand,
} from './sandboxManager.js';
import { spawn } from 'node:child_process';
import { EventEmitter } from 'node:events';
vi.mock('node:child_process', () => ({
spawn: vi.fn(),
}));
class MockSandboxManager implements SandboxManager {
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
return {
program: 'sandbox.exe',
args: ['0', req.cwd, req.command, ...req.args],
env: req.env || {},
};
}
}
describe('SandboxedFileSystemService', () => {
let sandboxManager: MockSandboxManager;
let service: SandboxedFileSystemService;
const cwd = '/test/cwd';
beforeEach(() => {
sandboxManager = new MockSandboxManager();
service = new SandboxedFileSystemService(sandboxManager, cwd);
vi.clearAllMocks();
});
it('should read a file through the sandbox', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockChild = new EventEmitter() as any;
mockChild.stdout = new EventEmitter();
mockChild.stderr = new EventEmitter();
vi.mocked(spawn).mockReturnValue(mockChild);
const readPromise = service.readTextFile('/test/file.txt');
// Use setImmediate to ensure events are emitted after the promise starts executing
setImmediate(() => {
mockChild.stdout.emit('data', Buffer.from('file content'));
mockChild.emit('close', 0);
});
const content = await readPromise;
expect(content).toBe('file content');
expect(spawn).toHaveBeenCalledWith(
'sandbox.exe',
['0', cwd, '__read', '/test/file.txt'],
expect.any(Object),
);
});
it('should write a file through the sandbox', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockChild = new EventEmitter() as any;
mockChild.stdin = {
write: vi.fn(),
end: vi.fn(),
};
mockChild.stderr = new EventEmitter();
vi.mocked(spawn).mockReturnValue(mockChild);
const writePromise = service.writeTextFile('/test/file.txt', 'new content');
setImmediate(() => {
mockChild.emit('close', 0);
});
await writePromise;
expect(mockChild.stdin.write).toHaveBeenCalledWith('new content');
expect(mockChild.stdin.end).toHaveBeenCalled();
expect(spawn).toHaveBeenCalledWith(
'sandbox.exe',
['0', cwd, '__write', '/test/file.txt'],
expect.any(Object),
);
});
it('should reject if sandbox command fails', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockChild = new EventEmitter() as any;
mockChild.stdout = new EventEmitter();
mockChild.stderr = new EventEmitter();
vi.mocked(spawn).mockReturnValue(mockChild);
const readPromise = service.readTextFile('/test/file.txt');
setImmediate(() => {
mockChild.stderr.emit('data', Buffer.from('access denied'));
mockChild.emit('close', 1);
});
await expect(readPromise).rejects.toThrow(
'Sandbox Error: Command failed with exit code 1. Details: access denied',
);
});
});
@@ -31,7 +31,8 @@ import {
sanitizeEnvironment,
type EnvironmentSanitizationConfig,
} from './environmentSanitization.js';
import { NoopSandboxManager } from './sandboxManager.js';
import { NoopSandboxManager, type SandboxManager } from './sandboxManager.js';
import type { SandboxConfig } from '../config/config.js';
import { killProcessGroup } from '../utils/process-utils.js';
import {
ExecutionLifecycleService,
@@ -94,6 +95,8 @@ export interface ShellExecutionConfig {
disableDynamicLineTrimming?: boolean;
scrollback?: number;
maxSerializedLines?: number;
sandboxManager?: SandboxManager;
sandboxConfig?: SandboxConfig;
}
/**
@@ -274,13 +277,17 @@ export class ShellExecutionService {
shouldUseNodePty: boolean,
shellExecutionConfig: ShellExecutionConfig,
): Promise<ShellExecutionHandle> {
const sandboxManager = new NoopSandboxManager();
const sandboxManager =
shellExecutionConfig.sandboxManager ?? new NoopSandboxManager();
const { env: sanitizedEnv } = await sandboxManager.prepareCommand({
command: commandToExecute,
args: [],
env: process.env,
cwd,
config: shellExecutionConfig,
config: {
...shellExecutionConfig,
...(shellExecutionConfig.sandboxConfig || {}),
},
});
if (shouldUseNodePty) {
@@ -7,48 +7,62 @@
import { describe, it, expect } from 'vitest';
import { WindowsSandboxManager } from './windowsSandboxManager.js';
import type { SandboxRequest } from './sandboxManager.js';
import * as os from 'node:os';
describe('WindowsSandboxManager', () => {
const manager = new WindowsSandboxManager();
const manager = new WindowsSandboxManager('win32');
it.skipIf(os.platform() !== 'win32')(
'should prepare a GeminiSandbox.exe command',
async () => {
const req: SandboxRequest = {
command: 'whoami',
args: ['/groups'],
cwd: process.cwd(),
env: { TEST_VAR: 'test_value' },
config: {
networkAccess: false,
it('should prepare a GeminiSandbox.exe command', async () => {
const req: SandboxRequest = {
command: 'whoami',
args: ['/groups'],
cwd: '/test/cwd',
env: { TEST_VAR: 'test_value' },
config: {
networkAccess: false,
},
};
const result = await manager.prepareCommand(req);
expect(result.program).toContain('GeminiSandbox.exe');
expect(result.args).toEqual(['0', '/test/cwd', 'whoami', '/groups']);
});
it('should handle networkAccess from config', async () => {
const req: SandboxRequest = {
command: 'whoami',
args: [],
cwd: '/test/cwd',
env: {},
config: {
networkAccess: true,
},
};
const result = await manager.prepareCommand(req);
expect(result.args[0]).toBe('1');
});
it('should sanitize environment variables', async () => {
const req: SandboxRequest = {
command: 'test',
args: [],
cwd: '/test/cwd',
env: {
API_KEY: 'secret',
PATH: '/usr/bin',
},
config: {
sanitizationConfig: {
allowedEnvironmentVariables: ['PATH'],
blockedEnvironmentVariables: ['API_KEY'],
enableEnvironmentVariableRedaction: true,
},
};
},
};
const result = await manager.prepareCommand(req);
expect(result.program).toContain('GeminiSandbox.exe');
expect(result.args).toEqual(
expect.arrayContaining(['0', process.cwd(), 'whoami', '/groups']),
);
},
);
it.skipIf(os.platform() !== 'win32')(
'should handle networkAccess from config',
async () => {
const req: SandboxRequest = {
command: 'whoami',
args: [],
cwd: process.cwd(),
env: {},
config: {
networkAccess: true,
},
};
const result = await manager.prepareCommand(req);
expect(result.args[0]).toBe('1');
},
);
const result = await manager.prepareCommand(req);
expect(result.env['PATH']).toBe('/usr/bin');
expect(result.env['API_KEY']).toBeUndefined();
});
});
@@ -28,14 +28,20 @@ const __dirname = path.dirname(__filename);
*/
export class WindowsSandboxManager implements SandboxManager {
private readonly helperPath: string;
private readonly platform: string;
private initialized = false;
constructor() {
constructor(platform: string = process.platform) {
this.platform = platform;
this.helperPath = path.resolve(__dirname, 'scripts', 'GeminiSandbox.exe');
}
private ensureInitialized(): void {
if (this.initialized) return;
if (this.platform !== 'win32') {
this.initialized = true;
return;
}
if (!fs.existsSync(this.helperPath)) {
// If the exe doesn't exist, we try to compile it from the .cs file
@@ -60,13 +66,15 @@ export class WindowsSandboxManager implements SandboxManager {
),
];
let compiled = false;
for (const csc of cscPaths) {
const result = spawnSync(csc, ['/out:' + this.helperPath, sourcePath], {
stdio: 'ignore',
});
const result = spawnSync(
csc,
['/out:' + this.helperPath, sourcePath],
{
stdio: 'ignore',
},
);
if (result.status === 0) {
compiled = true;
break;
}
}
@@ -128,11 +136,14 @@ export class WindowsSandboxManager implements SandboxManager {
* Grants "Low Mandatory Level" access to a path using icacls.
*/
private grantLowIntegrityAccess(targetPath: string): void {
if (this.platform !== 'win32') {
return;
}
try {
spawnSync('icacls', [targetPath, '/setintegritylevel', 'Low'], {
stdio: 'ignore',
});
} catch (e) {
} catch (_e) {
// Best effort
}
}