mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-19 02:20:42 -07:00
feat(core): implement strict macOS sandboxing using Seatbelt allowlist (#22832)
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { MacOsSandboxManager } from './MacOsSandboxManager.js';
|
||||
import { ShellExecutionService } from '../../services/shellExecutionService.js';
|
||||
import { getSecureSanitizationConfig } from '../../services/environmentSanitization.js';
|
||||
import { type SandboxedCommand } from '../../services/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';
|
||||
|
||||
/**
|
||||
* A simple asynchronous wrapper for execFile that returns the exit status,
|
||||
* stdout, and stderr. Unlike spawnSync, this does not block the Node.js
|
||||
* event loop, allowing the local HTTP test server to function.
|
||||
*/
|
||||
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 ?? '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(os.platform() !== 'darwin')(
|
||||
'MacOsSandboxManager Integration',
|
||||
() => {
|
||||
describe('Basic Execution', () => {
|
||||
it('should execute commands within the workspace', async () => {
|
||||
const manager = new MacOsSandboxManager({ workspace: process.cwd() });
|
||||
const command = await manager.prepareCommand({
|
||||
command: 'echo',
|
||||
args: ['sandbox test'],
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
const execResult = await runCommand(command);
|
||||
|
||||
expect(execResult.status).toBe(0);
|
||||
expect(execResult.stdout.trim()).toBe('sandbox test');
|
||||
});
|
||||
|
||||
it('should support interactive pseudo-terminals (node-pty)', async () => {
|
||||
const manager = new MacOsSandboxManager({ workspace: process.cwd() });
|
||||
const abortController = new AbortController();
|
||||
|
||||
// Verify that node-pty file descriptors are successfully allocated inside the sandbox
|
||||
// by using the bash [ -t 1 ] idiom to check if stdout is a TTY.
|
||||
const handle = await ShellExecutionService.execute(
|
||||
'bash -c "if [ -t 1 ]; then echo True; else echo False; fi"',
|
||||
process.cwd(),
|
||||
() => {},
|
||||
abortController.signal,
|
||||
true,
|
||||
{
|
||||
sanitizationConfig: getSecureSanitizationConfig(),
|
||||
sandboxManager: manager,
|
||||
},
|
||||
);
|
||||
|
||||
const result = await handle.result;
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.output).toContain('True');
|
||||
});
|
||||
});
|
||||
|
||||
describe('File System Access', () => {
|
||||
it('should block file system access outside the workspace', async () => {
|
||||
const manager = new MacOsSandboxManager({ workspace: process.cwd() });
|
||||
const blockedPath = '/Users/Shared/.gemini_test_sandbox_blocked';
|
||||
|
||||
const command = await manager.prepareCommand({
|
||||
command: 'touch',
|
||||
args: [blockedPath],
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
});
|
||||
const execResult = await runCommand(command);
|
||||
|
||||
expect(execResult.status).not.toBe(0);
|
||||
expect(execResult.stderr).toContain('Operation not permitted');
|
||||
});
|
||||
|
||||
it('should grant file system access to explicitly allowed paths', async () => {
|
||||
// Create a unique temporary directory to prevent artifacts and test flakiness
|
||||
const allowedDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-sandbox-test-'),
|
||||
);
|
||||
|
||||
try {
|
||||
const manager = new MacOsSandboxManager({
|
||||
workspace: process.cwd(),
|
||||
allowedPaths: [allowedDir],
|
||||
});
|
||||
const testFile = path.join(allowedDir, 'test.txt');
|
||||
|
||||
const command = await manager.prepareCommand({
|
||||
command: 'touch',
|
||||
args: [testFile],
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
const execResult = await runCommand(command);
|
||||
|
||||
expect(execResult.status).toBe(0);
|
||||
} finally {
|
||||
fs.rmSync(allowedDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Network Access', () => {
|
||||
let testServer: http.Server;
|
||||
let testServerUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testServer = http.createServer((_, res) => {
|
||||
// Ensure connections are closed immediately to prevent hanging
|
||||
res.setHeader('Connection', 'close');
|
||||
res.writeHead(200);
|
||||
res.end('ok');
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
testServer.on('error', reject);
|
||||
testServer.listen(0, '127.0.0.1', () => {
|
||||
const address = testServer.address() as import('net').AddressInfo;
|
||||
testServerUrl = `http://127.0.0.1:${address.port}`;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (testServer) {
|
||||
await new Promise<void>((resolve) => {
|
||||
testServer.close(() => resolve());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should block network access by default', async () => {
|
||||
const manager = new MacOsSandboxManager({ workspace: process.cwd() });
|
||||
const command = await manager.prepareCommand({
|
||||
command: 'curl',
|
||||
args: ['-s', '--connect-timeout', '1', testServerUrl],
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
const execResult = await runCommand(command);
|
||||
|
||||
expect(execResult.status).not.toBe(0);
|
||||
});
|
||||
|
||||
it('should grant network access when explicitly allowed', async () => {
|
||||
const manager = new MacOsSandboxManager({
|
||||
workspace: process.cwd(),
|
||||
networkAccess: true,
|
||||
});
|
||||
const command = await manager.prepareCommand({
|
||||
command: 'curl',
|
||||
args: ['-s', '--connect-timeout', '1', testServerUrl],
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
const execResult = await runCommand(command);
|
||||
|
||||
expect(execResult.status).toBe(0);
|
||||
expect(execResult.stdout.trim()).toBe('ok');
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
107
packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts
Normal file
107
packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
import { MacOsSandboxManager } from './MacOsSandboxManager.js';
|
||||
import * as seatbeltArgsBuilder from './seatbeltArgsBuilder.js';
|
||||
|
||||
describe('MacOsSandboxManager', () => {
|
||||
const mockWorkspace = '/test/workspace';
|
||||
const mockAllowedPaths = ['/test/allowed'];
|
||||
const mockNetworkAccess = true;
|
||||
|
||||
let manager: MacOsSandboxManager;
|
||||
let buildArgsSpy: MockInstance<typeof seatbeltArgsBuilder.buildSeatbeltArgs>;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new MacOsSandboxManager({
|
||||
workspace: mockWorkspace,
|
||||
allowedPaths: mockAllowedPaths,
|
||||
networkAccess: mockNetworkAccess,
|
||||
});
|
||||
|
||||
buildArgsSpy = vi
|
||||
.spyOn(seatbeltArgsBuilder, 'buildSeatbeltArgs')
|
||||
.mockReturnValue([
|
||||
'-p',
|
||||
'(mock profile)',
|
||||
'-D',
|
||||
'WORKSPACE=/test/workspace',
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should correctly invoke buildSeatbeltArgs with the configured options', async () => {
|
||||
await manager.prepareCommand({
|
||||
command: 'echo',
|
||||
args: ['hello'],
|
||||
cwd: mockWorkspace,
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(buildArgsSpy).toHaveBeenCalledWith({
|
||||
workspace: mockWorkspace,
|
||||
allowedPaths: mockAllowedPaths,
|
||||
networkAccess: mockNetworkAccess,
|
||||
});
|
||||
});
|
||||
|
||||
it('should format the executable and arguments correctly for sandbox-exec', async () => {
|
||||
const result = await manager.prepareCommand({
|
||||
command: 'echo',
|
||||
args: ['hello'],
|
||||
cwd: mockWorkspace,
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.program).toBe('/usr/bin/sandbox-exec');
|
||||
expect(result.args).toEqual([
|
||||
'-p',
|
||||
'(mock profile)',
|
||||
'-D',
|
||||
'WORKSPACE=/test/workspace',
|
||||
'--',
|
||||
'echo',
|
||||
'hello',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should correctly pass through the cwd to the resulting command', async () => {
|
||||
const result = await manager.prepareCommand({
|
||||
command: 'echo',
|
||||
args: ['hello'],
|
||||
cwd: '/test/different/cwd',
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.cwd).toBe('/test/different/cwd');
|
||||
});
|
||||
|
||||
it('should apply environment sanitization via the default mechanisms', async () => {
|
||||
const result = await manager.prepareCommand({
|
||||
command: 'echo',
|
||||
args: ['hello'],
|
||||
cwd: mockWorkspace,
|
||||
env: {
|
||||
SAFE_VAR: '1',
|
||||
GITHUB_TOKEN: 'sensitive',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.env['SAFE_VAR']).toBe('1');
|
||||
expect(result.env['GITHUB_TOKEN']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
60
packages/core/src/sandbox/macos/MacOsSandboxManager.ts
Normal file
60
packages/core/src/sandbox/macos/MacOsSandboxManager.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
type SandboxManager,
|
||||
type SandboxRequest,
|
||||
type SandboxedCommand,
|
||||
} from '../../services/sandboxManager.js';
|
||||
import {
|
||||
sanitizeEnvironment,
|
||||
getSecureSanitizationConfig,
|
||||
type EnvironmentSanitizationConfig,
|
||||
} from '../../services/environmentSanitization.js';
|
||||
import { buildSeatbeltArgs } from './seatbeltArgsBuilder.js';
|
||||
|
||||
/**
|
||||
* Options for configuring the MacOsSandboxManager.
|
||||
*/
|
||||
export interface MacOsSandboxOptions {
|
||||
/** The primary workspace path to allow access to within the sandbox. */
|
||||
workspace: string;
|
||||
/** Additional paths to allow access to within the sandbox. */
|
||||
allowedPaths?: string[];
|
||||
/** Whether network access is allowed. */
|
||||
networkAccess?: boolean;
|
||||
/** Optional base sanitization config. */
|
||||
sanitizationConfig?: EnvironmentSanitizationConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* A SandboxManager implementation for macOS that uses Seatbelt.
|
||||
*/
|
||||
export class MacOsSandboxManager implements SandboxManager {
|
||||
constructor(private readonly options: MacOsSandboxOptions) {}
|
||||
|
||||
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
|
||||
const sanitizationConfig = getSecureSanitizationConfig(
|
||||
req.config?.sanitizationConfig,
|
||||
this.options.sanitizationConfig,
|
||||
);
|
||||
|
||||
const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig);
|
||||
|
||||
const sandboxArgs = buildSeatbeltArgs({
|
||||
workspace: this.options.workspace,
|
||||
allowedPaths: this.options.allowedPaths,
|
||||
networkAccess: this.options.networkAccess,
|
||||
});
|
||||
|
||||
return {
|
||||
program: '/usr/bin/sandbox-exec',
|
||||
args: [...sandboxArgs, '--', req.command, ...req.args],
|
||||
env: sanitizedEnv,
|
||||
cwd: req.cwd,
|
||||
};
|
||||
}
|
||||
}
|
||||
94
packages/core/src/sandbox/macos/baseProfile.ts
Normal file
94
packages/core/src/sandbox/macos/baseProfile.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* The base macOS Seatbelt (SBPL) profile for tool execution.
|
||||
*
|
||||
* This uses a strict allowlist (deny default) but imports Apple's base system profile
|
||||
* to handle undocumented internal dependencies, sysctls, and IPC mach ports required
|
||||
* by standard tools to avoid "Abort trap: 6".
|
||||
*/
|
||||
export const BASE_SEATBELT_PROFILE = `(version 1)
|
||||
(deny default)
|
||||
|
||||
(import "system.sb")
|
||||
|
||||
; Core execution requirements
|
||||
(allow process-exec)
|
||||
(allow process-fork)
|
||||
(allow signal (target same-sandbox))
|
||||
(allow process-info* (target same-sandbox))
|
||||
|
||||
; Allow basic read access to system frameworks and libraries required to run
|
||||
(allow file-read*
|
||||
(subpath "/System")
|
||||
(subpath "/usr/lib")
|
||||
(subpath "/usr/share")
|
||||
(subpath "/usr/bin")
|
||||
(subpath "/bin")
|
||||
(subpath "/sbin")
|
||||
(subpath "/usr/local/bin")
|
||||
(subpath "/opt/homebrew")
|
||||
(subpath "/Library")
|
||||
(subpath "/private/var/run")
|
||||
(subpath "/private/var/db")
|
||||
(subpath "/private/etc")
|
||||
)
|
||||
|
||||
; PTY and Terminal support
|
||||
(allow pseudo-tty)
|
||||
(allow file-read* file-write* file-ioctl (literal "/dev/ptmx"))
|
||||
(allow file-read* file-write* file-ioctl (regex #"^/dev/ttys[0-9]+"))
|
||||
|
||||
; Allow read/write access to temporary directories and common device nodes
|
||||
(allow file-read* file-write*
|
||||
(literal "/dev/null")
|
||||
(literal "/dev/zero")
|
||||
(subpath "/tmp")
|
||||
(subpath "/private/tmp")
|
||||
(subpath (param "TMPDIR"))
|
||||
)
|
||||
|
||||
; Workspace access using parameterized paths
|
||||
(allow file-read* file-write*
|
||||
(subpath (param "WORKSPACE"))
|
||||
)
|
||||
`;
|
||||
|
||||
/**
|
||||
* The network-specific macOS Seatbelt (SBPL) profile rules.
|
||||
*
|
||||
* These rules are appended to the base profile when network access is enabled,
|
||||
* allowing standard socket creation, DNS resolution, and TLS certificate validation.
|
||||
*/
|
||||
export const NETWORK_SEATBELT_PROFILE = `
|
||||
; Network Access
|
||||
(allow network*)
|
||||
|
||||
(allow system-socket
|
||||
(require-all
|
||||
(socket-domain AF_SYSTEM)
|
||||
(socket-protocol 2)
|
||||
)
|
||||
)
|
||||
|
||||
(allow mach-lookup
|
||||
(global-name "com.apple.bsd.dirhelper")
|
||||
(global-name "com.apple.system.opendirectoryd.membership")
|
||||
(global-name "com.apple.SecurityServer")
|
||||
(global-name "com.apple.networkd")
|
||||
(global-name "com.apple.ocspd")
|
||||
(global-name "com.apple.trustd.agent")
|
||||
(global-name "com.apple.mDNSResponder")
|
||||
(global-name "com.apple.mDNSResponderHelper")
|
||||
(global-name "com.apple.SystemConfiguration.DNSConfiguration")
|
||||
(global-name "com.apple.SystemConfiguration.configd")
|
||||
)
|
||||
|
||||
(allow sysctl-read
|
||||
(sysctl-name-regex #"^net.routetable")
|
||||
)
|
||||
`;
|
||||
97
packages/core/src/sandbox/macos/seatbeltArgsBuilder.test.ts
Normal file
97
packages/core/src/sandbox/macos/seatbeltArgsBuilder.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { buildSeatbeltArgs } from './seatbeltArgsBuilder.js';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
|
||||
describe('seatbeltArgsBuilder', () => {
|
||||
it('should build a strict allowlist profile allowing the workspace via param', () => {
|
||||
// Mock realpathSync to just return the path for testing
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => p as string);
|
||||
|
||||
const args = buildSeatbeltArgs({ workspace: '/Users/test/workspace' });
|
||||
|
||||
expect(args[0]).toBe('-p');
|
||||
const profile = args[1];
|
||||
expect(profile).toContain('(version 1)');
|
||||
expect(profile).toContain('(deny default)');
|
||||
expect(profile).toContain('(allow process-exec)');
|
||||
expect(profile).toContain('(subpath (param "WORKSPACE"))');
|
||||
expect(profile).not.toContain('(allow network*)');
|
||||
|
||||
expect(args).toContain('-D');
|
||||
expect(args).toContain('WORKSPACE=/Users/test/workspace');
|
||||
expect(args).toContain(`TMPDIR=${os.tmpdir()}`);
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should allow network when networkAccess is true', () => {
|
||||
const args = buildSeatbeltArgs({ workspace: '/test', networkAccess: true });
|
||||
const profile = args[1];
|
||||
expect(profile).toContain('(allow network*)');
|
||||
});
|
||||
|
||||
it('should parameterize allowed paths and normalize them', () => {
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => {
|
||||
if (p === '/test/symlink') return '/test/real_path';
|
||||
return p as string;
|
||||
});
|
||||
|
||||
const args = buildSeatbeltArgs({
|
||||
workspace: '/test',
|
||||
allowedPaths: ['/custom/path1', '/test/symlink'],
|
||||
});
|
||||
|
||||
const profile = args[1];
|
||||
expect(profile).toContain('(subpath (param "ALLOWED_PATH_0"))');
|
||||
expect(profile).toContain('(subpath (param "ALLOWED_PATH_1"))');
|
||||
|
||||
expect(args).toContain('-D');
|
||||
expect(args).toContain('ALLOWED_PATH_0=/custom/path1');
|
||||
expect(args).toContain('ALLOWED_PATH_1=/test/real_path');
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should resolve parent directories if a file does not exist', () => {
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => {
|
||||
if (p === '/test/symlink/nonexistent.txt') {
|
||||
const error = new Error('ENOENT');
|
||||
Object.assign(error, { code: 'ENOENT' });
|
||||
throw error;
|
||||
}
|
||||
if (p === '/test/symlink') {
|
||||
return '/test/real_path';
|
||||
}
|
||||
return p as string;
|
||||
});
|
||||
|
||||
const args = buildSeatbeltArgs({
|
||||
workspace: '/test/symlink/nonexistent.txt',
|
||||
});
|
||||
|
||||
expect(args).toContain('WORKSPACE=/test/real_path/nonexistent.txt');
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should throw if realpathSync throws a non-ENOENT error', () => {
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation(() => {
|
||||
const error = new Error('Permission denied');
|
||||
Object.assign(error, { code: 'EACCES' });
|
||||
throw error;
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
buildSeatbeltArgs({
|
||||
workspace: '/test/workspace',
|
||||
}),
|
||||
).toThrow('Permission denied');
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
80
packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts
Normal file
80
packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
BASE_SEATBELT_PROFILE,
|
||||
NETWORK_SEATBELT_PROFILE,
|
||||
} from './baseProfile.js';
|
||||
|
||||
/**
|
||||
* Options for building macOS Seatbelt arguments.
|
||||
*/
|
||||
export interface SeatbeltArgsOptions {
|
||||
/** The primary workspace path to allow access to. */
|
||||
workspace: string;
|
||||
/** Additional paths to allow access to. */
|
||||
allowedPaths?: string[];
|
||||
/** Whether to allow network access. */
|
||||
networkAccess?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves symlinks for a given path to prevent sandbox escapes.
|
||||
* If a file does not exist (ENOENT), it recursively resolves the parent directory.
|
||||
* Other errors (e.g. EACCES) are re-thrown.
|
||||
*/
|
||||
function tryRealpath(p: string): string {
|
||||
try {
|
||||
return fs.realpathSync(p);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
|
||||
const parentDir = path.dirname(p);
|
||||
if (parentDir === p) {
|
||||
return p;
|
||||
}
|
||||
return path.join(tryRealpath(parentDir), path.basename(p));
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the arguments array for sandbox-exec using a strict allowlist profile.
|
||||
* It relies on parameters passed to sandbox-exec via the -D flag to avoid
|
||||
* string interpolation vulnerabilities, and normalizes paths against symlink escapes.
|
||||
*
|
||||
* Returns arguments up to the end of sandbox-exec configuration (e.g. ['-p', '<profile>', '-D', ...])
|
||||
* Does not include the final '--' separator or the command to run.
|
||||
*/
|
||||
export function buildSeatbeltArgs(options: SeatbeltArgsOptions): string[] {
|
||||
let profile = BASE_SEATBELT_PROFILE + '\n';
|
||||
const args: string[] = [];
|
||||
|
||||
const workspacePath = tryRealpath(options.workspace);
|
||||
args.push('-D', `WORKSPACE=${workspacePath}`);
|
||||
|
||||
const tmpPath = tryRealpath(os.tmpdir());
|
||||
args.push('-D', `TMPDIR=${tmpPath}`);
|
||||
|
||||
if (options.allowedPaths) {
|
||||
for (let i = 0; i < options.allowedPaths.length; i++) {
|
||||
const allowedPath = tryRealpath(options.allowedPaths[i]);
|
||||
args.push('-D', `ALLOWED_PATH_${i}=${allowedPath}`);
|
||||
profile += `(allow file-read* file-write* (subpath (param "ALLOWED_PATH_${i}")))\n`;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.networkAccess) {
|
||||
profile += NETWORK_SEATBELT_PROFILE;
|
||||
}
|
||||
|
||||
args.unshift('-p', profile);
|
||||
|
||||
return args;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
createSandboxManager,
|
||||
} from './sandboxManager.js';
|
||||
import { LinuxSandboxManager } from '../sandbox/linux/LinuxSandboxManager.js';
|
||||
import { MacOsSandboxManager } from '../sandbox/macos/MacOsSandboxManager.js';
|
||||
|
||||
describe('NoopSandboxManager', () => {
|
||||
const sandboxManager = new NoopSandboxManager();
|
||||
@@ -124,23 +125,20 @@ describe('createSandboxManager', () => {
|
||||
expect(manager).toBeInstanceOf(NoopSandboxManager);
|
||||
});
|
||||
|
||||
it('should return LinuxSandboxManager if sandboxing is enabled and platform is linux', () => {
|
||||
const osSpy = vi.spyOn(os, 'platform').mockReturnValue('linux');
|
||||
try {
|
||||
const manager = createSandboxManager(true, '/workspace');
|
||||
expect(manager).toBeInstanceOf(LinuxSandboxManager);
|
||||
} finally {
|
||||
osSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('should return LocalSandboxManager if sandboxing is enabled and platform is not linux', () => {
|
||||
const osSpy = vi.spyOn(os, 'platform').mockReturnValue('darwin');
|
||||
try {
|
||||
const manager = createSandboxManager(true, '/workspace');
|
||||
expect(manager).toBeInstanceOf(LocalSandboxManager);
|
||||
} finally {
|
||||
osSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
it.each([
|
||||
{ platform: 'linux', expected: LinuxSandboxManager },
|
||||
{ platform: 'darwin', expected: MacOsSandboxManager },
|
||||
{ platform: 'win32', expected: LocalSandboxManager },
|
||||
] as const)(
|
||||
'should return $expected.name if sandboxing is enabled and platform is $platform',
|
||||
({ platform, expected }) => {
|
||||
const osSpy = vi.spyOn(os, 'platform').mockReturnValue(platform);
|
||||
try {
|
||||
const manager = createSandboxManager(true, '/workspace');
|
||||
expect(manager).toBeInstanceOf(expected);
|
||||
} finally {
|
||||
osSpy.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type EnvironmentSanitizationConfig,
|
||||
} from './environmentSanitization.js';
|
||||
import { LinuxSandboxManager } from '../sandbox/linux/LinuxSandboxManager.js';
|
||||
import { MacOsSandboxManager } from '../sandbox/macos/MacOsSandboxManager.js';
|
||||
|
||||
/**
|
||||
* Request for preparing a command to run in a sandbox.
|
||||
@@ -98,6 +99,9 @@ export function createSandboxManager(
|
||||
if (os.platform() === 'linux') {
|
||||
return new LinuxSandboxManager({ workspace });
|
||||
}
|
||||
if (os.platform() === 'darwin') {
|
||||
return new MacOsSandboxManager({ workspace });
|
||||
}
|
||||
return new LocalSandboxManager();
|
||||
}
|
||||
return new NoopSandboxManager();
|
||||
|
||||
Reference in New Issue
Block a user