diff --git a/packages/runtime/src/__tests__/builtin-tools.test.ts b/packages/runtime/src/__tests__/builtin-tools.test.ts index 4f3fad6841..dab9473ee2 100644 --- a/packages/runtime/src/__tests__/builtin-tools.test.ts +++ b/packages/runtime/src/__tests__/builtin-tools.test.ts @@ -45,7 +45,11 @@ import { expect } from '../test-helpers.js'; import { buildBuiltinTools } from '../builtin-tools.js'; import { SandboxManager } from '../sandbox/sandbox-manager.js'; import { LinuxBubblewrapBackend } from '../sandbox/linux-sandbox.js'; -import { MacosSeatbeltBackend } from '../sandbox/macos-seatbelt.js'; +import { + MacosSeatbeltBackend, + macosBashExecutableRoots, + resolveMacosDeveloperToolchainRoot, +} from '../sandbox/macos-seatbelt.js'; import { WindowsBrokerSandboxBackend } from '../sandbox/windows-sandbox.js'; import { SandboxCommandError } from '../sandbox/errors.js'; import type { ShellRunLauncher } from '../shell-tools.js'; @@ -1465,12 +1469,16 @@ describe('builtin Bash streaming output', () => { (argument) => /^-DEXECUTABLE_ROOT_\d+=/u.test(argument) && argument.endsWith(`=${root}`), ); assert.ok(hasExecutableRoot(executableRoot)); - if (process.execPath.startsWith('/opt/homebrew/')) { - assert.ok(hasExecutableRoot('/opt/homebrew')); - } - if (process.execPath.startsWith('/usr/local/')) { - assert.ok(hasExecutableRoot('/usr/local')); + const expectedRoots = macosBashExecutableRoots({ + execPath: process.execPath, + path: process.env.PATH, + developerRoot: resolveMacosDeveloperToolchainRoot(process.env.DEVELOPER_DIR), + }); + for (const expectedRoot of expectedRoots) { + assert.ok(hasExecutableRoot(expectedRoot), `missing executable root ${expectedRoot}`); } + assert.equal(input?.env?.GIT_CONFIG_GLOBAL, process.env.GIT_CONFIG_GLOBAL ?? '/dev/null'); + assert.equal(input?.env?.GIT_CONFIG_SYSTEM, process.env.GIT_CONFIG_SYSTEM ?? '/dev/null'); } finally { await rm(root, { recursive: true, force: true }); } diff --git a/packages/runtime/src/__tests__/macos-seatbelt-smoke.test.ts b/packages/runtime/src/__tests__/macos-seatbelt-smoke.test.ts index 46309bf5df..25c8a6af00 100644 --- a/packages/runtime/src/__tests__/macos-seatbelt-smoke.test.ts +++ b/packages/runtime/src/__tests__/macos-seatbelt-smoke.test.ts @@ -32,7 +32,12 @@ import { type PermissionProfile, } from '@maka/core/permission-profile'; -import { MACOS_SEATBELT_EXECUTABLE, MacosSeatbeltBackend } from '../sandbox/macos-seatbelt.js'; +import { + MACOS_SEATBELT_EXECUTABLE, + MacosSeatbeltBackend, + macosBashExecutableRoots, + resolveMacosDeveloperToolchainRoot, +} from '../sandbox/macos-seatbelt.js'; import { SandboxManager } from '../sandbox/sandbox-manager.js'; const canRunSeatbelt = process.platform === 'darwin' && existsSync(MACOS_SEATBELT_EXECUTABLE); @@ -69,6 +74,8 @@ function runSeatbeltCommand( command: string, profile: PermissionProfile = createWorkspaceWritePermissionProfile(), includeTempRoots = false, + executableRoots: readonly string[] = [], + env: NodeJS.ProcessEnv = process.env, ) { const manager = new SandboxManager([new MacosSeatbeltBackend()]); const result = manager.transform({ @@ -81,6 +88,7 @@ function runSeatbeltCommand( pathContext: { workspaceRoots: [workspaceRoot], ...(includeTempRoots ? { tmpdir: tmpdir(), slashTmp: '/tmp' } : {}), + ...(executableRoots.length > 0 ? { executableRoots } : {}), }, }, }); @@ -90,7 +98,7 @@ function runSeatbeltCommand( return spawnSync(result.exec.argv[0], result.exec.argv.slice(1), { cwd: result.exec.cwd, - env: { ...process.env, ...result.exec.env }, + env: { ...env, ...result.exec.env }, encoding: 'utf8', }); } @@ -146,6 +154,115 @@ describe('macOS Seatbelt smoke', { skip: !canRunSeatbelt }, () => { assert.equal(child.status, 0, child.stderr); }); + it('runs a repository-local Homebrew Git command with its runtime dependencies', { + skip: !existsSync('/opt/homebrew/bin/git'), + }, async () => { + const workspaceRoot = await makeWorkspace(); + cleanup.push(workspaceRoot); + const gitEnvironment = { + ...process.env, + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_CONFIG_SYSTEM: '/dev/null', + }; + for (const args of [ + ['init'], + ['config', 'user.name', 'Maka Test'], + ['config', 'user.email', 'maka@example.test'], + ]) { + const setup = spawnSync('/opt/homebrew/bin/git', args, { + cwd: workspaceRoot, + env: gitEnvironment, + encoding: 'utf8', + }); + assert.equal(setup.status, 0, setup.stderr); + } + await writeFile(join(workspaceRoot, 'fixture.txt'), 'fixture\n'); + for (const args of [ + ['add', 'fixture.txt'], + ['commit', '-m', 'fixture commit'], + ]) { + const setup = spawnSync('/opt/homebrew/bin/git', args, { + cwd: workspaceRoot, + env: gitEnvironment, + encoding: 'utf8', + }); + assert.equal(setup.status, 0, setup.stderr); + } + const executableRoots = macosBashExecutableRoots({ + execPath: process.execPath, + path: '/opt/homebrew/bin:/usr/bin:/bin', + }); + + const child = runSeatbeltCommand( + workspaceRoot, + '/opt/homebrew/bin/git log -1 --pretty=format:"%s"', + createWorkspaceWritePermissionProfile(), + true, + executableRoots, + gitEnvironment, + ); + + assert.equal(child.status, 0, child.stderr); + assert.equal(child.stdout, 'fixture commit'); + }); + + it('allows Apple Git to load the selected developer toolchain', async () => { + const workspaceRoot = await makeWorkspace(); + cleanup.push(workspaceRoot); + const executableRoots = macosBashExecutableRoots({ + execPath: process.execPath, + path: '/usr/bin:/bin', + }); + + const child = runSeatbeltCommand( + workspaceRoot, + '/usr/bin/git --version', + createWorkspaceWritePermissionProfile(), + true, + executableRoots, + { + ...process.env, + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_CONFIG_SYSTEM: '/dev/null', + }, + ); + + assert.equal(child.status, 0, child.stderr); + assert.match(child.stdout, /^git version /); + }); + + it('admits an alternate selected Xcode application root', async () => { + const workspaceRoot = await makeWorkspace(); + const alternateRoot = await realpath( + await mkdtemp(join(tmpdir(), 'maka-seatbelt-xcode-beta-')), + ); + cleanup.push(workspaceRoot, alternateRoot); + const contents = join(alternateRoot, 'Xcode-beta.app', 'Contents'); + const developer = join(contents, 'Developer'); + const marker = join(contents, 'SharedFrameworks', 'marker.txt'); + await mkdir(developer, { recursive: true }); + await mkdir(join(contents, 'SharedFrameworks'), { recursive: true }); + await writeFile(marker, 'alternate developer root\n'); + const developerRoot = resolveMacosDeveloperToolchainRoot(undefined, () => developer); + assert.ok(developerRoot); + const executableRoots = macosBashExecutableRoots({ + execPath: process.execPath, + path: '/usr/bin:/bin', + developerRoot, + }); + + const child = runSeatbeltCommand( + workspaceRoot, + `/bin/cat ${JSON.stringify(marker)}`, + createWorkspaceWritePermissionProfile(), + false, + executableRoots, + ); + + assert.equal(child.status, 0, child.stderr); + assert.equal(child.stdout, 'alternate developer root\n'); + }); + it('denies writes outside the workspace root', async () => { const workspaceRoot = await makeWorkspace(); const outsideRoot = await realpath(await mkdtemp(join(tmpdir(), 'maka-seatbelt-outside-'))); diff --git a/packages/runtime/src/__tests__/macos-seatbelt.test.ts b/packages/runtime/src/__tests__/macos-seatbelt.test.ts index b415644939..70b2623b91 100644 --- a/packages/runtime/src/__tests__/macos-seatbelt.test.ts +++ b/packages/runtime/src/__tests__/macos-seatbelt.test.ts @@ -36,6 +36,8 @@ import { buildSeatbeltPolicy, createSeatbeltExecArgs, escapeSeatbeltRegex, + macosBashExecutableRoots, + resolveMacosDeveloperToolchainRoot, } from '../sandbox/macos-seatbelt.js'; import type { SandboxTransformRequest } from '../sandbox/types.js'; @@ -136,6 +138,85 @@ describe('escapeSeatbeltRegex', () => { }); }); +describe('macosBashExecutableRoots', () => { + it('provides fixed Homebrew and Apple developer toolchain roots', () => { + assert.deepEqual( + macosBashExecutableRoots({ + execPath: '/Applications/Maka.app/Contents/MacOS/Maka', + path: '/Users/test/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin', + developerRoot: '/Applications/Xcode-beta.app/Contents', + }), + [ + '/Applications/Maka.app/Contents/MacOS', + '/opt/homebrew/bin', + '/opt/homebrew/sbin', + '/opt/homebrew/Cellar', + '/opt/homebrew/opt', + '/opt/homebrew/lib', + '/opt/homebrew/libexec', + '/opt/homebrew/share', + '/usr/local/bin', + '/usr/local/sbin', + '/usr/local/Cellar', + '/usr/local/opt', + '/usr/local/lib', + '/usr/local/libexec', + '/usr/local/share', + '/Applications/Xcode-beta.app/Contents', + ], + ); + }); + + it('does not expose unrelated PATH directories as executable roots', () => { + const roots = macosBashExecutableRoots({ + execPath: '/Applications/Maka.app/Contents/MacOS/Maka', + path: '/Users/test/private:/custom/toolchain/bin:/usr/bin:/bin', + }); + + assert.equal(roots.includes('/Users/test/private'), false); + assert.equal(roots.includes('/custom/toolchain/bin'), false); + assert.equal(roots.includes('/opt/homebrew/etc'), false); + assert.equal(roots.includes('/usr/local/etc'), false); + }); + + it('keeps Homebrew Cellar Node on the fixed package-manager subroots', () => { + const roots = macosBashExecutableRoots({ + execPath: '/opt/homebrew/Cellar/node/24.0.0/bin/node', + path: '/opt/homebrew/bin:/usr/bin:/bin', + developerRoot: '/Applications/Xcode.app/Contents', + }); + + assert.equal(roots.includes('/opt/homebrew'), false); + assert.equal(roots.includes('/opt/homebrew/Cellar'), true); + assert.equal(roots.includes('/opt/homebrew/opt'), true); + assert.equal(roots.includes('/opt/homebrew/lib'), true); + }); + + it('resolves the configured or selected developer directory to the Xcode toolchain root', () => { + const root = mkdtempSync(join(realpathSync('/tmp'), 'maka-xcode-root-')); + try { + const contents = join(root, 'Xcode-beta.app', 'Contents'); + const developer = join(contents, 'Developer'); + const alias = join(root, 'selected-xcode'); + mkdirSync(developer, { recursive: true }); + symlinkSync(developer, alias); + + assert.equal( + resolveMacosDeveloperToolchainRoot(alias, () => { + throw new Error('DEVELOPER_DIR must take precedence'); + }), + realpathSync(contents), + ); + assert.equal( + resolveMacosDeveloperToolchainRoot(undefined, () => developer), + realpathSync(contents), + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe('buildSeatbeltPolicy', () => { it('builds read-only policy with readable workspace roots and no writable workspace roots', () => { const result = buildSeatbeltPolicy({ diff --git a/packages/runtime/src/builtin-tools.ts b/packages/runtime/src/builtin-tools.ts index dbb1d7dc65..df9a793157 100644 --- a/packages/runtime/src/builtin-tools.ts +++ b/packages/runtime/src/builtin-tools.ts @@ -79,6 +79,10 @@ import { profileRequiresSandbox, type SandboxManager } from './sandbox/sandbox-m import { SandboxCommandError } from './sandbox/errors.js'; import { isLikelySandboxDenial } from './sandbox/detect.js'; import { linuxExecutableRoots } from './sandbox/linux-sandbox.js'; +import { + macosBashExecutableRoots, + resolveMacosDeveloperToolchainRoot, +} from './sandbox/macos-seatbelt.js'; import { pinExistingLinuxProfilePath } from './sandbox/linux-profile-path.js'; import type { SandboxPlatform, SandboxType } from './sandbox/types.js'; import type { ChildFdInput } from './child-fd-input.js'; @@ -747,6 +751,14 @@ function sandboxCommand( ? { profile: boundary.profile, workspaceRoots: [cwd] } : effectivePermissionProfile(explicitProfile, ctx.permissionMode ?? 'ask', cwd); const env = { ...process.env }; + if (platform === 'darwin' && env.GIT_CONFIG_GLOBAL === undefined) { + // Restricted Seatbelt profiles cannot read ~/.gitconfig. Treat it as absent + // by default so ordinary repository-local Git commands do not fail closed. + env.GIT_CONFIG_GLOBAL = '/dev/null'; + } + if (platform === 'darwin' && env.GIT_CONFIG_SYSTEM === undefined) { + env.GIT_CONFIG_SYSTEM = '/dev/null'; + } if (pty) { if (profileRequiresSandbox(effective.profile)) { throw new SandboxCommandError({ @@ -833,7 +845,11 @@ function sandboxCommand( ...(platform === 'win32' ? {} : { slashTmp: '/tmp' }), ...(platform === 'darwin' ? { - executableRoots: macosRuntimeExecutableRoots(process.execPath), + executableRoots: macosBashExecutableRoots({ + execPath: process.execPath, + path: env.PATH, + developerRoot: resolveMacosDeveloperToolchainRoot(env.DEVELOPER_DIR), + }), } : {}), ...(platform === 'linux' @@ -1084,14 +1100,6 @@ function canonicalExistingPath(path: string): string { } } -function macosRuntimeExecutableRoots(execPath: string): readonly string[] { - return [ - ...linuxExecutableRoots({ execPath }), - ...(execPath.startsWith('/opt/homebrew/') ? ['/opt/homebrew'] : []), - ...(execPath.startsWith('/usr/local/') ? ['/usr/local'] : []), - ]; -} - function effectivePermissionProfile( explicitProfile: PermissionProfile | undefined, permissionMode: NonNullable, diff --git a/packages/runtime/src/sandbox/index.ts b/packages/runtime/src/sandbox/index.ts index aa2bd69693..d200bea296 100644 --- a/packages/runtime/src/sandbox/index.ts +++ b/packages/runtime/src/sandbox/index.ts @@ -61,6 +61,7 @@ export { buildSeatbeltPolicy, createSeatbeltExecArgs, escapeSeatbeltRegex, + macosBashExecutableRoots, } from './macos-seatbelt.js'; export type { BuildSeatbeltPolicyInput, diff --git a/packages/runtime/src/sandbox/macos-seatbelt.ts b/packages/runtime/src/sandbox/macos-seatbelt.ts index f4bdae5576..90dc2d8301 100644 --- a/packages/runtime/src/sandbox/macos-seatbelt.ts +++ b/packages/runtime/src/sandbox/macos-seatbelt.ts @@ -18,7 +18,18 @@ */ import { readlinkSync, realpathSync } from 'node:fs'; -import { basename, dirname, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { + basename, + delimiter, + dirname, + isAbsolute, + join, + normalize, + relative, + resolve, + sep, +} from 'node:path'; import type { PermissionProfile } from '@maka/core/permission-profile'; @@ -31,6 +42,81 @@ import type { export const MACOS_SEATBELT_EXECUTABLE = '/usr/bin/sandbox-exec'; +const MACOS_PACKAGE_TOOLCHAIN_ROOTS = ['/opt/homebrew', '/usr/local'] as const; +const MACOS_PACKAGE_TOOLCHAIN_SUBPATHS = [ + 'bin', + 'sbin', + 'Cellar', + 'opt', + 'lib', + 'libexec', + 'share', +] as const; +const MACOS_APPLE_DEVELOPER_ROOTS = [ + '/Applications/Xcode.app/Contents', + '/Library/Developer/CommandLineTools', +] as const; +const XCODE_SELECT_TIMEOUT_MS = 1_000; + +export function macosBashExecutableRoots(input: { + execPath: string; + path?: string; + developerRoot?: string; +}): readonly string[] { + const roots = [runtimeExecutableRoot(input.execPath)]; + const pathEntries = (input.path ?? '') + .split(delimiter) + .filter(isAbsolute) + .map((path) => normalize(path)); + + for (const toolchainRoot of MACOS_PACKAGE_TOOLCHAIN_ROOTS) { + if ( + isPathWithin(input.execPath, toolchainRoot) || + pathEntries.some((path) => isPathWithin(path, toolchainRoot)) + ) { + roots.push( + ...MACOS_PACKAGE_TOOLCHAIN_SUBPATHS.map((subpath) => join(toolchainRoot, subpath)), + ); + } + } + + roots.push(...(input.developerRoot ? [input.developerRoot] : MACOS_APPLE_DEVELOPER_ROOTS)); + const uniqueRoots = [...new Set(roots)]; + return uniqueRoots.filter( + (root) => !uniqueRoots.some((candidate) => candidate !== root && isPathWithin(root, candidate)), + ); +} + +export function resolveMacosDeveloperToolchainRoot( + developerDir: string | undefined, + selectedDeveloperDirectory: () => string | undefined = readSelectedDeveloperDirectory, +): string | undefined { + const configured = absoluteNonEmptyPath(developerDir); + const selected = configured ?? absoluteNonEmptyPath(selectedDeveloperDirectory()); + if (!selected) return undefined; + + const normalized = normalize(selected); + let canonicalSelected = normalized; + try { + canonicalSelected = realpathSync(normalized); + } catch { + // The command itself will report an invalid selected toolchain. Keep the + // lexical path so Seatbelt does not silently fall back to a different root. + } + const toolchainRoot = + basename(canonicalSelected) === 'Developer' && + basename(dirname(canonicalSelected)) === 'Contents' + ? dirname(canonicalSelected) + : canonicalSelected.endsWith('.app') + ? join(canonicalSelected, 'Contents') + : canonicalSelected; + try { + return realpathSync(toolchainRoot); + } catch { + return toolchainRoot; + } +} + export const MACOS_SEATBELT_BASE_POLICY = `(version 1) (deny default) @@ -558,6 +644,32 @@ function trimTrailingSlash(path: string): string { return path.replace(/\/+$/g, ''); } +function runtimeExecutableRoot(execPath: string): string { + const executableDirectory = dirname(normalize(execPath)); + return basename(executableDirectory) === 'bin' + ? dirname(executableDirectory) + : executableDirectory; +} + +function isPathWithin(path: string, root: string): boolean { + const delta = relative(root, normalize(path)); + return delta === '' || (delta !== '..' && !delta.startsWith(`..${sep}`)); +} + +function absoluteNonEmptyPath(path: string | undefined): string | undefined { + const trimmed = path?.trim(); + return trimmed && isAbsolute(trimmed) ? trimmed : undefined; +} + +function readSelectedDeveloperDirectory(): string | undefined { + const result = spawnSync('/usr/bin/xcode-select', ['-p'], { + encoding: 'utf8', + timeout: XCODE_SELECT_TIMEOUT_MS, + stdio: ['ignore', 'pipe', 'ignore'], + }); + return result.status === 0 ? result.stdout.trim() || undefined : undefined; +} + function buildNetworkPolicy(profile: PermissionProfile): string { if (profile.type === 'managed' && profile.network.kind === 'enabled') { return '(allow network*)';