mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-20 10:10:56 -07:00
feat(core): integrate SandboxManager to sandbox all process-spawning tools (#22231)
This commit is contained in:
@@ -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';
|
||||
import type { Config } from './config.js';
|
||||
|
||||
/**
|
||||
@@ -28,4 +29,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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
createSandboxManager,
|
||||
type SandboxManager,
|
||||
} from '../services/sandboxManager.js';
|
||||
import {
|
||||
initializeTelemetry,
|
||||
DEFAULT_TELEMETRY_TARGET,
|
||||
@@ -510,6 +514,7 @@ export interface ConfigParameters {
|
||||
clientVersion?: string;
|
||||
embeddingModel?: string;
|
||||
sandbox?: SandboxConfig;
|
||||
toolSandboxing?: boolean;
|
||||
targetDir: string;
|
||||
debugMode: boolean;
|
||||
question?: string;
|
||||
@@ -686,6 +691,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;
|
||||
@@ -855,7 +861,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, []);
|
||||
@@ -985,6 +1003,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 ??
|
||||
@@ -1102,6 +1121,8 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
}
|
||||
this._geminiClient = new GeminiClient(this);
|
||||
this._sandboxManager = createSandboxManager(params.toolSandboxing ?? false);
|
||||
this.shellExecutionConfig.sandboxManager = this._sandboxManager;
|
||||
this.modelRouterService = new ModelRouterService(this);
|
||||
|
||||
// HACK: The settings loading logic doesn't currently merge the default
|
||||
@@ -1423,6 +1444,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this._geminiClient;
|
||||
}
|
||||
|
||||
get sandboxManager(): SandboxManager {
|
||||
return this._sandboxManager;
|
||||
}
|
||||
|
||||
getSessionId(): string {
|
||||
return this.promptId;
|
||||
}
|
||||
@@ -2810,6 +2835,8 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
sanitizationConfig:
|
||||
config.sanitizationConfig ??
|
||||
this.shellExecutionConfig.sanitizationConfig,
|
||||
sandboxManager:
|
||||
config.sandboxManager ?? this.shellExecutionConfig.sandboxManager,
|
||||
};
|
||||
}
|
||||
getScreenReader(): boolean {
|
||||
|
||||
@@ -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<typeof import('./models.js')>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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> = {}): Config {
|
||||
allowedEnvironmentVariables: [],
|
||||
blockedEnvironmentVariables: [],
|
||||
},
|
||||
sandboxManager: new NoopSandboxManager(),
|
||||
}),
|
||||
storage: {
|
||||
getProjectTempDir: () => '/tmp',
|
||||
@@ -1211,6 +1213,7 @@ describe('CoreToolScheduler request queueing', () => {
|
||||
allowedEnvironmentVariables: [],
|
||||
blockedEnvironmentVariables: [],
|
||||
},
|
||||
sandboxManager: new NoopSandboxManager(),
|
||||
}),
|
||||
isInteractive: () => false,
|
||||
});
|
||||
@@ -1320,6 +1323,7 @@ describe('CoreToolScheduler request queueing', () => {
|
||||
allowedEnvironmentVariables: [],
|
||||
blockedEnvironmentVariables: [],
|
||||
},
|
||||
sandboxManager: new NoopSandboxManager(),
|
||||
}),
|
||||
getToolRegistry: () => toolRegistry,
|
||||
getHookSystem: () => undefined,
|
||||
|
||||
@@ -146,6 +146,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';
|
||||
|
||||
@@ -125,7 +125,7 @@ export const NEVER_ALLOWED_VALUE_PATTERNS = [
|
||||
/-----BEGIN (RSA|OPENSSH|EC|PGP) PRIVATE KEY-----/i,
|
||||
/-----BEGIN CERTIFICATE-----/i,
|
||||
// Credentials in URL
|
||||
/(https?|ftp|smtp):\/\/[^:]+:[^@]+@/i,
|
||||
/(https?|ftp|smtp):\/\/[^:\s]{1,1024}:[^@\s]{1,1024}@/i,
|
||||
// GitHub tokens (classic, fine-grained, OAuth, etc.)
|
||||
/(ghp|gho|ghu|ghs|ghr|github_pat)_[a-zA-Z0-9_]{36,}/i,
|
||||
// Google API keys
|
||||
@@ -133,7 +133,7 @@ export const NEVER_ALLOWED_VALUE_PATTERNS = [
|
||||
// Amazon AWS Access Key ID
|
||||
/AKIA[A-Z0-9]{16}/i,
|
||||
// Generic OAuth/JWT tokens
|
||||
/eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*/i,
|
||||
/eyJ[a-zA-Z0-9_-]{0,10240}\.[a-zA-Z0-9_-]{0,10240}\.[a-zA-Z0-9_-]{0,10240}/i,
|
||||
// Stripe API keys
|
||||
/(s|r)k_(live|test)_[0-9a-zA-Z]{24}/i,
|
||||
// Slack tokens (bot, user, etc.)
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('NoopSandboxManager', () => {
|
||||
expect(result.env['MY_SECRET']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should force environment variable redaction even if not requested in config', async () => {
|
||||
it('should allow disabling environment variable redaction if requested in config', async () => {
|
||||
const req = {
|
||||
command: 'echo',
|
||||
args: ['hello'],
|
||||
@@ -62,7 +62,7 @@ describe('NoopSandboxManager', () => {
|
||||
|
||||
const result = await sandboxManager.prepareCommand(req);
|
||||
|
||||
expect(result.env['API_KEY']).toBeUndefined();
|
||||
expect(result.env['API_KEY']).toBe('sensitive-key');
|
||||
});
|
||||
|
||||
it('should respect allowedEnvironmentVariables in config', async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -37,6 +37,8 @@ export interface SandboxedCommand {
|
||||
args: string[];
|
||||
/** Sanitized environment variables. */
|
||||
env: NodeJS.ProcessEnv;
|
||||
/** The working directory. */
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,7 +66,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);
|
||||
@@ -76,3 +80,24 @@ export class NoopSandboxManager implements SandboxManager {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SandboxManager that implements actual sandboxing.
|
||||
*/
|
||||
export class LocalSandboxManager implements SandboxManager {
|
||||
async prepareCommand(_req: SandboxRequest): Promise<SandboxedCommand> {
|
||||
throw new Error('Tool sandboxing is not yet implemented.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a sandbox manager based on the provided settings.
|
||||
*/
|
||||
export function createSandboxManager(
|
||||
sandboxingEnabled: boolean,
|
||||
): SandboxManager {
|
||||
if (sandboxingEnabled) {
|
||||
return new LocalSandboxManager();
|
||||
}
|
||||
return new NoopSandboxManager();
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type ShellOutputEvent,
|
||||
type ShellExecutionConfig,
|
||||
} from './shellExecutionService.js';
|
||||
import { NoopSandboxManager } from './sandboxManager.js';
|
||||
import { ExecutionLifecycleService } from './executionLifecycleService.js';
|
||||
import type { AnsiOutput, AnsiToken } from '../utils/terminalSerializer.js';
|
||||
|
||||
@@ -137,6 +138,7 @@ const shellExecutionConfig: ShellExecutionConfig = {
|
||||
allowedEnvironmentVariables: [],
|
||||
blockedEnvironmentVariables: [],
|
||||
},
|
||||
sandboxManager: new NoopSandboxManager(),
|
||||
};
|
||||
|
||||
const createMockSerializeTerminalToObjectReturnValue = (
|
||||
@@ -625,6 +627,7 @@ describe('ShellExecutionService', () => {
|
||||
new AbortController().signal,
|
||||
true,
|
||||
{
|
||||
...shellExecutionConfig,
|
||||
sanitizationConfig: {
|
||||
enableEnvironmentVariableRedaction: true,
|
||||
allowedEnvironmentVariables: [],
|
||||
@@ -1396,7 +1399,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
expect(mockCpSpawn).toHaveBeenCalledWith(
|
||||
expectedCommand,
|
||||
['/pid', String(mockChildProcess.pid), '/f', '/t'],
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1417,6 +1420,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
abortController.signal,
|
||||
true,
|
||||
{
|
||||
...shellExecutionConfig,
|
||||
sanitizationConfig: {
|
||||
enableEnvironmentVariableRedaction: true,
|
||||
allowedEnvironmentVariables: [],
|
||||
@@ -1631,6 +1635,7 @@ describe('ShellExecutionService execution method selection', () => {
|
||||
abortController.signal,
|
||||
false, // shouldUseNodePty
|
||||
{
|
||||
...shellExecutionConfig,
|
||||
sanitizationConfig: {
|
||||
enableEnvironmentVariableRedaction: true,
|
||||
allowedEnvironmentVariables: [],
|
||||
@@ -1778,6 +1783,7 @@ describe('ShellExecutionService environment variables', () => {
|
||||
new AbortController().signal,
|
||||
true,
|
||||
{
|
||||
...shellExecutionConfig,
|
||||
sanitizationConfig: {
|
||||
enableEnvironmentVariableRedaction: false,
|
||||
allowedEnvironmentVariables: [],
|
||||
@@ -1837,6 +1843,7 @@ describe('ShellExecutionService environment variables', () => {
|
||||
new AbortController().signal,
|
||||
true,
|
||||
{
|
||||
...shellExecutionConfig,
|
||||
sanitizationConfig: {
|
||||
enableEnvironmentVariableRedaction: false,
|
||||
allowedEnvironmentVariables: [],
|
||||
@@ -1904,6 +1911,58 @@ describe('ShellExecutionService environment variables', () => {
|
||||
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;
|
||||
});
|
||||
|
||||
it('should include headless git and gh environment variables in non-interactive mode and append git config safely', async () => {
|
||||
vi.resetModules();
|
||||
vi.stubEnv('GIT_CONFIG_COUNT', '2');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -27,11 +27,8 @@ import {
|
||||
serializeTerminalToObject,
|
||||
type AnsiOutput,
|
||||
} from '../utils/terminalSerializer.js';
|
||||
import {
|
||||
sanitizeEnvironment,
|
||||
type EnvironmentSanitizationConfig,
|
||||
} from './environmentSanitization.js';
|
||||
import { NoopSandboxManager } from './sandboxManager.js';
|
||||
import { type EnvironmentSanitizationConfig } from './environmentSanitization.js';
|
||||
import { type SandboxManager } from './sandboxManager.js';
|
||||
import { killProcessGroup } from '../utils/process-utils.js';
|
||||
import {
|
||||
ExecutionLifecycleService,
|
||||
@@ -90,6 +87,7 @@ export interface ShellExecutionConfig {
|
||||
defaultFg?: string;
|
||||
defaultBg?: string;
|
||||
sanitizationConfig: EnvironmentSanitizationConfig;
|
||||
sandboxManager: SandboxManager;
|
||||
// Used for testing
|
||||
disableDynamicLineTrimming?: boolean;
|
||||
scrollback?: number;
|
||||
@@ -274,15 +272,6 @@ export class ShellExecutionService {
|
||||
shouldUseNodePty: boolean,
|
||||
shellExecutionConfig: ShellExecutionConfig,
|
||||
): Promise<ShellExecutionHandle> {
|
||||
const sandboxManager = new NoopSandboxManager();
|
||||
const { env: sanitizedEnv } = await sandboxManager.prepareCommand({
|
||||
command: commandToExecute,
|
||||
args: [],
|
||||
env: process.env,
|
||||
cwd,
|
||||
config: shellExecutionConfig,
|
||||
});
|
||||
|
||||
if (shouldUseNodePty) {
|
||||
const ptyInfo = await getPty();
|
||||
if (ptyInfo) {
|
||||
@@ -294,7 +283,6 @@ export class ShellExecutionService {
|
||||
abortSignal,
|
||||
shellExecutionConfig,
|
||||
ptyInfo,
|
||||
sanitizedEnv,
|
||||
);
|
||||
} catch (_e) {
|
||||
// Fallback to child_process
|
||||
@@ -307,7 +295,7 @@ export class ShellExecutionService {
|
||||
cwd,
|
||||
onOutputEvent,
|
||||
abortSignal,
|
||||
shellExecutionConfig.sanitizationConfig,
|
||||
shellExecutionConfig,
|
||||
shouldUseNodePty,
|
||||
);
|
||||
}
|
||||
@@ -342,14 +330,49 @@ export class ShellExecutionService {
|
||||
return { newBuffer: truncatedBuffer + chunk, truncated: true };
|
||||
}
|
||||
|
||||
private static childProcessFallback(
|
||||
private static async prepareExecution(
|
||||
executable: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
shellExecutionConfig: ShellExecutionConfig,
|
||||
sanitizationConfigOverride?: EnvironmentSanitizationConfig,
|
||||
): Promise<{
|
||||
program: string;
|
||||
args: string[];
|
||||
env: NodeJS.ProcessEnv;
|
||||
cwd: string;
|
||||
}> {
|
||||
const resolvedExecutable =
|
||||
(await resolveExecutable(executable)) ?? executable;
|
||||
|
||||
const prepared = await shellExecutionConfig.sandboxManager.prepareCommand({
|
||||
command: resolvedExecutable,
|
||||
args,
|
||||
cwd,
|
||||
env,
|
||||
config: {
|
||||
sanitizationConfig:
|
||||
sanitizationConfigOverride ?? shellExecutionConfig.sanitizationConfig,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
program: prepared.program,
|
||||
args: prepared.args,
|
||||
env: prepared.env,
|
||||
cwd: prepared.cwd ?? cwd,
|
||||
};
|
||||
}
|
||||
|
||||
private static async childProcessFallback(
|
||||
commandToExecute: string,
|
||||
cwd: string,
|
||||
onOutputEvent: (event: ShellOutputEvent) => void,
|
||||
abortSignal: AbortSignal,
|
||||
sanitizationConfig: EnvironmentSanitizationConfig,
|
||||
shellExecutionConfig: ShellExecutionConfig,
|
||||
isInteractive: boolean,
|
||||
): ShellExecutionHandle {
|
||||
): Promise<ShellExecutionHandle> {
|
||||
try {
|
||||
const isWindows = os.platform() === 'win32';
|
||||
const { executable, argsPrefix, shell } = getShellConfiguration();
|
||||
@@ -361,16 +384,17 @@ export class ShellExecutionService {
|
||||
const gitConfigKeys = !isInteractive
|
||||
? Object.keys(process.env).filter((k) => k.startsWith('GIT_CONFIG_'))
|
||||
: [];
|
||||
const sanitizedEnv = sanitizeEnvironment(process.env, {
|
||||
...sanitizationConfig,
|
||||
const localSanitizationConfig = {
|
||||
...shellExecutionConfig.sanitizationConfig,
|
||||
allowedEnvironmentVariables: [
|
||||
...(sanitizationConfig.allowedEnvironmentVariables || []),
|
||||
...(shellExecutionConfig.sanitizationConfig
|
||||
.allowedEnvironmentVariables || []),
|
||||
...gitConfigKeys,
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...sanitizedEnv,
|
||||
const env = {
|
||||
...process.env,
|
||||
[GEMINI_CLI_IDENTIFICATION_ENV_VAR]:
|
||||
GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE,
|
||||
TERM: 'xterm-256color',
|
||||
@@ -378,12 +402,28 @@ export class ShellExecutionService {
|
||||
GIT_PAGER: 'cat',
|
||||
};
|
||||
|
||||
const {
|
||||
program: finalExecutable,
|
||||
args: finalArgs,
|
||||
env: sanitizedEnv,
|
||||
cwd: finalCwd,
|
||||
} = await this.prepareExecution(
|
||||
executable,
|
||||
spawnArgs,
|
||||
cwd,
|
||||
env,
|
||||
shellExecutionConfig,
|
||||
localSanitizationConfig,
|
||||
);
|
||||
|
||||
const finalEnv = { ...sanitizedEnv };
|
||||
|
||||
if (!isInteractive) {
|
||||
const gitConfigCount = parseInt(
|
||||
sanitizedEnv['GIT_CONFIG_COUNT'] || '0',
|
||||
finalEnv['GIT_CONFIG_COUNT'] || '0',
|
||||
10,
|
||||
);
|
||||
Object.assign(env, {
|
||||
Object.assign(finalEnv, {
|
||||
// Disable interactive prompts and session-linked credential helpers
|
||||
// in non-interactive mode to prevent hangs in detached process groups.
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
@@ -399,13 +439,13 @@ export class ShellExecutionService {
|
||||
});
|
||||
}
|
||||
|
||||
const child = cpSpawn(executable, spawnArgs, {
|
||||
cwd,
|
||||
const child = cpSpawn(finalExecutable, finalArgs, {
|
||||
cwd: finalCwd,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsVerbatimArguments: isWindows ? false : undefined,
|
||||
shell: false,
|
||||
detached: !isWindows,
|
||||
env,
|
||||
env: finalEnv,
|
||||
});
|
||||
|
||||
const state = {
|
||||
@@ -682,7 +722,6 @@ export class ShellExecutionService {
|
||||
abortSignal: AbortSignal,
|
||||
shellExecutionConfig: ShellExecutionConfig,
|
||||
ptyInfo: PtyImplementation,
|
||||
sanitizedEnv: Record<string, string | undefined>,
|
||||
): Promise<ShellExecutionHandle> {
|
||||
if (!ptyInfo) {
|
||||
// This should not happen, but as a safeguard...
|
||||
@@ -695,29 +734,52 @@ 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];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const ptyProcess = ptyInfo.module.spawn(executable, args, {
|
||||
const env = {
|
||||
...process.env,
|
||||
GEMINI_CLI: '1',
|
||||
TERM: 'xterm-256color',
|
||||
PAGER: shellExecutionConfig.pager ?? 'cat',
|
||||
GIT_PAGER: shellExecutionConfig.pager ?? 'cat',
|
||||
};
|
||||
|
||||
// Specifically allow GIT_CONFIG_* variables to pass through sanitization
|
||||
// so we can safely append our overrides if needed.
|
||||
const gitConfigKeys = Object.keys(process.env).filter((k) =>
|
||||
k.startsWith('GIT_CONFIG_'),
|
||||
);
|
||||
const localSanitizationConfig = {
|
||||
...shellExecutionConfig.sanitizationConfig,
|
||||
allowedEnvironmentVariables: [
|
||||
...(shellExecutionConfig.sanitizationConfig
|
||||
?.allowedEnvironmentVariables ?? []),
|
||||
...gitConfigKeys,
|
||||
],
|
||||
};
|
||||
|
||||
const {
|
||||
program: finalExecutable,
|
||||
args: finalArgs,
|
||||
env: finalEnv,
|
||||
cwd: finalCwd,
|
||||
} = await this.prepareExecution(
|
||||
executable,
|
||||
args,
|
||||
cwd,
|
||||
env,
|
||||
shellExecutionConfig,
|
||||
localSanitizationConfig,
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const ptyProcess = ptyInfo.module.spawn(finalExecutable, finalArgs, {
|
||||
cwd: finalCwd,
|
||||
name: 'xterm-256color',
|
||||
cols,
|
||||
rows,
|
||||
env: {
|
||||
...sanitizedEnv,
|
||||
GEMINI_CLI: '1',
|
||||
TERM: 'xterm-256color',
|
||||
PAGER: shellExecutionConfig.pager ?? 'cat',
|
||||
GIT_PAGER: shellExecutionConfig.pager ?? 'cat',
|
||||
},
|
||||
env: finalEnv,
|
||||
handleFlowControl: true,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -301,15 +301,41 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
* @param {string} command The command name (e.g., 'git', 'grep').
|
||||
* @returns {Promise<boolean>} True if the command is available, false otherwise.
|
||||
*/
|
||||
private isCommandAvailable(command: string): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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) => {
|
||||
@@ -319,10 +345,10 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
);
|
||||
resolve(false);
|
||||
});
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -381,6 +407,7 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
cwd: absolutePath,
|
||||
signal: options.signal,
|
||||
allowedExitCodes: [0, 1],
|
||||
sandboxManager: this.config.sandboxManager,
|
||||
});
|
||||
|
||||
const results: GrepMatch[] = [];
|
||||
@@ -452,6 +479,7 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
cwd: absolutePath,
|
||||
signal: options.signal,
|
||||
allowedExitCodes: [0, 1],
|
||||
sandboxManager: this.config.sandboxManager,
|
||||
});
|
||||
|
||||
for await (const line of generator) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
@@ -137,6 +138,7 @@ describe('ShellTool', () => {
|
||||
getEnableInteractiveShell: vi.fn().mockReturnValue(false),
|
||||
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
|
||||
sanitizationConfig: {},
|
||||
sandboxManager: new NoopSandboxManager(),
|
||||
} as unknown as Config;
|
||||
|
||||
const bus = createMockMessageBus();
|
||||
@@ -281,7 +283,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
|
||||
@@ -306,7 +312,11 @@ describe('ShellTool', () => {
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal),
|
||||
false,
|
||||
{ pager: 'cat', sanitizationConfig: {} },
|
||||
expect.objectContaining({
|
||||
pager: 'cat',
|
||||
sanitizationConfig: {},
|
||||
sandboxManager: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -327,7 +337,11 @@ describe('ShellTool', () => {
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal),
|
||||
false,
|
||||
{ pager: 'cat', sanitizationConfig: {} },
|
||||
expect.objectContaining({
|
||||
pager: 'cat',
|
||||
sanitizationConfig: {},
|
||||
sandboxManager: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -373,7 +387,11 @@ describe('ShellTool', () => {
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal),
|
||||
false,
|
||||
{ pager: 'cat', sanitizationConfig: {} },
|
||||
{
|
||||
pager: 'cat',
|
||||
sanitizationConfig: {},
|
||||
sandboxManager: new NoopSandboxManager(),
|
||||
},
|
||||
);
|
||||
},
|
||||
20000,
|
||||
|
||||
@@ -278,6 +278,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
sanitizationConfig:
|
||||
shellExecutionConfig?.sanitizationConfig ??
|
||||
this.context.config.sanitizationConfig,
|
||||
sandboxManager: this.context.config.sandboxManager,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -57,7 +57,28 @@ class DiscoveredToolInvocation extends BaseToolInvocation<
|
||||
_updateOutput?: (output: string) => void,
|
||||
): Promise<ToolResult> {
|
||||
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();
|
||||
|
||||
@@ -322,8 +343,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 = '';
|
||||
|
||||
@@ -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<string, void, void> {
|
||||
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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user