Files
gemini-cli/packages/core/src/services/sandboxManager.integration.test.ts

666 lines
20 KiB
TypeScript

/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createSandboxManager } from './sandboxManagerFactory.js';
import { ShellExecutionService } from './shellExecutionService.js';
import { getSecureSanitizationConfig } from './environmentSanitization.js';
import {
type SandboxManager,
type SandboxedCommand,
} from './sandboxManager.js';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import os from 'node:os';
import fs from 'node:fs';
import path from 'node:path';
import http from 'node:http';
/**
* Cross-platform command wrappers using Node.js inline scripts.
* Ensures consistent execution behavior and reliable exit codes across
* different host operating systems and restricted sandbox environments.
*/
const Platform = {
isWindows: os.platform() === 'win32',
isMac: os.platform() === 'darwin',
/** Returns a command to create an empty file. */
touch(filePath: string) {
return {
command: process.execPath,
args: [
'-e',
`require("node:fs").writeFileSync(${JSON.stringify(filePath)}, "")`,
],
};
},
/** Returns a command to read a file's content. */
cat(filePath: string) {
return {
command: process.execPath,
args: [
'-e',
`console.log(require("node:fs").readFileSync(${JSON.stringify(filePath)}, "utf8"))`,
],
};
},
/** Returns a command to echo a string. */
echo(text: string) {
return {
command: process.execPath,
args: ['-e', `console.log(${JSON.stringify(text)})`],
};
},
/** Returns a command to perform a network request. */
curl(url: string) {
return {
command: process.execPath,
args: [
'-e',
`require("node:http").get(${JSON.stringify(url)}, (res) => { res.on("data", (d) => process.stdout.write(d)); res.on("end", () => process.exit(0)); }).on("error", () => process.exit(1));`,
],
};
},
/** Returns a command that checks if the current terminal is interactive. */
isPty() {
// ShellExecutionService.execute expects a raw shell string
return `"${process.execPath}" -e "console.log(process.stdout.isTTY ? 'True' : 'False')"`;
},
/** Returns a path that is strictly outside the workspace and likely blocked. */
getExternalBlockedPath() {
return this.isWindows
? 'C:\\Windows\\System32\\drivers\\etc\\hosts'
: '/Users/Shared/.gemini_test_blocked';
},
};
async function runCommand(command: SandboxedCommand) {
try {
const { stdout, stderr } = await promisify(execFile)(
command.program,
command.args,
{
cwd: command.cwd,
env: command.env,
encoding: 'utf-8',
},
);
return { status: 0, stdout, stderr };
} catch (error: unknown) {
const err = error as { code?: number; stdout?: string; stderr?: string };
return {
status: err.code ?? 1,
stdout: err.stdout ?? '',
stderr: err.stderr ?? '',
};
}
}
/**
* Asserts the result of a sandboxed command execution, and provides detailed
* diagnostics on failure.
*/
function assertResult(
result: { status: number; stdout: string; stderr: string },
command: SandboxedCommand,
expected: 'success' | 'failure',
) {
const isSuccess = result.status === 0;
const shouldBeSuccess = expected === 'success';
if (isSuccess === shouldBeSuccess) {
if (shouldBeSuccess) {
expect(result.status).toBe(0);
} else {
expect(result.status).not.toBe(0);
}
return;
}
const commandLine = `${command.program} ${command.args.join(' ')}`;
const message = `Command ${
shouldBeSuccess ? 'failed' : 'succeeded'
} unexpectedly.
Command: ${commandLine}
CWD: ${command.cwd || 'N/A'}
Status: ${result.status} (expected ${expected})${
result.stdout ? `\nStdout: ${result.stdout.trim()}` : ''
}${result.stderr ? `\nStderr: ${result.stderr.trim()}` : ''}`;
throw new Error(message);
}
describe('SandboxManager Integration', () => {
const tempDirectories: string[] = [];
/**
* Creates a temporary directory.
* - macOS: Created in process.cwd() to avoid the seatbelt profile's global os.tmpdir() whitelist.
* - Win/Linux: Created in os.tmpdir() because enforcing sandbox restrictions inside a large directory can be very slow.
*/
function createTempDir(prefix = 'gemini-sandbox-test-'): string {
const baseDir = Platform.isMac
? path.join(process.cwd(), `.${prefix}`)
: path.join(os.tmpdir(), prefix);
const dir = fs.mkdtempSync(baseDir);
tempDirectories.push(dir);
return dir;
}
let workspace: string;
let manager: SandboxManager;
beforeAll(() => {
workspace = createTempDir('workspace-');
manager = createSandboxManager({ enabled: true }, { workspace });
});
afterAll(() => {
for (const dir of tempDirectories) {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
// Best-effort cleanup
}
}
});
describe('Basic Execution', () => {
it('executes commands within the workspace', async () => {
const { command, args } = Platform.echo('sandbox test');
const sandboxed = await manager.prepareCommand({
command,
args,
cwd: workspace,
env: process.env,
});
const result = await runCommand(sandboxed);
assertResult(result, sandboxed, 'success');
expect(result.stdout.trim()).toBe('sandbox test');
});
// The Windows sandbox wrapper (GeminiSandbox.exe) uses standard pipes
// for I/O interception, which breaks ConPTY pseudo-terminal inheritance.
it.skipIf(Platform.isWindows)(
'supports interactive pseudo-terminals (node-pty)',
async () => {
const handle = await ShellExecutionService.execute(
Platform.isPty(),
workspace,
() => {},
new AbortController().signal,
true,
{
sanitizationConfig: getSecureSanitizationConfig(),
sandboxManager: manager,
},
);
const result = await handle.result;
expect(result.exitCode).toBe(0);
expect(result.output).toContain('True');
},
);
});
describe('File System Access', () => {
it('blocks access outside the workspace', async () => {
const blockedPath = Platform.getExternalBlockedPath();
const { command, args } = Platform.touch(blockedPath);
const sandboxed = await manager.prepareCommand({
command,
args,
cwd: workspace,
env: process.env,
});
const result = await runCommand(sandboxed);
assertResult(result, sandboxed, 'failure');
});
it('allows dynamic expansion of permissions after a failure', async () => {
const tempDir = createTempDir('expansion-');
const testFile = path.join(tempDir, 'test.txt');
const { command, args } = Platform.touch(testFile);
// First attempt: fails due to sandbox restrictions
const sandboxed1 = await manager.prepareCommand({
command,
args,
cwd: workspace,
env: process.env,
});
const result1 = await runCommand(sandboxed1);
assertResult(result1, sandboxed1, 'failure');
expect(fs.existsSync(testFile)).toBe(false);
// Second attempt: succeeds with additional permissions
const sandboxed2 = await manager.prepareCommand({
command,
args,
cwd: workspace,
env: process.env,
policy: { allowedPaths: [tempDir] },
});
const result2 = await runCommand(sandboxed2);
assertResult(result2, sandboxed2, 'success');
expect(fs.existsSync(testFile)).toBe(true);
});
it('grants access to explicitly allowed paths', async () => {
const allowedDir = createTempDir('allowed-');
const testFile = path.join(allowedDir, 'test.txt');
const { command, args } = Platform.touch(testFile);
const sandboxed = await manager.prepareCommand({
command,
args,
cwd: workspace,
env: process.env,
policy: { allowedPaths: [allowedDir] },
});
const result = await runCommand(sandboxed);
assertResult(result, sandboxed, 'success');
expect(fs.existsSync(testFile)).toBe(true);
});
it('blocks write access to forbidden paths within the workspace', async () => {
const tempWorkspace = createTempDir('workspace-');
const forbiddenDir = path.join(tempWorkspace, 'forbidden');
const testFile = path.join(forbiddenDir, 'test.txt');
fs.mkdirSync(forbiddenDir);
const osManager = createSandboxManager(
{ enabled: true },
{
workspace: tempWorkspace,
forbiddenPaths: async () => [forbiddenDir],
},
);
const { command, args } = Platform.touch(testFile);
const sandboxed = await osManager.prepareCommand({
command,
args,
cwd: tempWorkspace,
env: process.env,
});
const result = await runCommand(sandboxed);
assertResult(result, sandboxed, 'failure');
});
// Windows icacls does not reliably block read-up access for Low Integrity
// processes, so we skip read-specific assertions on Windows. The internal
// tool architecture prevents read bypasses via the C# wrapper and __read.
it.skipIf(Platform.isWindows)(
'blocks read access to forbidden paths within the workspace',
async () => {
const tempWorkspace = createTempDir('workspace-');
const forbiddenDir = path.join(tempWorkspace, 'forbidden');
const testFile = path.join(forbiddenDir, 'test.txt');
fs.mkdirSync(forbiddenDir);
fs.writeFileSync(testFile, 'secret data');
const osManager = createSandboxManager(
{ enabled: true },
{
workspace: tempWorkspace,
forbiddenPaths: async () => [forbiddenDir],
},
);
const { command, args } = Platform.cat(testFile);
const sandboxed = await osManager.prepareCommand({
command,
args,
cwd: tempWorkspace,
env: process.env,
});
const result = await runCommand(sandboxed);
assertResult(result, sandboxed, 'failure');
},
);
it('blocks access to files inside forbidden directories recursively', async () => {
const tempWorkspace = createTempDir('workspace-');
const forbiddenDir = path.join(tempWorkspace, 'forbidden');
const nestedDir = path.join(forbiddenDir, 'nested');
const nestedFile = path.join(nestedDir, 'test.txt');
// Create the base forbidden directory first so the manager can restrict access to it.
fs.mkdirSync(forbiddenDir);
const osManager = createSandboxManager(
{ enabled: true },
{
workspace: tempWorkspace,
forbiddenPaths: async () => [forbiddenDir],
},
);
// Execute a dummy command so the manager initializes its restrictions.
const dummyCommand = await osManager.prepareCommand({
...Platform.echo('init'),
cwd: tempWorkspace,
env: process.env,
});
await runCommand(dummyCommand);
// Now create the nested items. They will inherit the sandbox restrictions from their parent.
fs.mkdirSync(nestedDir, { recursive: true });
fs.writeFileSync(nestedFile, 'secret');
const { command, args } = Platform.touch(nestedFile);
const sandboxed = await osManager.prepareCommand({
command,
args,
cwd: tempWorkspace,
env: process.env,
});
const result = await runCommand(sandboxed);
assertResult(result, sandboxed, 'failure');
});
it('prioritizes forbiddenPaths over allowedPaths', async () => {
const tempWorkspace = createTempDir('workspace-');
const conflictDir = path.join(tempWorkspace, 'conflict');
const testFile = path.join(conflictDir, 'test.txt');
fs.mkdirSync(conflictDir);
const osManager = createSandboxManager(
{ enabled: true },
{
workspace: tempWorkspace,
forbiddenPaths: async () => [conflictDir],
},
);
const { command, args } = Platform.touch(testFile);
const sandboxed = await osManager.prepareCommand({
command,
args,
cwd: tempWorkspace,
env: process.env,
policy: {
allowedPaths: [conflictDir],
},
});
const result = await runCommand(sandboxed);
assertResult(result, sandboxed, 'failure');
});
it('gracefully ignores non-existent paths in allowedPaths and forbiddenPaths', async () => {
const tempWorkspace = createTempDir('workspace-');
const nonExistentPath = path.join(tempWorkspace, 'does-not-exist');
const osManager = createSandboxManager(
{ enabled: true },
{
workspace: tempWorkspace,
forbiddenPaths: async () => [nonExistentPath],
},
);
const { command, args } = Platform.echo('survived');
const sandboxed = await osManager.prepareCommand({
command,
args,
cwd: tempWorkspace,
env: process.env,
policy: {
allowedPaths: [nonExistentPath],
},
});
const result = await runCommand(sandboxed);
assertResult(result, sandboxed, 'success');
expect(result.stdout.trim()).toBe('survived');
});
it('prevents creation of non-existent forbidden paths', async () => {
const tempWorkspace = createTempDir('workspace-');
const nonExistentFile = path.join(tempWorkspace, 'never-created.txt');
const osManager = createSandboxManager(
{ enabled: true },
{
workspace: tempWorkspace,
forbiddenPaths: async () => [nonExistentFile],
},
);
// We use touch to attempt creation of the file
const { command: cmdTouch, args: argsTouch } =
Platform.touch(nonExistentFile);
const sandboxedCmd = await osManager.prepareCommand({
command: cmdTouch,
args: argsTouch,
cwd: tempWorkspace,
env: process.env,
});
// Execute the command, we expect it to fail (permission denied or read-only file system)
const result = await runCommand(sandboxedCmd);
assertResult(result, sandboxedCmd, 'failure');
expect(fs.existsSync(nonExistentFile)).toBe(false);
});
it('blocks access to both a symlink and its target when the symlink is forbidden', async () => {
const tempWorkspace = createTempDir('workspace-');
const targetFile = path.join(tempWorkspace, 'target.txt');
const symlinkFile = path.join(tempWorkspace, 'link.txt');
fs.writeFileSync(targetFile, 'secret data');
fs.symlinkSync(targetFile, symlinkFile);
const osManager = createSandboxManager(
{ enabled: true },
{
workspace: tempWorkspace,
forbiddenPaths: async () => [symlinkFile],
},
);
// Attempt to write to the target file directly
const { command: cmdTarget, args: argsTarget } =
Platform.touch(targetFile);
const commandTarget = await osManager.prepareCommand({
command: cmdTarget,
args: argsTarget,
cwd: tempWorkspace,
env: process.env,
});
const resultTarget = await runCommand(commandTarget);
assertResult(resultTarget, commandTarget, 'failure');
// Attempt to write via the symlink
const { command: cmdLink, args: argsLink } = Platform.touch(symlinkFile);
const commandLink = await osManager.prepareCommand({
command: cmdLink,
args: argsLink,
cwd: tempWorkspace,
env: process.env,
});
const resultLink = await runCommand(commandLink);
assertResult(resultLink, commandLink, 'failure');
});
});
describe('Git Worktree Support', () => {
it('allows access to git common directory in a worktree', async () => {
const mainRepo = createTempDir('main-repo-');
const worktreeDir = createTempDir('worktree-');
const mainGitDir = path.join(mainRepo, '.git');
fs.mkdirSync(mainGitDir, { recursive: true });
fs.writeFileSync(
path.join(mainGitDir, 'config'),
'[core]\n\trepositoryformatversion = 0\n',
);
const worktreeGitDir = path.join(
mainGitDir,
'worktrees',
'test-worktree',
);
fs.mkdirSync(worktreeGitDir, { recursive: true });
// Create the .git file in the worktree directory pointing to the worktree git dir
fs.writeFileSync(
path.join(worktreeDir, '.git'),
`gitdir: ${worktreeGitDir}\n`,
);
// Create the backlink from worktree git dir to the worktree's .git file
const backlinkPath = path.join(worktreeGitDir, 'gitdir');
fs.writeFileSync(backlinkPath, path.join(worktreeDir, '.git'));
// Create a file in the worktree git dir that we want to access
const secretFile = path.join(worktreeGitDir, 'secret.txt');
fs.writeFileSync(secretFile, 'git-secret');
const osManager = createSandboxManager(
{ enabled: true },
{ workspace: worktreeDir },
);
const { command, args } = Platform.cat(secretFile);
const sandboxed = await osManager.prepareCommand({
command,
args,
cwd: worktreeDir,
env: process.env,
});
const result = await runCommand(sandboxed);
assertResult(result, sandboxed, 'success');
expect(result.stdout.trim()).toBe('git-secret');
});
it('blocks write access to git common directory in a worktree', async () => {
const mainRepo = createTempDir('main-repo-');
const worktreeDir = createTempDir('worktree-');
const mainGitDir = path.join(mainRepo, '.git');
fs.mkdirSync(mainGitDir, { recursive: true });
const worktreeGitDir = path.join(
mainGitDir,
'worktrees',
'test-worktree',
);
fs.mkdirSync(worktreeGitDir, { recursive: true });
fs.writeFileSync(
path.join(worktreeDir, '.git'),
`gitdir: ${worktreeGitDir}\n`,
);
fs.writeFileSync(
path.join(worktreeGitDir, 'gitdir'),
path.join(worktreeDir, '.git'),
);
const targetFile = path.join(worktreeGitDir, 'secret.txt');
const osManager = createSandboxManager(
{ enabled: true },
// Use YOLO mode to ensure the workspace is fully writable, but git worktrees should still be read-only
{ workspace: worktreeDir, modeConfig: { yolo: true } },
);
const { command, args } = Platform.touch(targetFile);
const sandboxed = await osManager.prepareCommand({
command,
args,
cwd: worktreeDir,
env: process.env,
});
const result = await runCommand(sandboxed);
assertResult(result, sandboxed, 'failure');
expect(fs.existsSync(targetFile)).toBe(false);
});
});
describe('Network Access', () => {
let server: http.Server;
let url: string;
beforeAll(async () => {
server = http.createServer((_, res) => {
res.setHeader('Connection', 'close');
res.writeHead(200);
res.end('ok');
});
await new Promise<void>((resolve, reject) => {
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const addr = server.address() as import('net').AddressInfo;
url = `http://127.0.0.1:${addr.port}`;
resolve();
});
});
});
afterAll(async () => {
if (server) await new Promise<void>((res) => server.close(() => res()));
});
// Windows Job Object rate limits exempt loopback (127.0.0.1) traffic,
// so this test cannot verify loopback blocking on Windows.
it.skipIf(Platform.isWindows)(
'blocks network access by default',
async () => {
const { command, args } = Platform.curl(url);
const sandboxed = await manager.prepareCommand({
command,
args,
cwd: workspace,
env: process.env,
});
const result = await runCommand(sandboxed);
assertResult(result, sandboxed, 'failure');
},
);
it('grants network access when explicitly allowed', async () => {
const { command, args } = Platform.curl(url);
const sandboxed = await manager.prepareCommand({
command,
args,
cwd: workspace,
env: process.env,
policy: { networkAccess: true },
});
const result = await runCommand(sandboxed);
assertResult(result, sandboxed, 'success');
if (!Platform.isWindows) {
expect(result.stdout.trim()).toBe('ok');
}
});
});
});