From 11ff2065c5d9d38d24ae30096f8952360b7d613d Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Mon, 31 Aug 2026 00:53:25 +0800 Subject: [PATCH 1/2] fix(runtime): admit macOS Bash toolchain dependencies Sandboxed Bash only exposed the Electron runtime root, so Homebrew and Apple Git could be selected from PATH but fail while loading dylibs. Provide a fixed read/execute-only toolchain substrate, make inaccessible Git global config behave as absent, and cover both toolchains with real Seatbelt regression tests. --- .../src/__tests__/builtin-tools.test.ts | 2 + .../__tests__/macos-seatbelt-smoke.test.ts | 88 ++++++++++++++++++- .../src/__tests__/macos-seatbelt.test.ts | 43 +++++++++ packages/runtime/src/builtin-tools.ts | 22 +++-- packages/runtime/src/sandbox/index.ts | 1 + .../runtime/src/sandbox/macos-seatbelt.ts | 67 +++++++++++++- 6 files changed, 211 insertions(+), 12 deletions(-) diff --git a/packages/runtime/src/__tests__/builtin-tools.test.ts b/packages/runtime/src/__tests__/builtin-tools.test.ts index 4f3fad6841..0dfdc2bde3 100644 --- a/packages/runtime/src/__tests__/builtin-tools.test.ts +++ b/packages/runtime/src/__tests__/builtin-tools.test.ts @@ -1471,6 +1471,8 @@ describe('builtin Bash streaming output', () => { if (process.execPath.startsWith('/usr/local/')) { assert.ok(hasExecutableRoot('/usr/local')); } + 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..d0d197f953 100644 --- a/packages/runtime/src/__tests__/macos-seatbelt-smoke.test.ts +++ b/packages/runtime/src/__tests__/macos-seatbelt-smoke.test.ts @@ -32,7 +32,11 @@ import { type PermissionProfile, } from '@maka/core/permission-profile'; -import { MACOS_SEATBELT_EXECUTABLE, MacosSeatbeltBackend } from '../sandbox/macos-seatbelt.js'; +import { + MACOS_SEATBELT_EXECUTABLE, + MacosSeatbeltBackend, + macosBashExecutableRoots, +} from '../sandbox/macos-seatbelt.js'; import { SandboxManager } from '../sandbox/sandbox-manager.js'; const canRunSeatbelt = process.platform === 'darwin' && existsSync(MACOS_SEATBELT_EXECUTABLE); @@ -69,6 +73,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 +87,7 @@ function runSeatbeltCommand( pathContext: { workspaceRoots: [workspaceRoot], ...(includeTempRoots ? { tmpdir: tmpdir(), slashTmp: '/tmp' } : {}), + ...(executableRoots.length > 0 ? { executableRoots } : {}), }, }, }); @@ -90,7 +97,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 +153,83 @@ 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('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..db4fae5953 100644 --- a/packages/runtime/src/__tests__/macos-seatbelt.test.ts +++ b/packages/runtime/src/__tests__/macos-seatbelt.test.ts @@ -36,6 +36,7 @@ import { buildSeatbeltPolicy, createSeatbeltExecArgs, escapeSeatbeltRegex, + macosBashExecutableRoots, } from '../sandbox/macos-seatbelt.js'; import type { SandboxTransformRequest } from '../sandbox/types.js'; @@ -136,6 +137,48 @@ 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', + }), + [ + '/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.app/Contents', + '/Library/Developer/CommandLineTools', + ], + ); + }); + + 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); + }); +}); + 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..a15194215b 100644 --- a/packages/runtime/src/builtin-tools.ts +++ b/packages/runtime/src/builtin-tools.ts @@ -79,6 +79,7 @@ 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 } 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 +748,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 +842,10 @@ function sandboxCommand( ...(platform === 'win32' ? {} : { slashTmp: '/tmp' }), ...(platform === 'darwin' ? { - executableRoots: macosRuntimeExecutableRoots(process.execPath), + executableRoots: macosBashExecutableRoots({ + execPath: process.execPath, + path: env.PATH, + }), } : {}), ...(platform === 'linux' @@ -1084,14 +1096,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 9019f56a6f..44dad02848 100644 --- a/packages/runtime/src/sandbox/index.ts +++ b/packages/runtime/src/sandbox/index.ts @@ -78,6 +78,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 f9abb58430..be324b200a 100644 --- a/packages/runtime/src/sandbox/macos-seatbelt.ts +++ b/packages/runtime/src/sandbox/macos-seatbelt.ts @@ -18,7 +18,17 @@ */ import { readlinkSync, realpathSync } from 'node:fs'; -import { basename, dirname, resolve } from 'node:path'; +import { + basename, + delimiter, + dirname, + isAbsolute, + join, + normalize, + relative, + resolve, + sep, +} from 'node:path'; import type { PermissionProfile } from '@maka/core/permission-profile'; @@ -32,6 +42,49 @@ 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; + +export function macosBashExecutableRoots(input: { + execPath: string; + path?: 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(...MACOS_APPLE_DEVELOPER_ROOTS); + const uniqueRoots = [...new Set(roots)]; + return uniqueRoots.filter( + (root) => !uniqueRoots.some((candidate) => candidate !== root && isPathWithin(root, candidate)), + ); +} + export const MACOS_SEATBELT_BASE_POLICY = `(version 1) (deny default) @@ -571,6 +624,18 @@ 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 buildNetworkPolicy(profile: PermissionProfile): string { if (profile.type === 'managed' && profile.network.kind === 'enabled') { return '(allow network*)'; From b7da3005ac31e67a2e5e2736677bcb4fdb35d503 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Mon, 31 Aug 2026 14:30:23 +0800 Subject: [PATCH 2/2] fix(runtime): honor active macOS developer toolchain Resolve inherited DEVELOPER_DIR or the active xcode-select path before compiling the Bash Seatbelt substrate. Tighten the Homebrew root regression assertions and add an alternate Xcode sandbox smoke test. Generated-by: OpenAI Codex --- .../src/__tests__/builtin-tools.test.ts | 18 ++++--- .../__tests__/macos-seatbelt-smoke.test.ts | 33 +++++++++++++ .../src/__tests__/macos-seatbelt.test.ts | 42 +++++++++++++++- packages/runtime/src/builtin-tools.ts | 6 ++- .../runtime/src/sandbox/macos-seatbelt.ts | 49 ++++++++++++++++++- 5 files changed, 138 insertions(+), 10 deletions(-) diff --git a/packages/runtime/src/__tests__/builtin-tools.test.ts b/packages/runtime/src/__tests__/builtin-tools.test.ts index 0dfdc2bde3..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,11 +1469,13 @@ 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'); diff --git a/packages/runtime/src/__tests__/macos-seatbelt-smoke.test.ts b/packages/runtime/src/__tests__/macos-seatbelt-smoke.test.ts index d0d197f953..25c8a6af00 100644 --- a/packages/runtime/src/__tests__/macos-seatbelt-smoke.test.ts +++ b/packages/runtime/src/__tests__/macos-seatbelt-smoke.test.ts @@ -36,6 +36,7 @@ import { MACOS_SEATBELT_EXECUTABLE, MacosSeatbeltBackend, macosBashExecutableRoots, + resolveMacosDeveloperToolchainRoot, } from '../sandbox/macos-seatbelt.js'; import { SandboxManager } from '../sandbox/sandbox-manager.js'; @@ -230,6 +231,38 @@ describe('macOS Seatbelt smoke', { skip: !canRunSeatbelt }, () => { 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 db4fae5953..70b2623b91 100644 --- a/packages/runtime/src/__tests__/macos-seatbelt.test.ts +++ b/packages/runtime/src/__tests__/macos-seatbelt.test.ts @@ -37,6 +37,7 @@ import { createSeatbeltExecArgs, escapeSeatbeltRegex, macosBashExecutableRoots, + resolveMacosDeveloperToolchainRoot, } from '../sandbox/macos-seatbelt.js'; import type { SandboxTransformRequest } from '../sandbox/types.js'; @@ -143,6 +144,7 @@ describe('macosBashExecutableRoots', () => { 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', @@ -160,8 +162,7 @@ describe('macosBashExecutableRoots', () => { '/usr/local/lib', '/usr/local/libexec', '/usr/local/share', - '/Applications/Xcode.app/Contents', - '/Library/Developer/CommandLineTools', + '/Applications/Xcode-beta.app/Contents', ], ); }); @@ -177,6 +178,43 @@ describe('macosBashExecutableRoots', () => { 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', () => { diff --git a/packages/runtime/src/builtin-tools.ts b/packages/runtime/src/builtin-tools.ts index a15194215b..df9a793157 100644 --- a/packages/runtime/src/builtin-tools.ts +++ b/packages/runtime/src/builtin-tools.ts @@ -79,7 +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 } from './sandbox/macos-seatbelt.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'; @@ -845,6 +848,7 @@ function sandboxCommand( executableRoots: macosBashExecutableRoots({ execPath: process.execPath, path: env.PATH, + developerRoot: resolveMacosDeveloperToolchainRoot(env.DEVELOPER_DIR), }), } : {}), diff --git a/packages/runtime/src/sandbox/macos-seatbelt.ts b/packages/runtime/src/sandbox/macos-seatbelt.ts index 880b1919b2..90dc2d8301 100644 --- a/packages/runtime/src/sandbox/macos-seatbelt.ts +++ b/packages/runtime/src/sandbox/macos-seatbelt.ts @@ -18,6 +18,7 @@ */ import { readlinkSync, realpathSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; import { basename, delimiter, @@ -55,10 +56,12 @@ 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 ?? '') @@ -77,13 +80,43 @@ export function macosBashExecutableRoots(input: { } } - roots.push(...MACOS_APPLE_DEVELOPER_ROOTS); + 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) @@ -623,6 +656,20 @@ function isPathWithin(path: string, root: string): boolean { 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*)';