mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-20 10:10:56 -07:00
feat(core): implement SandboxManager interface and config schema
- Add `sandbox` block to `ConfigSchema` with `enabled`, `allowedPaths`, and `networkAccess` properties. - Define the `SandboxManager` interface and request/response types. - Implement `NoopSandboxManager` fallback that silently passes commands through but rigorously enforces environment variable sanitization via `sanitizeEnvironment`. - Update config and sandbox tests to use the new `SandboxConfig` schema. - Add `createMockSandboxConfig` utility to `test-utils` for cleaner test mocking across the monorepo.
This commit is contained in:
@@ -31,7 +31,9 @@ const VALID_SANDBOX_COMMANDS: ReadonlyArray<SandboxConfig['command']> = [
|
||||
'lxc',
|
||||
];
|
||||
|
||||
function isSandboxCommand(value: string): value is SandboxConfig['command'] {
|
||||
function isSandboxCommand(
|
||||
value: string,
|
||||
): value is Exclude<SandboxConfig['command'], undefined> {
|
||||
return (VALID_SANDBOX_COMMANDS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
@@ -124,5 +126,7 @@ export async function loadSandboxConfig(
|
||||
process.env['GEMINI_SANDBOX_IMAGE_DEFAULT'] ??
|
||||
packageJson?.config?.sandboxImageUri;
|
||||
|
||||
return command && image ? { command, image } : undefined;
|
||||
return command && image
|
||||
? { enabled: true, allowedPaths: [], networkAccess: false, command, image }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from './gemini.js';
|
||||
import { loadCliConfig, parseArguments } from './config/config.js';
|
||||
import { loadSandboxConfig } from './config/sandboxConfig.js';
|
||||
import { createMockSandboxConfig } from '@google/gemini-cli-test-utils';
|
||||
import { terminalCapabilityManager } from './ui/utils/terminalCapabilityManager.js';
|
||||
import { start_sandbox } from './utils/sandbox.js';
|
||||
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
|
||||
@@ -189,15 +190,26 @@ vi.mock('./ui/utils/terminalCapabilityManager.js', () => ({
|
||||
|
||||
vi.mock('./config/config.js', () => ({
|
||||
loadCliConfig: vi.fn().mockImplementation(async () => createMockConfig()),
|
||||
parseArguments: vi.fn().mockResolvedValue({}),
|
||||
parseArguments: vi
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
}),
|
||||
isDebugMode: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('read-package-up', () => ({
|
||||
readPackageUp: vi.fn().mockResolvedValue({
|
||||
packageJson: { name: 'test-pkg', version: 'test-version' },
|
||||
path: '/fake/path/package.json',
|
||||
}),
|
||||
readPackageUp: vi
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
packageJson: { name: 'test-pkg', version: 'test-version' },
|
||||
path: '/fake/path/package.json',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('update-notifier', () => ({
|
||||
@@ -231,10 +243,15 @@ vi.mock('./utils/relaunch.js', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('./config/sandboxConfig.js', () => ({
|
||||
loadSandboxConfig: vi.fn().mockResolvedValue({
|
||||
command: 'docker',
|
||||
image: 'test-image',
|
||||
}),
|
||||
loadSandboxConfig: vi
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
command: 'docker',
|
||||
image: 'test-image',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('./deferred.js', () => ({
|
||||
@@ -536,6 +553,9 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
);
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
promptInteractive: false,
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
@@ -599,6 +619,9 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
});
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
promptInteractive: false,
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
@@ -618,14 +641,17 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
const mockConfig = createMockConfig({
|
||||
isInteractive: () => false,
|
||||
getQuestion: () => '',
|
||||
getSandbox: () => ({ command: 'docker', image: 'test-image' }),
|
||||
getSandbox: () =>
|
||||
createMockSandboxConfig({ command: 'docker', image: 'test-image' }),
|
||||
});
|
||||
|
||||
vi.mocked(loadCliConfig).mockResolvedValue(mockConfig);
|
||||
vi.mocked(loadSandboxConfig).mockResolvedValue({
|
||||
command: 'docker',
|
||||
image: 'test-image',
|
||||
});
|
||||
vi.mocked(loadSandboxConfig).mockResolvedValue(
|
||||
createMockSandboxConfig({
|
||||
command: 'docker',
|
||||
image: 'test-image',
|
||||
}),
|
||||
);
|
||||
|
||||
process.env['GEMINI_API_KEY'] = 'test-key';
|
||||
try {
|
||||
@@ -666,6 +692,9 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
);
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
promptInteractive: false,
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(loadCliConfig).mockResolvedValue(
|
||||
@@ -721,6 +750,9 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
);
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
promptInteractive: false,
|
||||
resume: 'session-id',
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
@@ -777,6 +809,9 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
);
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
promptInteractive: false,
|
||||
resume: 'latest',
|
||||
} as unknown as CliArgs);
|
||||
@@ -827,6 +862,9 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
);
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
promptInteractive: false,
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(loadCliConfig).mockResolvedValue(
|
||||
@@ -877,6 +915,9 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
);
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
promptInteractive: false,
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(loadCliConfig).mockResolvedValue(
|
||||
@@ -951,6 +992,9 @@ describe('gemini.tsx main function exit codes', () => {
|
||||
}),
|
||||
);
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
promptInteractive: true,
|
||||
} as unknown as CliArgs);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -967,10 +1011,12 @@ describe('gemini.tsx main function exit codes', () => {
|
||||
|
||||
it('should exit with 41 for auth failure during sandbox setup', async () => {
|
||||
vi.stubEnv('SANDBOX', '');
|
||||
vi.mocked(loadSandboxConfig).mockResolvedValue({
|
||||
command: 'docker',
|
||||
image: 'test-image',
|
||||
});
|
||||
vi.mocked(loadSandboxConfig).mockResolvedValue(
|
||||
createMockSandboxConfig({
|
||||
command: 'docker',
|
||||
image: 'test-image',
|
||||
}),
|
||||
);
|
||||
vi.mocked(loadCliConfig).mockResolvedValue(
|
||||
createMockConfig({
|
||||
refreshAuth: vi.fn().mockRejectedValue(new Error('Auth failed')),
|
||||
@@ -1010,6 +1056,9 @@ describe('gemini.tsx main function exit codes', () => {
|
||||
}),
|
||||
);
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
resume: 'invalid-session',
|
||||
} as unknown as CliArgs);
|
||||
|
||||
@@ -1051,7 +1100,11 @@ describe('gemini.tsx main function exit codes', () => {
|
||||
merged: { security: { auth: {} }, ui: {} },
|
||||
}),
|
||||
);
|
||||
vi.mocked(parseArguments).mockResolvedValue({} as unknown as CliArgs);
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
} as unknown as CliArgs);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(process.stdin as any).isTTY = true;
|
||||
|
||||
@@ -1086,7 +1139,11 @@ describe('gemini.tsx main function exit codes', () => {
|
||||
merged: { security: { auth: { selectedType: undefined } }, ui: {} },
|
||||
}),
|
||||
);
|
||||
vi.mocked(parseArguments).mockResolvedValue({} as unknown as CliArgs);
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
} as unknown as CliArgs);
|
||||
|
||||
runNonInteractiveSpy.mockImplementation(() => Promise.resolve());
|
||||
|
||||
@@ -1156,7 +1213,12 @@ describe('project hooks loading based on trust', () => {
|
||||
const configModule = await import('./config/config.js');
|
||||
loadCliConfig = vi.mocked(configModule.loadCliConfig);
|
||||
parseArguments = vi.mocked(configModule.parseArguments);
|
||||
parseArguments.mockResolvedValue({ startupMessages: [] });
|
||||
parseArguments.mockResolvedValue({
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
startupMessages: [],
|
||||
});
|
||||
|
||||
const settingsModule = await import('./config/settings.js');
|
||||
loadSettings = vi.mocked(settingsModule.loadSettings);
|
||||
|
||||
@@ -10,6 +10,7 @@ import os from 'node:os';
|
||||
import fs from 'node:fs';
|
||||
import { start_sandbox } from './sandbox.js';
|
||||
import { FatalSandboxError, type SandboxConfig } from '@google/gemini-cli-core';
|
||||
import { createMockSandboxConfig } from '@google/gemini-cli-test-utils';
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
const { mockedHomedir, mockedGetContainerPath } = vi.hoisted(() => ({
|
||||
@@ -137,10 +138,10 @@ describe('sandbox', () => {
|
||||
describe('start_sandbox', () => {
|
||||
it('should handle macOS seatbelt (sandbox-exec)', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
const config: SandboxConfig = {
|
||||
const config: SandboxConfig = createMockSandboxConfig({
|
||||
command: 'sandbox-exec',
|
||||
image: 'some-image',
|
||||
};
|
||||
});
|
||||
|
||||
interface MockProcess extends EventEmitter {
|
||||
stdout: EventEmitter;
|
||||
@@ -173,19 +174,19 @@ describe('sandbox', () => {
|
||||
it('should throw FatalSandboxError if seatbelt profile is missing', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
const config: SandboxConfig = {
|
||||
const config: SandboxConfig = createMockSandboxConfig({
|
||||
command: 'sandbox-exec',
|
||||
image: 'some-image',
|
||||
};
|
||||
});
|
||||
|
||||
await expect(start_sandbox(config)).rejects.toThrow(FatalSandboxError);
|
||||
});
|
||||
|
||||
it('should handle Docker execution', async () => {
|
||||
const config: SandboxConfig = {
|
||||
const config: SandboxConfig = createMockSandboxConfig({
|
||||
command: 'docker',
|
||||
image: 'gemini-cli-sandbox',
|
||||
};
|
||||
});
|
||||
|
||||
// Mock image check to return true (image exists)
|
||||
interface MockProcessWithStdout extends EventEmitter {
|
||||
@@ -231,10 +232,10 @@ describe('sandbox', () => {
|
||||
});
|
||||
|
||||
it('should pull image if missing', async () => {
|
||||
const config: SandboxConfig = {
|
||||
const config: SandboxConfig = createMockSandboxConfig({
|
||||
command: 'docker',
|
||||
image: 'missing-image',
|
||||
};
|
||||
});
|
||||
|
||||
// 1. Image check fails
|
||||
interface MockProcessWithStdout extends EventEmitter {
|
||||
@@ -300,10 +301,10 @@ describe('sandbox', () => {
|
||||
});
|
||||
|
||||
it('should throw if image pull fails', async () => {
|
||||
const config: SandboxConfig = {
|
||||
const config: SandboxConfig = createMockSandboxConfig({
|
||||
command: 'docker',
|
||||
image: 'missing-image',
|
||||
};
|
||||
});
|
||||
|
||||
// 1. Image check fails
|
||||
interface MockProcessWithStdout extends EventEmitter {
|
||||
@@ -338,10 +339,10 @@ describe('sandbox', () => {
|
||||
});
|
||||
|
||||
it('should mount volumes correctly', async () => {
|
||||
const config: SandboxConfig = {
|
||||
const config: SandboxConfig = createMockSandboxConfig({
|
||||
command: 'docker',
|
||||
image: 'gemini-cli-sandbox',
|
||||
};
|
||||
});
|
||||
process.env['SANDBOX_MOUNTS'] = '/host/path:/container/path:ro';
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true); // For mount path check
|
||||
|
||||
@@ -395,10 +396,10 @@ describe('sandbox', () => {
|
||||
});
|
||||
|
||||
it('should pass through GOOGLE_GEMINI_BASE_URL and GOOGLE_VERTEX_BASE_URL', async () => {
|
||||
const config: SandboxConfig = {
|
||||
const config: SandboxConfig = createMockSandboxConfig({
|
||||
command: 'docker',
|
||||
image: 'gemini-cli-sandbox',
|
||||
};
|
||||
});
|
||||
process.env['GOOGLE_GEMINI_BASE_URL'] = 'http://gemini.proxy';
|
||||
process.env['GOOGLE_VERTEX_BASE_URL'] = 'http://vertex.proxy';
|
||||
|
||||
@@ -442,10 +443,10 @@ describe('sandbox', () => {
|
||||
});
|
||||
|
||||
it('should handle user creation on Linux if needed', async () => {
|
||||
const config: SandboxConfig = {
|
||||
const config: SandboxConfig = createMockSandboxConfig({
|
||||
command: 'docker',
|
||||
image: 'gemini-cli-sandbox',
|
||||
};
|
||||
});
|
||||
process.env['SANDBOX_SET_UID_GID'] = 'true';
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.mocked(execSync).mockImplementation((cmd) => {
|
||||
@@ -508,10 +509,10 @@ describe('sandbox', () => {
|
||||
|
||||
it('should run lxc exec with correct args for a running container', async () => {
|
||||
process.env['TEST_LXC_LIST_OUTPUT'] = LXC_RUNNING;
|
||||
const config: SandboxConfig = {
|
||||
const config: SandboxConfig = createMockSandboxConfig({
|
||||
command: 'lxc',
|
||||
image: 'gemini-sandbox',
|
||||
};
|
||||
});
|
||||
|
||||
const mockSpawnProcess = new EventEmitter() as unknown as ReturnType<
|
||||
typeof spawn
|
||||
@@ -542,10 +543,10 @@ describe('sandbox', () => {
|
||||
|
||||
it('should throw FatalSandboxError if lxc list fails', async () => {
|
||||
process.env['TEST_LXC_LIST_OUTPUT'] = 'throw';
|
||||
const config: SandboxConfig = {
|
||||
const config: SandboxConfig = createMockSandboxConfig({
|
||||
command: 'lxc',
|
||||
image: 'gemini-sandbox',
|
||||
};
|
||||
});
|
||||
|
||||
await expect(start_sandbox(config)).rejects.toThrow(
|
||||
/Failed to query LXC container/,
|
||||
@@ -554,20 +555,20 @@ describe('sandbox', () => {
|
||||
|
||||
it('should throw FatalSandboxError if container is not running', async () => {
|
||||
process.env['TEST_LXC_LIST_OUTPUT'] = LXC_STOPPED;
|
||||
const config: SandboxConfig = {
|
||||
const config: SandboxConfig = createMockSandboxConfig({
|
||||
command: 'lxc',
|
||||
image: 'gemini-sandbox',
|
||||
};
|
||||
});
|
||||
|
||||
await expect(start_sandbox(config)).rejects.toThrow(/is not running/);
|
||||
});
|
||||
|
||||
it('should throw FatalSandboxError if container is not found in list', async () => {
|
||||
process.env['TEST_LXC_LIST_OUTPUT'] = '[]';
|
||||
const config: SandboxConfig = {
|
||||
const config: SandboxConfig = createMockSandboxConfig({
|
||||
command: 'lxc',
|
||||
image: 'gemini-sandbox',
|
||||
};
|
||||
});
|
||||
|
||||
await expect(start_sandbox(config)).rejects.toThrow(/not found/);
|
||||
});
|
||||
@@ -577,10 +578,10 @@ describe('sandbox', () => {
|
||||
describe('gVisor (runsc)', () => {
|
||||
it('should use docker with --runtime=runsc on Linux', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
const config: SandboxConfig = {
|
||||
const config: SandboxConfig = createMockSandboxConfig({
|
||||
command: 'runsc',
|
||||
image: 'gemini-cli-sandbox',
|
||||
};
|
||||
});
|
||||
|
||||
// Mock image check
|
||||
interface MockProcessWithStdout extends EventEmitter {
|
||||
|
||||
@@ -217,6 +217,7 @@ export async function start_sandbox(
|
||||
|
||||
// runsc uses docker with --runtime=runsc
|
||||
const command = config.command === 'runsc' ? 'docker' : config.command;
|
||||
if (!command) throw new FatalSandboxError('Sandbox command is required');
|
||||
|
||||
debugLogger.log(`hopping into sandbox (command: ${command}) ...`);
|
||||
|
||||
@@ -230,6 +231,7 @@ export async function start_sandbox(
|
||||
const isCustomProjectSandbox = fs.existsSync(projectSandboxDockerfile);
|
||||
|
||||
const image = config.image;
|
||||
if (!image) throw new FatalSandboxError('Sandbox image is required');
|
||||
const workdir = path.resolve(process.cwd());
|
||||
const containerWorkdir = getContainerPath(workdir);
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type ConfigParameters,
|
||||
type SandboxConfig,
|
||||
} from './config.js';
|
||||
import { createMockSandboxConfig } from '@google/gemini-cli-test-utils';
|
||||
import { DEFAULT_MAX_ATTEMPTS } from '../utils/retry.js';
|
||||
import { ExperimentFlags } from '../code_assist/experiments/flagNames.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
@@ -247,10 +248,10 @@ vi.mock('../code_assist/experiments/experiments.js');
|
||||
|
||||
describe('Server Config (config.ts)', () => {
|
||||
const MODEL = DEFAULT_GEMINI_MODEL;
|
||||
const SANDBOX: SandboxConfig = {
|
||||
const SANDBOX: SandboxConfig = createMockSandboxConfig({
|
||||
command: 'docker',
|
||||
image: 'gemini-cli-sandbox',
|
||||
};
|
||||
});
|
||||
const TARGET_DIR = '/path/to/target';
|
||||
const DEBUG_MODE = false;
|
||||
const QUESTION = 'test question';
|
||||
@@ -1477,14 +1478,62 @@ describe('Server Config (config.ts)', () => {
|
||||
expect(browserConfig.customConfig.sessionMode).toBe('persistent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sandbox Configuration', () => {
|
||||
it('should default sandbox settings when not provided', () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
sandbox: undefined,
|
||||
});
|
||||
|
||||
expect(config.getSandboxEnabled()).toBe(false);
|
||||
expect(config.getSandboxAllowedPaths()).toEqual([]);
|
||||
expect(config.getSandboxNetworkAccess()).toBe(false);
|
||||
});
|
||||
|
||||
it('should store provided sandbox settings', () => {
|
||||
const sandbox: SandboxConfig = {
|
||||
enabled: true,
|
||||
allowedPaths: ['/tmp/foo', '/var/bar'],
|
||||
networkAccess: true,
|
||||
command: 'docker',
|
||||
image: 'my-image',
|
||||
};
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
sandbox,
|
||||
});
|
||||
|
||||
expect(config.getSandboxEnabled()).toBe(true);
|
||||
expect(config.getSandboxAllowedPaths()).toEqual(['/tmp/foo', '/var/bar']);
|
||||
expect(config.getSandboxNetworkAccess()).toBe(true);
|
||||
expect(config.getSandbox()?.command).toBe('docker');
|
||||
expect(config.getSandbox()?.image).toBe('my-image');
|
||||
});
|
||||
|
||||
it('should partially override default sandbox settings', () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
allowedPaths: ['/only/this'],
|
||||
networkAccess: false,
|
||||
} as SandboxConfig,
|
||||
});
|
||||
|
||||
expect(config.getSandboxEnabled()).toBe(true);
|
||||
expect(config.getSandboxAllowedPaths()).toEqual(['/only/this']);
|
||||
expect(config.getSandboxNetworkAccess()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GemmaModelRouterSettings', () => {
|
||||
const MODEL = DEFAULT_GEMINI_MODEL;
|
||||
const SANDBOX: SandboxConfig = {
|
||||
const SANDBOX: SandboxConfig = createMockSandboxConfig({
|
||||
command: 'docker',
|
||||
image: 'gemini-cli-sandbox',
|
||||
};
|
||||
});
|
||||
const TARGET_DIR = '/path/to/target';
|
||||
const DEBUG_MODE = false;
|
||||
const QUESTION = 'test question';
|
||||
@@ -1861,10 +1910,10 @@ describe('isYoloModeDisabled', () => {
|
||||
|
||||
describe('BaseLlmClient Lifecycle', () => {
|
||||
const MODEL = 'gemini-pro';
|
||||
const SANDBOX: SandboxConfig = {
|
||||
const SANDBOX: SandboxConfig = createMockSandboxConfig({
|
||||
command: 'docker',
|
||||
image: 'gemini-cli-sandbox',
|
||||
};
|
||||
});
|
||||
const TARGET_DIR = '/path/to/target';
|
||||
const DEBUG_MODE = false;
|
||||
const QUESTION = 'test question';
|
||||
@@ -1916,10 +1965,10 @@ describe('BaseLlmClient Lifecycle', () => {
|
||||
|
||||
describe('Generation Config Merging (HACK)', () => {
|
||||
const MODEL = 'gemini-pro';
|
||||
const SANDBOX: SandboxConfig = {
|
||||
const SANDBOX: SandboxConfig = createMockSandboxConfig({
|
||||
command: 'docker',
|
||||
image: 'gemini-cli-sandbox',
|
||||
};
|
||||
});
|
||||
const TARGET_DIR = '/path/to/target';
|
||||
const DEBUG_MODE = false;
|
||||
const QUESTION = 'test question';
|
||||
@@ -2222,10 +2271,10 @@ describe('Config getHooks', () => {
|
||||
|
||||
describe('LocalLiteRtLmClient Lifecycle', () => {
|
||||
const MODEL = 'gemini-pro';
|
||||
const SANDBOX: SandboxConfig = {
|
||||
const SANDBOX: SandboxConfig = createMockSandboxConfig({
|
||||
command: 'docker',
|
||||
image: 'gemini-cli-sandbox',
|
||||
};
|
||||
});
|
||||
const TARGET_DIR = '/path/to/target';
|
||||
const DEBUG_MODE = false;
|
||||
const QUESTION = 'test question';
|
||||
@@ -2540,6 +2589,9 @@ describe('Config Quota & Preview Model Access', () => {
|
||||
usageStatisticsEnabled: false,
|
||||
embeddingModel: 'gemini-embedding',
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
command: 'docker',
|
||||
image: 'gemini-cli-sandbox',
|
||||
},
|
||||
@@ -3175,3 +3227,39 @@ describe('Model Persistence Bug Fix (#19864)', () => {
|
||||
expect(config.getModel()).toBe(PREVIEW_GEMINI_3_1_MODEL);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConfigSchema validation', () => {
|
||||
it('should validate a valid sandbox config', async () => {
|
||||
const validConfig = {
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
allowedPaths: ['/tmp'],
|
||||
networkAccess: false,
|
||||
command: 'docker',
|
||||
image: 'node:20',
|
||||
},
|
||||
};
|
||||
|
||||
const { ConfigSchema } = await import('./config.js');
|
||||
const result = ConfigSchema.safeParse(validConfig);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.sandbox?.enabled).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should apply defaults in ConfigSchema', async () => {
|
||||
const minimalConfig = {
|
||||
sandbox: {},
|
||||
};
|
||||
|
||||
const { ConfigSchema } = await import('./config.js');
|
||||
const result = ConfigSchema.safeParse(minimalConfig);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.sandbox?.enabled).toBe(false);
|
||||
expect(result.data.sandbox?.allowedPaths).toEqual([]);
|
||||
expect(result.data.sandbox?.networkAccess).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { inspect } from 'node:util';
|
||||
import process from 'node:process';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
AuthType,
|
||||
createContentGenerator,
|
||||
@@ -96,7 +97,6 @@ import type {
|
||||
import { ModelAvailabilityService } from '../availability/modelAvailabilityService.js';
|
||||
import { ModelRouterService } from '../routing/modelRouterService.js';
|
||||
import { OutputFormat } from '../output/types.js';
|
||||
//import { type AgentLoopContext } from './agent-loop-context.js';
|
||||
import {
|
||||
ModelConfigService,
|
||||
type ModelConfig,
|
||||
@@ -447,10 +447,27 @@ export enum AuthProviderType {
|
||||
}
|
||||
|
||||
export interface SandboxConfig {
|
||||
command: 'docker' | 'podman' | 'sandbox-exec' | 'runsc' | 'lxc';
|
||||
image: string;
|
||||
enabled: boolean;
|
||||
allowedPaths: string[];
|
||||
networkAccess: boolean;
|
||||
command?: 'docker' | 'podman' | 'sandbox-exec' | 'runsc' | 'lxc';
|
||||
image?: string;
|
||||
}
|
||||
|
||||
export const ConfigSchema = z.object({
|
||||
sandbox: z
|
||||
.object({
|
||||
enabled: z.boolean().default(false),
|
||||
allowedPaths: z.array(z.string()).default([]),
|
||||
networkAccess: z.boolean().default(false),
|
||||
command: z
|
||||
.enum(['docker', 'podman', 'sandbox-exec', 'runsc', 'lxc'])
|
||||
.optional(),
|
||||
image: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Callbacks for checking MCP server enablement status.
|
||||
* These callbacks are provided by the CLI package to bridge
|
||||
@@ -814,7 +831,19 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.embeddingModel =
|
||||
params.embeddingModel ?? DEFAULT_GEMINI_EMBEDDING_MODEL;
|
||||
this.fileSystemService = new StandardFileSystemService();
|
||||
this.sandbox = params.sandbox;
|
||||
this.sandbox = params.sandbox
|
||||
? {
|
||||
enabled: params.sandbox.enabled ?? false,
|
||||
allowedPaths: params.sandbox.allowedPaths ?? [],
|
||||
networkAccess: params.sandbox.networkAccess ?? false,
|
||||
command: params.sandbox.command,
|
||||
image: params.sandbox.image,
|
||||
}
|
||||
: {
|
||||
enabled: false,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
};
|
||||
this.targetDir = path.resolve(params.targetDir);
|
||||
this.folderTrust = params.folderTrust ?? false;
|
||||
this.workspaceContext = new WorkspaceContext(this.targetDir, []);
|
||||
@@ -948,7 +977,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.truncateToolOutputThreshold =
|
||||
params.truncateToolOutputThreshold ??
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD;
|
||||
// // TODO(joshualitt): Re-evaluate the todo tool for 3 family.
|
||||
this.useWriteTodos = isPreviewModel(this.model)
|
||||
? false
|
||||
: (params.useWriteTodos ?? true);
|
||||
@@ -1599,6 +1627,18 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.sandbox;
|
||||
}
|
||||
|
||||
getSandboxEnabled(): boolean {
|
||||
return this.sandbox?.enabled ?? false;
|
||||
}
|
||||
|
||||
getSandboxAllowedPaths(): string[] {
|
||||
return this.sandbox?.allowedPaths ?? [];
|
||||
}
|
||||
|
||||
getSandboxNetworkAccess(): boolean {
|
||||
return this.sandbox?.networkAccess ?? false;
|
||||
}
|
||||
|
||||
isRestrictiveSandbox(): boolean {
|
||||
const sandboxConfig = this.getSandbox();
|
||||
const seatbeltProfile = process.env['SEATBELT_PROFILE'];
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { NoopSandboxManager } from './sandboxManager.js';
|
||||
|
||||
describe('NoopSandboxManager', () => {
|
||||
const sandboxManager = new NoopSandboxManager();
|
||||
|
||||
it('should pass through the command and arguments unchanged', async () => {
|
||||
const req = {
|
||||
command: 'ls',
|
||||
args: ['-la'],
|
||||
cwd: '/tmp',
|
||||
env: { PATH: '/usr/bin' },
|
||||
};
|
||||
|
||||
const result = await sandboxManager.prepareCommand(req);
|
||||
|
||||
expect(result.program).toBe('ls');
|
||||
expect(result.args).toEqual(['-la']);
|
||||
});
|
||||
|
||||
it('should sanitize the environment variables', async () => {
|
||||
const req = {
|
||||
command: 'echo',
|
||||
args: ['hello'],
|
||||
cwd: '/tmp',
|
||||
env: {
|
||||
PATH: '/usr/bin',
|
||||
GITHUB_TOKEN: 'ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
|
||||
MY_SECRET: 'super-secret',
|
||||
SAFE_VAR: 'is-safe',
|
||||
},
|
||||
};
|
||||
|
||||
const result = await sandboxManager.prepareCommand(req);
|
||||
|
||||
expect(result.env['PATH']).toBe('/usr/bin');
|
||||
expect(result.env['SAFE_VAR']).toBe('is-safe');
|
||||
expect(result.env['GITHUB_TOKEN']).toBeUndefined();
|
||||
expect(result.env['MY_SECRET']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should force environment variable redaction even if not requested in config', async () => {
|
||||
const req = {
|
||||
command: 'echo',
|
||||
args: ['hello'],
|
||||
cwd: '/tmp',
|
||||
env: {
|
||||
API_KEY: 'sensitive-key',
|
||||
},
|
||||
config: {
|
||||
sanitizationConfig: {
|
||||
enableEnvironmentVariableRedaction: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await sandboxManager.prepareCommand(req);
|
||||
|
||||
expect(result.env['API_KEY']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should respect allowedEnvironmentVariables in config', async () => {
|
||||
const req = {
|
||||
command: 'echo',
|
||||
args: ['hello'],
|
||||
cwd: '/tmp',
|
||||
env: {
|
||||
MY_TOKEN: 'secret-token',
|
||||
OTHER_SECRET: 'another-secret',
|
||||
},
|
||||
config: {
|
||||
sanitizationConfig: {
|
||||
allowedEnvironmentVariables: ['MY_TOKEN'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await sandboxManager.prepareCommand(req);
|
||||
|
||||
expect(result.env['MY_TOKEN']).toBe('secret-token');
|
||||
expect(result.env['OTHER_SECRET']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should respect blockedEnvironmentVariables in config', async () => {
|
||||
const req = {
|
||||
command: 'echo',
|
||||
args: ['hello'],
|
||||
cwd: '/tmp',
|
||||
env: {
|
||||
SAFE_VAR: 'safe-value',
|
||||
BLOCKED_VAR: 'blocked-value',
|
||||
},
|
||||
config: {
|
||||
sanitizationConfig: {
|
||||
blockedEnvironmentVariables: ['BLOCKED_VAR'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await sandboxManager.prepareCommand(req);
|
||||
|
||||
expect(result.env['SAFE_VAR']).toBe('safe-value');
|
||||
expect(result.env['BLOCKED_VAR']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
sanitizeEnvironment,
|
||||
type EnvironmentSanitizationConfig,
|
||||
} from './environmentSanitization.js';
|
||||
|
||||
/**
|
||||
* Request for preparing a command to run in a sandbox.
|
||||
*/
|
||||
export interface SandboxRequest {
|
||||
/** The program to execute. */
|
||||
command: string;
|
||||
/** Arguments for the program. */
|
||||
args: string[];
|
||||
/** The working directory. */
|
||||
cwd: string;
|
||||
/** Environment variables to be passed to the program. */
|
||||
env: NodeJS.ProcessEnv;
|
||||
/** Optional sandbox-specific configuration. */
|
||||
config?: {
|
||||
sanitizationConfig?: Partial<EnvironmentSanitizationConfig>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A command that has been prepared for sandboxed execution.
|
||||
*/
|
||||
export interface SandboxedCommand {
|
||||
/** The program or wrapper to execute. */
|
||||
program: string;
|
||||
/** Final arguments for the program. */
|
||||
args: string[];
|
||||
/** Sanitized environment variables. */
|
||||
env: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for a service that prepares commands for sandboxed execution.
|
||||
*/
|
||||
export interface SandboxManager {
|
||||
/**
|
||||
* Prepares a command to run in a sandbox, including environment sanitization.
|
||||
*/
|
||||
prepareCommand(req: SandboxRequest): Promise<SandboxedCommand>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A no-op implementation of SandboxManager that silently passes commands
|
||||
* through while applying environment sanitization.
|
||||
*/
|
||||
export class NoopSandboxManager implements SandboxManager {
|
||||
/**
|
||||
* Prepares a command by sanitizing the environment and passing through
|
||||
* the original program and arguments.
|
||||
*/
|
||||
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
|
||||
const sanitizationConfig: EnvironmentSanitizationConfig = {
|
||||
allowedEnvironmentVariables:
|
||||
req.config?.sanitizationConfig?.allowedEnvironmentVariables ?? [],
|
||||
blockedEnvironmentVariables:
|
||||
req.config?.sanitizationConfig?.blockedEnvironmentVariables ?? [],
|
||||
enableEnvironmentVariableRedaction: true, // Forced for safety
|
||||
};
|
||||
|
||||
const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig);
|
||||
|
||||
return {
|
||||
program: req.command,
|
||||
args: req.args,
|
||||
env: sanitizedEnv,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,3 +6,4 @@
|
||||
|
||||
export * from './file-system-test-helpers.js';
|
||||
export * from './test-rig.js';
|
||||
export * from './mock-utils.js';
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { SandboxConfig } from '@google/gemini-cli-core';
|
||||
|
||||
export function createMockSandboxConfig(
|
||||
overrides?: Partial<SandboxConfig>,
|
||||
): SandboxConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
allowedPaths: [],
|
||||
networkAccess: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user