feat(core): refactor SandboxManager to a stateless architecture and introduce explicit Deny interface (#23141)

This commit is contained in:
Emily Hedlund
2026-03-23 11:43:58 -04:00
committed by GitHub
parent 99e5164c82
commit cdf077da56
13 changed files with 444 additions and 388 deletions

View File

@@ -4,24 +4,20 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { describe, it, expect, beforeEach } from 'vitest';
import { LinuxSandboxManager } from './LinuxSandboxManager.js';
import type { SandboxRequest } from '../../services/sandboxManager.js';
describe('LinuxSandboxManager', () => {
const workspace = '/home/user/workspace';
let manager: LinuxSandboxManager;
it('correctly outputs bwrap as the program with appropriate isolation flags', async () => {
const manager = new LinuxSandboxManager({ workspace });
const req: SandboxRequest = {
command: 'ls',
args: ['-la'],
cwd: workspace,
env: {},
};
beforeEach(() => {
manager = new LinuxSandboxManager({ workspace });
});
const getBwrapArgs = async (req: SandboxRequest) => {
const result = await manager.prepareCommand(req);
expect(result.program).toBe('sh');
expect(result.args[0]).toBe('-c');
expect(result.args[1]).toBe(
@@ -29,8 +25,17 @@ describe('LinuxSandboxManager', () => {
);
expect(result.args[2]).toBe('_');
expect(result.args[3]).toMatch(/gemini-cli-seccomp-.*\.bpf$/);
return result.args.slice(4);
};
it('correctly outputs bwrap as the program with appropriate isolation flags', async () => {
const bwrapArgs = await getBwrapArgs({
command: 'ls',
args: ['-la'],
cwd: workspace,
env: {},
});
const bwrapArgs = result.args.slice(4);
expect(bwrapArgs).toEqual([
'--unshare-all',
'--new-session',
@@ -56,55 +61,48 @@ describe('LinuxSandboxManager', () => {
});
it('maps allowedPaths to bwrap binds', async () => {
const manager = new LinuxSandboxManager({
workspace,
allowedPaths: ['/tmp/cache', '/opt/tools', workspace],
});
const req: SandboxRequest = {
const bwrapArgs = await getBwrapArgs({
command: 'node',
args: ['script.js'],
cwd: workspace,
env: {},
};
policy: {
allowedPaths: ['/tmp/cache', '/opt/tools', workspace],
},
});
const result = await manager.prepareCommand(req);
// Verify the specific bindings were added correctly
const bindsIndex = bwrapArgs.indexOf('--seccomp');
const binds = bwrapArgs.slice(bwrapArgs.indexOf('--bind'), bindsIndex);
expect(result.program).toBe('sh');
expect(result.args[0]).toBe('-c');
expect(result.args[1]).toBe(
'bpf_path="$1"; shift; exec bwrap "$@" 9< "$bpf_path"',
);
expect(result.args[2]).toBe('_');
expect(result.args[3]).toMatch(/gemini-cli-seccomp-.*\.bpf$/);
const bwrapArgs = result.args.slice(4);
expect(bwrapArgs).toEqual([
'--unshare-all',
'--new-session',
'--die-with-parent',
'--ro-bind',
'/',
'/',
'--dev',
'/dev',
'--proc',
'/proc',
'--tmpfs',
'/tmp',
expect(binds).toEqual([
'--bind',
workspace,
workspace,
'--bind',
'--bind-try',
'/tmp/cache',
'/tmp/cache',
'--bind',
'--bind-try',
'/opt/tools',
'/opt/tools',
'--seccomp',
'9',
'--',
'node',
'script.js',
]);
});
it('should not bind the workspace twice even if it has a trailing slash in allowedPaths', async () => {
const bwrapArgs = await getBwrapArgs({
command: 'ls',
args: ['-la'],
cwd: workspace,
env: {},
policy: {
allowedPaths: [workspace + '/'],
},
});
const bindsIndex = bwrapArgs.indexOf('--seccomp');
const binds = bwrapArgs.slice(bwrapArgs.indexOf('--bind'), bindsIndex);
// Should only contain the primary workspace bind, not the second one with a trailing slash
expect(binds).toEqual(['--bind', workspace, workspace]);
});
});

View File

@@ -4,18 +4,19 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { join } from 'node:path';
import { join, normalize } from 'node:path';
import { writeFileSync } from 'node:fs';
import os from 'node:os';
import {
type SandboxManager,
type GlobalSandboxOptions,
type SandboxRequest,
type SandboxedCommand,
sanitizePaths,
} from '../../services/sandboxManager.js';
import {
sanitizeEnvironment,
getSecureSanitizationConfig,
type EnvironmentSanitizationConfig,
} from '../../services/environmentSanitization.js';
let cachedBpfPath: string | undefined;
@@ -76,28 +77,15 @@ function getSeccompBpfPath(): string {
return bpfPath;
}
/**
* Options for configuring the LinuxSandboxManager.
*/
export interface LinuxSandboxOptions {
/** The primary workspace path to bind into the sandbox. */
workspace: string;
/** Additional paths to bind into the sandbox. */
allowedPaths?: string[];
/** Optional base sanitization config. */
sanitizationConfig?: EnvironmentSanitizationConfig;
}
/**
* A SandboxManager implementation for Linux that uses Bubblewrap (bwrap).
*/
export class LinuxSandboxManager implements SandboxManager {
constructor(private readonly options: LinuxSandboxOptions) {}
constructor(private readonly options: GlobalSandboxOptions) {}
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
const sanitizationConfig = getSecureSanitizationConfig(
req.config?.sanitizationConfig,
this.options.sanitizationConfig,
req.policy?.sanitizationConfig,
);
const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig);
@@ -121,13 +109,20 @@ export class LinuxSandboxManager implements SandboxManager {
this.options.workspace,
];
const allowedPaths = this.options.allowedPaths ?? [];
for (const path of allowedPaths) {
if (path !== this.options.workspace) {
bwrapArgs.push('--bind', path, path);
const allowedPaths = sanitizePaths(req.policy?.allowedPaths) || [];
const normalizedWorkspace = normalize(this.options.workspace).replace(
/\/$/,
'',
);
for (const allowedPath of allowedPaths) {
const normalizedAllowedPath = normalize(allowedPath).replace(/\/$/, '');
if (normalizedAllowedPath !== normalizedWorkspace) {
bwrapArgs.push('--bind-try', allowedPath, allowedPath);
}
}
// TODO: handle forbidden paths
const bpfPath = getSeccompBpfPath();
bwrapArgs.push('--seccomp', '9');