diff --git a/packages/core/src/sandbox/macos/MacOsSandboxManager.integration.test.ts b/packages/core/src/sandbox/macos/MacOsSandboxManager.integration.test.ts new file mode 100644 index 0000000000..d9776bc715 --- /dev/null +++ b/packages/core/src/sandbox/macos/MacOsSandboxManager.integration.test.ts @@ -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((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((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'); + }); + }); + }, +); diff --git a/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts b/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts new file mode 100644 index 0000000000..69946daade --- /dev/null +++ b/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts @@ -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; + + 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(); + }); +}); diff --git a/packages/core/src/sandbox/macos/MacOsSandboxManager.ts b/packages/core/src/sandbox/macos/MacOsSandboxManager.ts new file mode 100644 index 0000000000..a212b310b2 --- /dev/null +++ b/packages/core/src/sandbox/macos/MacOsSandboxManager.ts @@ -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 { + 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, + }; + } +} diff --git a/packages/core/src/sandbox/macos/baseProfile.ts b/packages/core/src/sandbox/macos/baseProfile.ts new file mode 100644 index 0000000000..b331b7c58e --- /dev/null +++ b/packages/core/src/sandbox/macos/baseProfile.ts @@ -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") +) +`; diff --git a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.test.ts b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.test.ts new file mode 100644 index 0000000000..340eaead60 --- /dev/null +++ b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.test.ts @@ -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(); + }); +}); diff --git a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts new file mode 100644 index 0000000000..0e162f22dd --- /dev/null +++ b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts @@ -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', '', '-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; +} diff --git a/packages/core/src/services/sandboxManager.test.ts b/packages/core/src/services/sandboxManager.test.ts index 44d52aa83c..1c351ce483 100644 --- a/packages/core/src/services/sandboxManager.test.ts +++ b/packages/core/src/services/sandboxManager.test.ts @@ -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(); + } + }, + ); }); diff --git a/packages/core/src/services/sandboxManager.ts b/packages/core/src/services/sandboxManager.ts index ff1f83dde5..b48f010cea 100644 --- a/packages/core/src/services/sandboxManager.ts +++ b/packages/core/src/services/sandboxManager.ts @@ -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();