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
+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,
});