feat(core): implement strict macOS sandboxing using Seatbelt allowlist (#22832)

This commit is contained in:
Emily Hedlund
2026-03-18 16:07:54 -04:00
committed by GitHub
parent 1725ec346b
commit f6e21f50fd
8 changed files with 661 additions and 19 deletions

View File

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

View 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();
});
});

View 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,
};
}
}

View 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")
)
`;

View 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();
});
});

View 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;
}

View File

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

View File

@@ -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();