diff --git a/packages/a2a-server/src/commands/memory.ts b/packages/a2a-server/src/commands/memory.ts index 16af1d3fe2..cd0649d47f 100644 --- a/packages/a2a-server/src/commands/memory.ts +++ b/packages/a2a-server/src/commands/memory.ts @@ -102,6 +102,7 @@ export class AddMemoryCommand implements Command { const signal = abortController.signal; await tool.buildAndExecute(result.toolArgs, signal, undefined, { sanitizationConfig: DEFAULT_SANITIZATION_CONFIG, + sandboxManager: context.config.sandboxManager, }); await refreshMemory(context.config); return { diff --git a/packages/a2a-server/src/utils/testing_utils.ts b/packages/a2a-server/src/utils/testing_utils.ts index 7d77d8dc9a..69b63d4046 100644 --- a/packages/a2a-server/src/utils/testing_utils.ts +++ b/packages/a2a-server/src/utils/testing_utils.ts @@ -20,6 +20,7 @@ import { tmpdir, type Config, type Storage, + NoopSandboxManager, } from '@google/gemini-cli-core'; import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js'; import { expect, vi } from 'vitest'; @@ -73,6 +74,14 @@ export function createMockConfig( }), getGitService: vi.fn(), validatePathAccess: vi.fn().mockReturnValue(undefined), + getShellExecutionConfig: vi.fn().mockReturnValue({ + sandboxManager: new NoopSandboxManager(), + sanitizationConfig: { + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: [], + enableEnvironmentVariableRedaction: false, + }, + }), ...overrides, } as unknown as Config; diff --git a/packages/cli/src/acp/commands/memory.ts b/packages/cli/src/acp/commands/memory.ts index 9460af7ad1..1154c852a1 100644 --- a/packages/cli/src/acp/commands/memory.ts +++ b/packages/cli/src/acp/commands/memory.ts @@ -105,6 +105,7 @@ export class AddMemoryCommand implements Command { await tool.buildAndExecute(result.toolArgs, signal, undefined, { sanitizationConfig: DEFAULT_SANITIZATION_CONFIG, + sandboxManager: context.config.sandboxManager, }); await refreshMemory(context.config); return { diff --git a/packages/cli/src/config/extension-manager-themes.spec.ts b/packages/cli/src/config/extension-manager-themes.spec.ts index b1b21aab55..9358784a2f 100644 --- a/packages/cli/src/config/extension-manager-themes.spec.ts +++ b/packages/cli/src/config/extension-manager-themes.spec.ts @@ -20,7 +20,12 @@ import { import { createExtension } from '../test-utils/createExtension.js'; import { ExtensionManager } from './extension-manager.js'; import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js'; -import { GEMINI_DIR, type Config, tmpdir } from '@google/gemini-cli-core'; +import { + GEMINI_DIR, + type Config, + tmpdir, + NoopSandboxManager, +} from '@google/gemini-cli-core'; import { createTestMergedSettings, SettingScope } from './settings.js'; describe('ExtensionManager theme loading', () => { @@ -117,6 +122,7 @@ describe('ExtensionManager theme loading', () => { terminalHeight: 24, showColor: false, pager: 'cat', + sandboxManager: new NoopSandboxManager(), sanitizationConfig: { allowedEnvironmentVariables: [], blockedEnvironmentVariables: [], diff --git a/packages/cli/src/config/sandboxConfig.test.ts b/packages/cli/src/config/sandboxConfig.test.ts index 51c4f7d83c..b264074fa2 100644 --- a/packages/cli/src/config/sandboxConfig.test.ts +++ b/packages/cli/src/config/sandboxConfig.test.ts @@ -90,7 +90,13 @@ describe('loadSandboxConfig', () => { process.env['GEMINI_SANDBOX'] = 'docker'; mockedCommandExistsSync.mockReturnValue(true); const config = await loadSandboxConfig({}, {}); - expect(config).toEqual({ command: 'docker', image: 'default/image' }); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'docker', + image: 'default/image', + }); expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker'); }); @@ -113,7 +119,13 @@ describe('loadSandboxConfig', () => { process.env['GEMINI_SANDBOX'] = 'lxc'; mockedCommandExistsSync.mockReturnValue(true); const config = await loadSandboxConfig({}, {}); - expect(config).toEqual({ command: 'lxc', image: 'default/image' }); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'lxc', + image: 'default/image', + }); expect(mockedCommandExistsSync).toHaveBeenCalledWith('lxc'); }); @@ -134,6 +146,9 @@ describe('loadSandboxConfig', () => { ); const config = await loadSandboxConfig({}, { sandbox: true }); expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, command: 'sandbox-exec', image: 'default/image', }); @@ -144,6 +159,9 @@ describe('loadSandboxConfig', () => { mockedCommandExistsSync.mockReturnValue(true); // all commands exist const config = await loadSandboxConfig({}, { sandbox: true }); expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, command: 'sandbox-exec', image: 'default/image', }); @@ -153,14 +171,26 @@ describe('loadSandboxConfig', () => { mockedOsPlatform.mockReturnValue('linux'); mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'docker'); const config = await loadSandboxConfig({ tools: { sandbox: true } }, {}); - expect(config).toEqual({ command: 'docker', image: 'default/image' }); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'docker', + image: 'default/image', + }); }); it('should use podman if available and docker is not', async () => { mockedOsPlatform.mockReturnValue('linux'); mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'podman'); const config = await loadSandboxConfig({}, { sandbox: true }); - expect(config).toEqual({ command: 'podman', image: 'default/image' }); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'podman', + image: 'default/image', + }); }); it('should throw if sandbox: true but no command is found', async () => { @@ -177,7 +207,13 @@ describe('loadSandboxConfig', () => { it('should use the specified command if it exists', async () => { mockedCommandExistsSync.mockReturnValue(true); const config = await loadSandboxConfig({}, { sandbox: 'podman' }); - expect(config).toEqual({ command: 'podman', image: 'default/image' }); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'podman', + image: 'default/image', + }); expect(mockedCommandExistsSync).toHaveBeenCalledWith('podman'); }); @@ -205,14 +241,26 @@ describe('loadSandboxConfig', () => { process.env['GEMINI_SANDBOX'] = 'docker'; mockedCommandExistsSync.mockReturnValue(true); const config = await loadSandboxConfig({}, {}); - expect(config).toEqual({ command: 'docker', image: 'env/image' }); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'docker', + image: 'env/image', + }); }); it('should use image from package.json if env var is not set', async () => { process.env['GEMINI_SANDBOX'] = 'docker'; mockedCommandExistsSync.mockReturnValue(true); const config = await loadSandboxConfig({}, {}); - expect(config).toEqual({ command: 'docker', image: 'default/image' }); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'docker', + image: 'default/image', + }); }); it('should return undefined if command is found but no image is configured', async () => { @@ -234,7 +282,13 @@ describe('loadSandboxConfig', () => { 'should enable sandbox for value: %s', async (value) => { const config = await loadSandboxConfig({}, { sandbox: value }); - expect(config).toEqual({ command: 'docker', image: 'default/image' }); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'docker', + image: 'default/image', + }); }, ); @@ -257,7 +311,13 @@ describe('loadSandboxConfig', () => { it('should use runsc via CLI argument on Linux', async () => { const config = await loadSandboxConfig({}, { sandbox: 'runsc' }); - expect(config).toEqual({ command: 'runsc', image: 'default/image' }); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'runsc', + image: 'default/image', + }); expect(mockedCommandExistsSync).toHaveBeenCalledWith('runsc'); expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker'); }); @@ -266,7 +326,13 @@ describe('loadSandboxConfig', () => { process.env['GEMINI_SANDBOX'] = 'runsc'; const config = await loadSandboxConfig({}, {}); - expect(config).toEqual({ command: 'runsc', image: 'default/image' }); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'runsc', + image: 'default/image', + }); expect(mockedCommandExistsSync).toHaveBeenCalledWith('runsc'); expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker'); }); @@ -277,7 +343,13 @@ describe('loadSandboxConfig', () => { {}, ); - expect(config).toEqual({ command: 'runsc', image: 'default/image' }); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'runsc', + image: 'default/image', + }); expect(mockedCommandExistsSync).toHaveBeenCalledWith('runsc'); expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker'); }); @@ -289,7 +361,13 @@ describe('loadSandboxConfig', () => { { sandbox: 'podman' }, ); - expect(config).toEqual({ command: 'runsc', image: 'default/image' }); + expect(config).toEqual({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'runsc', + image: 'default/image', + }); }); it('should reject runsc on macOS (Linux-only)', async () => { diff --git a/packages/cli/src/config/sandboxConfig.ts b/packages/cli/src/config/sandboxConfig.ts index 281e5c0a9f..913464a6b0 100644 --- a/packages/cli/src/config/sandboxConfig.ts +++ b/packages/cli/src/config/sandboxConfig.ts @@ -34,7 +34,9 @@ const VALID_SANDBOX_COMMANDS: ReadonlyArray = [ function isSandboxCommand( value: string, ): value is Exclude { - return (VALID_SANDBOX_COMMANDS as readonly string[]).includes(value); + return (VALID_SANDBOX_COMMANDS as ReadonlyArray).includes( + value, + ); } function getSandboxCommand( diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 47008c9991..5cf0d306a0 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -190,26 +190,22 @@ vi.mock('./ui/utils/terminalCapabilityManager.js', () => ({ vi.mock('./config/config.js', () => ({ loadCliConfig: vi.fn().mockImplementation(async () => createMockConfig()), - parseArguments: vi - .fn() - .mockResolvedValue({ - enabled: true, - allowedPaths: [], - networkAccess: false, - }), + parseArguments: vi.fn().mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, + }), isDebugMode: vi.fn(() => false), })); vi.mock('read-package-up', () => ({ - readPackageUp: vi - .fn() - .mockResolvedValue({ - enabled: true, - allowedPaths: [], - networkAccess: false, - 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', () => ({ @@ -243,15 +239,13 @@ vi.mock('./utils/relaunch.js', () => ({ })); vi.mock('./config/sandboxConfig.js', () => ({ - loadSandboxConfig: vi - .fn() - .mockResolvedValue({ - enabled: true, - allowedPaths: [], - networkAccess: false, - command: 'docker', - image: 'test-image', - }), + loadSandboxConfig: vi.fn().mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'docker', + image: 'test-image', + }), })); vi.mock('./deferred.js', () => ({ diff --git a/packages/cli/src/services/prompt-processors/shellProcessor.test.ts b/packages/cli/src/services/prompt-processors/shellProcessor.test.ts index 0f6fb562a8..84010ab625 100644 --- a/packages/cli/src/services/prompt-processors/shellProcessor.test.ts +++ b/packages/cli/src/services/prompt-processors/shellProcessor.test.ts @@ -13,6 +13,7 @@ import { ApprovalMode, getShellConfiguration, PolicyDecision, + NoopSandboxManager, } from '@google/gemini-cli-core'; import { quote } from 'shell-quote'; import { createPartFromText } from '@google/genai'; @@ -77,7 +78,14 @@ describe('ShellProcessor', () => { getTargetDir: vi.fn().mockReturnValue('/test/dir'), getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getEnableInteractiveShell: vi.fn().mockReturnValue(false), - getShellExecutionConfig: vi.fn().mockReturnValue({}), + getShellExecutionConfig: vi.fn().mockReturnValue({ + sandboxManager: new NoopSandboxManager(), + sanitizationConfig: { + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: [], + enableEnvironmentVariableRedaction: false, + }, + }), getPolicyEngine: vi.fn().mockReturnValue({ check: mockPolicyEngineCheck, }), diff --git a/packages/cli/src/test-utils/mockConfig.ts b/packages/cli/src/test-utils/mockConfig.ts index c8ab45a35d..cc390c13b6 100644 --- a/packages/cli/src/test-utils/mockConfig.ts +++ b/packages/cli/src/test-utils/mockConfig.ts @@ -5,7 +5,7 @@ */ import { vi } from 'vitest'; -import type { Config } from '@google/gemini-cli-core'; +import { type Config, NoopSandboxManager } from '@google/gemini-cli-core'; import type { LoadedSettings, Settings } from '../config/settings.js'; import { createTestMergedSettings } from '../config/settings.js'; @@ -128,7 +128,14 @@ export const createMockConfig = (overrides: Partial = {}): Config => getRetryFetchErrors: vi.fn().mockReturnValue(false), getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true), getShellToolInactivityTimeout: vi.fn().mockReturnValue(300000), - getShellExecutionConfig: vi.fn().mockReturnValue({}), + getShellExecutionConfig: vi.fn().mockReturnValue({ + sandboxManager: new NoopSandboxManager(), + sanitizationConfig: { + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: [], + enableEnvironmentVariableRedaction: false, + }, + }), setShellExecutionConfig: vi.fn(), getEnableToolOutputTruncation: vi.fn().mockReturnValue(true), getTruncateToolOutputThreshold: vi.fn().mockReturnValue(1000), diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 67f2d5dd84..9d0f558a49 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1402,6 +1402,7 @@ Logging in with Google... Restarting Gemini CLI to continue. pager: settings.merged.tools.shell.pager, showColor: settings.merged.tools.shell.showColor, sanitizationConfig: config.sanitizationConfig, + sandboxManager: config.sandboxManager, }); const { isFocused, hasReceivedFocusEvent } = useFocus(); diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx index 377cac9b7c..53bafb2125 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx +++ b/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx @@ -16,6 +16,7 @@ import { afterEach, type Mock, } from 'vitest'; +import { NoopSandboxManager } from '@google/gemini-cli-core'; const mockIsBinary = vi.hoisted(() => vi.fn()); const mockShellExecutionService = vi.hoisted(() => vi.fn()); @@ -109,8 +110,14 @@ describe('useShellCommandProcessor', () => { getShellExecutionConfig: () => ({ terminalHeight: 20, terminalWidth: 80, + sandboxManager: new NoopSandboxManager(), + sanitizationConfig: { + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: [], + enableEnvironmentVariableRedaction: false, + }, }), - } as Config; + } as unknown as Config; mockGeminiClient = { addHistory: vi.fn() } as unknown as GeminiClient; vi.mocked(os.platform).mockReturnValue('linux'); diff --git a/packages/core/src/config/agent-loop-context.ts b/packages/core/src/config/agent-loop-context.ts index 0a7334c334..b40280129e 100644 --- a/packages/core/src/config/agent-loop-context.ts +++ b/packages/core/src/config/agent-loop-context.ts @@ -7,6 +7,7 @@ import type { GeminiClient } from '../core/client.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; import type { ToolRegistry } from '../tools/tool-registry.js'; +import type { SandboxManager } from '../services/sandboxManager.js'; /** * AgentLoopContext represents the execution-scoped view of the world for a single @@ -24,4 +25,7 @@ export interface AgentLoopContext { /** The client used to communicate with the LLM in this context. */ readonly geminiClient: GeminiClient; + + /** The service used to prepare commands for sandboxed execution. */ + readonly sandboxManager: SandboxManager; } diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 53d93a2759..55eae40247 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -41,6 +41,10 @@ import { LocalLiteRtLmClient } from '../core/localLiteRtLmClient.js'; import type { HookDefinition, HookEventName } from '../hooks/types.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { GitService } from '../services/gitService.js'; +import { + NoopSandboxManager, + type SandboxManager, +} from '../services/sandboxManager.js'; import { initializeTelemetry, DEFAULT_TELEMETRY_TARGET, @@ -665,6 +669,7 @@ export class Config implements McpContext, AgentLoopContext { private readonly telemetrySettings: TelemetrySettings; private readonly usageStatisticsEnabled: boolean; private _geminiClient!: GeminiClient; + private readonly _sandboxManager: SandboxManager; private baseLlmClient!: BaseLlmClient; private localLiteRtLmClient?: LocalLiteRtLmClient; private modelRouterService: ModelRouterService; @@ -973,6 +978,7 @@ export class Config implements McpContext, AgentLoopContext { showColor: params.shellExecutionConfig?.showColor ?? false, pager: params.shellExecutionConfig?.pager ?? 'cat', sanitizationConfig: this.sanitizationConfig, + sandboxManager: this.sandboxManager, }; this.truncateToolOutputThreshold = params.truncateToolOutputThreshold ?? @@ -1088,6 +1094,8 @@ export class Config implements McpContext, AgentLoopContext { } } this._geminiClient = new GeminiClient(this); + this._sandboxManager = new NoopSandboxManager(); + this.shellExecutionConfig.sandboxManager = this._sandboxManager; this.modelRouterService = new ModelRouterService(this); // HACK: The settings loading logic doesn't currently merge the default @@ -1389,6 +1397,10 @@ export class Config implements McpContext, AgentLoopContext { return this._geminiClient; } + get sandboxManager(): SandboxManager { + return this._sandboxManager; + } + getSessionId(): string { return this.promptId; } @@ -2722,6 +2734,8 @@ export class Config implements McpContext, AgentLoopContext { sanitizationConfig: config.sanitizationConfig ?? this.shellExecutionConfig.sanitizationConfig, + sandboxManager: + config.sandboxManager ?? this.shellExecutionConfig.sandboxManager, }; } getScreenReader(): boolean { diff --git a/packages/core/src/config/sandbox-integration.test.ts b/packages/core/src/config/sandbox-integration.test.ts new file mode 100644 index 0000000000..305b9e2638 --- /dev/null +++ b/packages/core/src/config/sandbox-integration.test.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { Config } from './config.js'; +import { NoopSandboxManager } from '../services/sandboxManager.js'; + +// Minimal mocks for Config dependencies to allow instantiation +vi.mock('../core/client.js'); +vi.mock('../core/contentGenerator.js'); +vi.mock('../telemetry/index.js'); +vi.mock('../core/tokenLimits.js'); +vi.mock('../services/fileDiscoveryService.js'); +vi.mock('../services/gitService.js'); +vi.mock('../services/trackerService.js'); +vi.mock('../confirmation-bus/message-bus.js', () => ({ + MessageBus: vi.fn(), +})); +vi.mock('../policy/policy-engine.js', () => ({ + PolicyEngine: vi.fn().mockImplementation(() => ({ + getExcludedTools: vi.fn().mockReturnValue(new Set()), + })), +})); +vi.mock('../skills/skillManager.js', () => ({ + SkillManager: vi.fn().mockImplementation(() => ({ + setAdminSettings: vi.fn(), + })), +})); +vi.mock('../agents/registry.js', () => ({ + AgentRegistry: vi.fn().mockImplementation(() => ({ + initialize: vi.fn(), + })), +})); +vi.mock('../agents/acknowledgedAgents.js', () => ({ + AcknowledgedAgentsService: vi.fn(), +})); +vi.mock('../services/modelConfigService.js', () => ({ + ModelConfigService: vi.fn(), +})); +vi.mock('./models.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isPreviewModel: vi.fn().mockReturnValue(false), + resolveModel: vi.fn().mockReturnValue('test-model'), + }; +}); + +describe('Sandbox Integration', () => { + it('should have a NoopSandboxManager by default in Config', () => { + const config = new Config({ + sessionId: 'test-session', + targetDir: '.', + model: 'test-model', + cwd: '.', + debugMode: false, + }); + + expect(config.sandboxManager).toBeDefined(); + expect(config.sandboxManager).toBeInstanceOf(NoopSandboxManager); + }); +}); diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index a2f98dde98..ab35e52057 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -34,6 +34,7 @@ import { GeminiCliOperation, } from '../index.js'; import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; +import { NoopSandboxManager } from '../services/sandboxManager.js'; import { MockModifiableTool, MockTool, @@ -274,6 +275,7 @@ function createMockConfig(overrides: Partial = {}): Config { allowedEnvironmentVariables: [], blockedEnvironmentVariables: [], }, + sandboxManager: new NoopSandboxManager(), }), storage: { getProjectTempDir: () => '/tmp', @@ -1201,6 +1203,7 @@ describe('CoreToolScheduler request queueing', () => { allowedEnvironmentVariables: [], blockedEnvironmentVariables: [], }, + sandboxManager: new NoopSandboxManager(), }), isInteractive: () => false, }); @@ -1310,6 +1313,7 @@ describe('CoreToolScheduler request queueing', () => { allowedEnvironmentVariables: [], blockedEnvironmentVariables: [], }, + sandboxManager: new NoopSandboxManager(), }), getToolRegistry: () => toolRegistry, getHookSystem: () => undefined, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5dfd74ad61..747c9a3ef8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -144,6 +144,7 @@ export * from './ide/types.js'; // Export Shell Execution Service export * from './services/shellExecutionService.js'; +export * from './services/sandboxManager.js'; // Export base tool definitions export * from './tools/tools.js'; diff --git a/packages/core/src/services/sandboxManager.ts b/packages/core/src/services/sandboxManager.ts index 458e15260e..4f31527d8d 100644 --- a/packages/core/src/services/sandboxManager.ts +++ b/packages/core/src/services/sandboxManager.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -64,7 +64,9 @@ export class NoopSandboxManager implements SandboxManager { req.config?.sanitizationConfig?.allowedEnvironmentVariables ?? [], blockedEnvironmentVariables: req.config?.sanitizationConfig?.blockedEnvironmentVariables ?? [], - enableEnvironmentVariableRedaction: true, // Forced for safety + enableEnvironmentVariableRedaction: + req.config?.sanitizationConfig?.enableEnvironmentVariableRedaction ?? + true, }; const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig); diff --git a/packages/core/src/services/shellExecutionService.test.ts b/packages/core/src/services/shellExecutionService.test.ts index 77de13de3a..3b7c67953f 100644 --- a/packages/core/src/services/shellExecutionService.test.ts +++ b/packages/core/src/services/shellExecutionService.test.ts @@ -21,6 +21,7 @@ import { type ShellOutputEvent, type ShellExecutionConfig, } from './shellExecutionService.js'; +import { NoopSandboxManager } from './sandboxManager.js'; import type { AnsiOutput, AnsiToken } from '../utils/terminalSerializer.js'; // Hoisted Mocks @@ -103,6 +104,7 @@ const shellExecutionConfig: ShellExecutionConfig = { allowedEnvironmentVariables: [], blockedEnvironmentVariables: [], }, + sandboxManager: new NoopSandboxManager(), }; const createMockSerializeTerminalToObjectReturnValue = ( @@ -578,6 +580,7 @@ describe('ShellExecutionService', () => { new AbortController().signal, true, { + ...shellExecutionConfig, sanitizationConfig: { enableEnvironmentVariableRedaction: true, allowedEnvironmentVariables: [], @@ -1148,6 +1151,7 @@ describe('ShellExecutionService child_process fallback', () => { abortController.signal, true, { + ...shellExecutionConfig, sanitizationConfig: { enableEnvironmentVariableRedaction: true, allowedEnvironmentVariables: [], @@ -1360,6 +1364,7 @@ describe('ShellExecutionService execution method selection', () => { abortController.signal, false, // shouldUseNodePty { + ...shellExecutionConfig, sanitizationConfig: { enableEnvironmentVariableRedaction: true, allowedEnvironmentVariables: [], @@ -1507,6 +1512,7 @@ describe('ShellExecutionService environment variables', () => { new AbortController().signal, true, { + ...shellExecutionConfig, sanitizationConfig: { enableEnvironmentVariableRedaction: false, allowedEnvironmentVariables: [], @@ -1566,6 +1572,7 @@ describe('ShellExecutionService environment variables', () => { new AbortController().signal, true, { + ...shellExecutionConfig, sanitizationConfig: { enableEnvironmentVariableRedaction: false, allowedEnvironmentVariables: [], @@ -1632,4 +1639,56 @@ describe('ShellExecutionService environment variables', () => { mockChildProcess.emit('close', 0, null); await new Promise(process.nextTick); }); + + it('should call prepareCommand on sandboxManager when provided', async () => { + const mockSandboxManager = { + prepareCommand: vi.fn().mockResolvedValue({ + program: 'sandboxed-bash', + args: ['-c', 'ls'], + env: { SANDBOXED: 'true' }, + }), + }; + + const configWithSandbox: ShellExecutionConfig = { + ...shellExecutionConfig, + sandboxManager: mockSandboxManager, + }; + + mockResolveExecutable.mockResolvedValue('/bin/bash/resolved'); + const mockChild = new EventEmitter() as unknown as ChildProcess; + mockChild.stdout = new EventEmitter() as unknown as Readable; + mockChild.stderr = new EventEmitter() as unknown as Readable; + Object.assign(mockChild, { pid: 123 }); + mockCpSpawn.mockReturnValue(mockChild); + + const handle = await ShellExecutionService.execute( + 'ls', + '/test/cwd', + () => {}, + new AbortController().signal, + false, // child_process path + configWithSandbox, + ); + + expect(mockResolveExecutable).toHaveBeenCalledWith(expect.any(String)); + expect(mockSandboxManager.prepareCommand).toHaveBeenCalledWith( + expect.objectContaining({ + command: '/bin/bash/resolved', + args: expect.arrayContaining([expect.stringContaining('ls')]), + cwd: '/test/cwd', + }), + ); + expect(mockCpSpawn).toHaveBeenCalledWith( + 'sandboxed-bash', + ['-c', 'ls'], + expect.objectContaining({ + env: expect.objectContaining({ SANDBOXED: 'true' }), + }), + ); + + // Clean up + mockChild.emit('exit', 0, null); + mockChild.emit('close', 0, null); + await handle.result; + }); }); diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index fdb2ca79b5..5775c7f4b2 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -22,10 +22,8 @@ import { serializeTerminalToObject, type AnsiOutput, } from '../utils/terminalSerializer.js'; -import { - sanitizeEnvironment, - type EnvironmentSanitizationConfig, -} from './environmentSanitization.js'; +import { type EnvironmentSanitizationConfig } from './environmentSanitization.js'; +import { type SandboxManager } from './sandboxManager.js'; import { killProcessGroup } from '../utils/process-utils.js'; const { Terminal } = pkg; @@ -102,6 +100,7 @@ export interface ShellExecutionConfig { defaultFg?: string; defaultBg?: string; sanitizationConfig: EnvironmentSanitizationConfig; + sandboxManager: SandboxManager; // Used for testing disableDynamicLineTrimming?: boolean; scrollback?: number; @@ -251,7 +250,7 @@ export class ShellExecutionService { cwd, onOutputEvent, abortSignal, - shellExecutionConfig.sanitizationConfig, + shellExecutionConfig, ); } @@ -292,33 +291,68 @@ export class ShellExecutionService { } } - private static childProcessFallback( + private static async prepareExecution( + executable: string, + args: string[], + cwd: string, + env: NodeJS.ProcessEnv, + shellExecutionConfig: ShellExecutionConfig, + ): Promise<{ program: string; args: string[]; env: NodeJS.ProcessEnv }> { + const resolvedExecutable = + (await resolveExecutable(executable)) ?? executable; + + return shellExecutionConfig.sandboxManager.prepareCommand({ + command: resolvedExecutable, + args, + cwd, + env, + config: { + sanitizationConfig: shellExecutionConfig.sanitizationConfig, + }, + }); + } + + private static async childProcessFallback( commandToExecute: string, cwd: string, onOutputEvent: (event: ShellOutputEvent) => void, abortSignal: AbortSignal, - sanitizationConfig: EnvironmentSanitizationConfig, - ): ShellExecutionHandle { + shellExecutionConfig: ShellExecutionConfig, + ): Promise { try { const isWindows = os.platform() === 'win32'; const { executable, argsPrefix, shell } = getShellConfiguration(); const guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell); const spawnArgs = [...argsPrefix, guardedCommand]; - const child = cpSpawn(executable, spawnArgs, { + const env = { + ...process.env, + [GEMINI_CLI_IDENTIFICATION_ENV_VAR]: + GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE, + TERM: 'xterm-256color', + PAGER: 'cat', + GIT_PAGER: 'cat', + }; + + const { + program: finalExecutable, + args: finalArgs, + env: finalEnv, + } = await this.prepareExecution( + executable, + spawnArgs, + cwd, + env, + shellExecutionConfig, + ); + + const child = cpSpawn(finalExecutable, finalArgs, { cwd, stdio: ['ignore', 'pipe', 'pipe'], windowsVerbatimArguments: isWindows ? false : undefined, shell: false, detached: !isWindows, - env: { - ...sanitizeEnvironment(process.env, sanitizationConfig), - [GEMINI_CLI_IDENTIFICATION_ENV_VAR]: - GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE, - TERM: 'xterm-256color', - PAGER: 'cat', - GIT_PAGER: 'cat', - }, + env: finalEnv, }); const state = { @@ -557,32 +591,36 @@ export class ShellExecutionService { const rows = shellExecutionConfig.terminalHeight ?? 30; const { executable, argsPrefix, shell } = getShellConfiguration(); - const resolvedExecutable = await resolveExecutable(executable); - if (!resolvedExecutable) { - throw new Error( - `Shell executable "${executable}" not found in PATH or at absolute location. Please ensure the shell is installed and available in your environment.`, - ); - } - const guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell); const args = [...argsPrefix, guardedCommand]; + const env = { + ...process.env, + GEMINI_CLI: '1', + TERM: 'xterm-256color', + PAGER: shellExecutionConfig.pager ?? 'cat', + GIT_PAGER: shellExecutionConfig.pager ?? 'cat', + }; + + const { + program: finalExecutable, + args: finalArgs, + env: finalEnv, + } = await this.prepareExecution( + executable, + args, + cwd, + env, + shellExecutionConfig, + ); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const ptyProcess = ptyInfo.module.spawn(executable, args, { + const ptyProcess = ptyInfo.module.spawn(finalExecutable, finalArgs, { cwd, name: 'xterm-256color', cols, rows, - env: { - ...sanitizeEnvironment( - process.env, - shellExecutionConfig.sanitizationConfig, - ), - GEMINI_CLI: '1', - TERM: 'xterm-256color', - PAGER: shellExecutionConfig.pager ?? 'cat', - GIT_PAGER: shellExecutionConfig.pager ?? 'cat', - }, + env: finalEnv, handleFlowControl: true, }); diff --git a/packages/core/src/tools/grep.ts b/packages/core/src/tools/grep.ts index c7e676951a..2eba7421ae 100644 --- a/packages/core/src/tools/grep.ts +++ b/packages/core/src/tools/grep.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -290,15 +290,41 @@ class GrepToolInvocation extends BaseToolInvocation< * @param {string} command The command name (e.g., 'git', 'grep'). * @returns {Promise} True if the command is available, false otherwise. */ - private isCommandAvailable(command: string): Promise { - return new Promise((resolve) => { - const checkCommand = process.platform === 'win32' ? 'where' : 'command'; - const checkArgs = - process.platform === 'win32' ? [command] : ['-v', command]; - try { - const child = spawn(checkCommand, checkArgs, { + private async isCommandAvailable(command: string): Promise { + const checkCommand = process.platform === 'win32' ? 'where' : 'command'; + const checkArgs = + process.platform === 'win32' ? [command] : ['-v', command]; + try { + const sandboxManager = this.config.sandboxManager; + + let finalCommand = checkCommand; + let finalArgs = checkArgs; + let finalEnv = process.env; + + if (sandboxManager) { + try { + const prepared = await sandboxManager.prepareCommand({ + command: checkCommand, + args: checkArgs, + cwd: process.cwd(), + env: process.env, + }); + finalCommand = prepared.program; + finalArgs = prepared.args; + finalEnv = prepared.env; + } catch (err) { + debugLogger.debug( + `[GrepTool] Sandbox preparation failed for '${command}':`, + err, + ); + } + } + + return await new Promise((resolve) => { + const child = spawn(finalCommand, finalArgs, { stdio: 'ignore', shell: true, + env: finalEnv, }); child.on('close', (code) => resolve(code === 0)); child.on('error', (err) => { @@ -308,10 +334,10 @@ class GrepToolInvocation extends BaseToolInvocation< ); resolve(false); }); - } catch { - resolve(false); - } - }); + }); + } catch { + return false; + } } /** @@ -370,6 +396,7 @@ class GrepToolInvocation extends BaseToolInvocation< cwd: absolutePath, signal: options.signal, allowedExitCodes: [0, 1], + sandboxManager: this.config.sandboxManager, }); const results: GrepMatch[] = []; @@ -441,6 +468,7 @@ class GrepToolInvocation extends BaseToolInvocation< cwd: absolutePath, signal: options.signal, allowedExitCodes: [0, 1], + sandboxManager: this.config.sandboxManager, }); for await (const line of generator) { diff --git a/packages/core/src/tools/ripGrep.ts b/packages/core/src/tools/ripGrep.ts index 18a1b0c133..69f269143b 100644 --- a/packages/core/src/tools/ripGrep.ts +++ b/packages/core/src/tools/ripGrep.ts @@ -476,6 +476,7 @@ class GrepToolInvocation extends BaseToolInvocation< const generator = execStreaming(rgPath, rgArgs, { signal: options.signal, allowedExitCodes: [0, 1], + sandboxManager: this.config.sandboxManager, }); let matchesFound = 0; diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index d3e47de17f..d160b38876 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -45,6 +45,7 @@ import { initializeShellParsers } from '../utils/shell-utils.js'; import { ShellTool, OUTPUT_UPDATE_INTERVAL_MS } from './shell.js'; import { debugLogger } from '../index.js'; import { type Config } from '../config/config.js'; +import { NoopSandboxManager } from '../services/sandboxManager.js'; import { type ShellExecutionResult, type ShellOutputEvent, @@ -130,6 +131,7 @@ describe('ShellTool', () => { getEnableInteractiveShell: vi.fn().mockReturnValue(false), getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true), sanitizationConfig: {}, + sandboxManager: new NoopSandboxManager(), } as unknown as Config; const bus = createMockMessageBus(); @@ -274,7 +276,11 @@ describe('ShellTool', () => { expect.any(Function), expect.any(AbortSignal), false, - { pager: 'cat', sanitizationConfig: {} }, + expect.objectContaining({ + pager: 'cat', + sanitizationConfig: {}, + sandboxManager: expect.any(Object), + }), ); expect(result.llmContent).toContain('Background PIDs: 54322'); // The file should be deleted by the tool @@ -299,7 +305,11 @@ describe('ShellTool', () => { expect.any(Function), expect.any(AbortSignal), false, - { pager: 'cat', sanitizationConfig: {} }, + expect.objectContaining({ + pager: 'cat', + sanitizationConfig: {}, + sandboxManager: expect.any(Object), + }), ); }); @@ -320,7 +330,11 @@ describe('ShellTool', () => { expect.any(Function), expect.any(AbortSignal), false, - { pager: 'cat', sanitizationConfig: {} }, + expect.objectContaining({ + pager: 'cat', + sanitizationConfig: {}, + sandboxManager: expect.any(Object), + }), ); }); @@ -366,7 +380,11 @@ describe('ShellTool', () => { expect.any(Function), expect.any(AbortSignal), false, - { pager: 'cat', sanitizationConfig: {} }, + { + pager: 'cat', + sanitizationConfig: {}, + sandboxManager: new NoopSandboxManager(), + }, ); }, 20000, diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 4ea83b0af4..aa3034e6ca 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -277,6 +277,7 @@ export class ShellToolInvocation extends BaseToolInvocation< sanitizationConfig: shellExecutionConfig?.sanitizationConfig ?? this.config.sanitizationConfig, + sandboxManager: this.config.sandboxManager, }, ); diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index 69695877c2..a9bfbb0229 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -57,7 +57,28 @@ class DiscoveredToolInvocation extends BaseToolInvocation< _updateOutput?: (output: string) => void, ): Promise { const callCommand = this.config.getToolCallCommand()!; - const child = spawn(callCommand, [this.originalToolName]); + const args = [this.originalToolName]; + + let finalCommand = callCommand; + let finalArgs = args; + let finalEnv = process.env; + + const sandboxManager = this.config.sandboxManager; + if (sandboxManager) { + const prepared = await sandboxManager.prepareCommand({ + command: callCommand, + args, + cwd: process.cwd(), + env: process.env, + }); + finalCommand = prepared.program; + finalArgs = prepared.args; + finalEnv = prepared.env; + } + + const child = spawn(finalCommand, finalArgs, { + env: finalEnv, + }); child.stdin.write(JSON.stringify(this.params)); child.stdin.end(); @@ -326,8 +347,36 @@ export class ToolRegistry { 'Tool discovery command is empty or contains only whitespace.', ); } - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const proc = spawn(cmdParts[0] as string, cmdParts.slice(1) as string[]); + + const firstPart = cmdParts[0]; + if (typeof firstPart !== 'string') { + throw new Error( + 'Tool discovery command must start with a program name.', + ); + } + + let finalCommand: string = firstPart; + let finalArgs: string[] = cmdParts + .slice(1) + .filter((p): p is string => typeof p === 'string'); + let finalEnv = process.env; + + const sandboxManager = this.config.sandboxManager; + if (sandboxManager) { + const prepared = await sandboxManager.prepareCommand({ + command: finalCommand, + args: finalArgs, + cwd: process.cwd(), + env: process.env, + }); + finalCommand = prepared.program; + finalArgs = prepared.args; + finalEnv = prepared.env; + } + + const proc = spawn(finalCommand, finalArgs, { + env: finalEnv, + }); let stdout = ''; const stdoutDecoder = new StringDecoder('utf8'); let stderr = ''; diff --git a/packages/core/src/utils/shell-utils.ts b/packages/core/src/utils/shell-utils.ts index 00b3533400..89f50a9ce7 100644 --- a/packages/core/src/utils/shell-utils.ts +++ b/packages/core/src/utils/shell-utils.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -17,6 +17,8 @@ import * as readline from 'node:readline'; import { Language, Parser, Query, type Node, type Tree } from 'web-tree-sitter'; import { loadWasmBinary } from './fileUtils.js'; import { debugLogger } from './debugLogger.js'; +import type { SandboxManager } from '../services/sandboxManager.js'; +import { NoopSandboxManager } from '../services/sandboxManager.js'; export const SHELL_TOOL_NAMES = ['run_shell_command', 'ShellTool']; @@ -737,13 +739,26 @@ export function stripShellWrapper(command: string): string { * @param config The application configuration. * @returns An object with 'allowed' boolean and optional 'reason' string if not allowed. */ -export const spawnAsync = ( +export const spawnAsync = async ( command: string, args: string[], - options?: SpawnOptionsWithoutStdio, -): Promise<{ stdout: string; stderr: string }> => - new Promise((resolve, reject) => { - const child = spawn(command, args, options); + options?: SpawnOptionsWithoutStdio & { sandboxManager?: SandboxManager }, +): Promise<{ stdout: string; stderr: string }> => { + const sandboxManager = options?.sandboxManager ?? new NoopSandboxManager(); + const prepared = await sandboxManager.prepareCommand({ + command, + args, + cwd: options?.cwd?.toString() ?? process.cwd(), + env: options?.env ?? process.env, + }); + + const { program: finalCommand, args: finalArgs, env: finalEnv } = prepared; + + return new Promise((resolve, reject) => { + const child = spawn(finalCommand, finalArgs, { + ...options, + env: finalEnv, + }); let stdout = ''; let stderr = ''; @@ -767,6 +782,7 @@ export const spawnAsync = ( reject(err); }); }); +}; /** * Executes a command and yields lines of output as they appear. @@ -782,10 +798,22 @@ export async function* execStreaming( options?: SpawnOptionsWithoutStdio & { signal?: AbortSignal; allowedExitCodes?: number[]; + sandboxManager?: SandboxManager; }, ): AsyncGenerator { - const child = spawn(command, args, { + const sandboxManager = options?.sandboxManager ?? new NoopSandboxManager(); + const prepared = await sandboxManager.prepareCommand({ + command, + args, + cwd: options?.cwd?.toString() ?? process.cwd(), + env: options?.env ?? process.env, + }); + + const { program: finalCommand, args: finalArgs, env: finalEnv } = prepared; + + const child = spawn(finalCommand, finalArgs, { ...options, + env: finalEnv, // ensure we don't open a window on windows if possible/relevant windowsHide: true, });