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:
galz10
2026-03-09 11:20:13 -07:00
parent 09e99824d4
commit 863a0aa01e
11 changed files with 494 additions and 65 deletions

View File

@@ -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);
}
});
});

View File

@@ -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'];

View 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();
});
});

View 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,
};
}
}