feat(core): integrate SandboxManager to sandbox all process-spawning tools

- Integrate `SandboxManager` into `Config` and `AgentLoopContext`.
- Refactor `ShellExecutionService` to use sandboxing for PTY and child process spawns.
- Update `GrepTool`, `ShellTool`, and `ToolRegistry` to execute commands via `SandboxManager`.
- Ensure consistent environment sanitization in `spawnAsync` and `execStreaming` utilities.
- Address PR review feedback and fix compilation/lint errors:
    - Respect user redaction settings in `NoopSandboxManager`.
    - Use idiomatic `async/await` in `GrepTool` availability checks.
    - Update license headers to 2026.
    - Fix cross-package build errors and exports.
    - Resolve all TypeScript and ESLint warnings/errors.
- Update `sandboxConfig.test.ts` to match new `SandboxConfig` schema.
This commit is contained in:
galz10
2026-03-09 14:57:45 -07:00
parent 863a0aa01e
commit 002a57efeb
25 changed files with 533 additions and 107 deletions
@@ -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;
}
+14
View File
@@ -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 {
@@ -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',
@@ -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,
+1
View File
@@ -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';
+4 -2
View File
@@ -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);
@@ -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;
});
});
@@ -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<ShellExecutionHandle> {
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,
});
+40 -12
View File
@@ -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<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) => {
@@ -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) {
+1
View File
@@ -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;
+22 -4
View File
@@ -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,
+1
View File
@@ -277,6 +277,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
sanitizationConfig:
shellExecutionConfig?.sanitizationConfig ??
this.config.sanitizationConfig,
sandboxManager: this.config.sandboxManager,
},
);
+52 -3
View File
@@ -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();
@@ -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 = '';
+35 -7
View File
@@ -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,
});