mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-22 02:54:31 -07:00
feat(core,cli): harden windows sandboxing and add platform-agnostic tests
This commit is contained in:
+1
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user