mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-14 23:31:13 -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:
@@ -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'];
|
||||
|
||||
111
packages/core/src/services/sandboxManager.test.ts
Normal file
111
packages/core/src/services/sandboxManager.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
78
packages/core/src/services/sandboxManager.ts
Normal file
78
packages/core/src/services/sandboxManager.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user