diff --git a/docs/windows-test-inventory.md b/docs/windows-test-inventory.md index 1c49c743a99..5e432e8a473 100644 --- a/docs/windows-test-inventory.md +++ b/docs/windows-test-inventory.md @@ -17,9 +17,9 @@ Locations intentionally omit line numbers so unrelated edits do not invalidate t |---|---:| | windows-backend-gap | 27 | | portable-candidate | 56 | -| platform-contract | 48 | +| platform-contract | 49 | -Total Windows-excluded declarations: **131** +Total Windows-excluded declarations: **132** ## Inventory @@ -86,6 +86,7 @@ Total Windows-excluded declarations: **131** | platform-contract | `packages/runtime/src/__tests__/filesystem-worker-process-runner.test.ts` filesystem worker rejects boundedly when a detached descendant retains stdout | `process.platform === 'win32' ? 'POSIX detached process-group semantics required' : false` | | platform-contract | `packages/runtime/src/__tests__/filesystem-worker-smoke.test.ts` macOS filesystem worker smoke | `process.platform !== 'darwin'` | | platform-contract | `packages/runtime/src/__tests__/glob-search.test.ts` both Glob paths report permission failures and recover after permissions are restored | `process.platform === 'win32' \|\| process.getuid?.() === 0` | +| platform-contract | `packages/runtime/src/__tests__/macos-command-paths.test.ts` Apple signer validation | `process.platform !== 'darwin'` | | portable-candidate | `packages/runtime/src/__tests__/node-pty-write-lifecycle.test.ts` does not carry queued Unix PTY writes past native exit | `process.platform === 'win32' ? 'Unix PTY file-descriptor lifecycle only' : false` | | platform-contract | `packages/runtime/src/__tests__/owned-child-process.test.ts` POSIX cancellation delivers SIGTERM exactly once to the owned command | `process.platform === 'win32' ? 'POSIX detached process-group semantics required' : false` | | platform-contract | `packages/runtime/src/__tests__/owned-child-process.test.ts` supervisor mirrors a command that dies from ${signal} | `process.platform === 'win32' ? 'POSIX detached process-group semantics required' : false` | diff --git a/packages/runtime/src/__tests__/builtin-tools.test.ts b/packages/runtime/src/__tests__/builtin-tools.test.ts index 361c070fefa..ecafdf0b818 100644 --- a/packages/runtime/src/__tests__/builtin-tools.test.ts +++ b/packages/runtime/src/__tests__/builtin-tools.test.ts @@ -1534,6 +1534,71 @@ describe('builtin Bash streaming output', () => { } }); + test('does not turn Git config references into host-discovered sandbox grants', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-bash-no-git-config-scan-')); + try { + const config = join(root, 'outside.config'); + await writeFile(config, `[include]\n\tpath = ${join(root, 'included.config')}\n`); + let input: Parameters[0] | undefined; + const shellRuns: ShellRunLauncher = { + async runForegroundBash(value) { + input = value; + return { + kind: 'terminal', + cwd: value.cwd, + cmd: value.command, + status: 'completed', + exitCode: 0, + output: { + mode: 'pipes', + stdout: '', + stderr: '', + stdoutTruncated: false, + stderrTruncated: false, + redacted: false, + }, + }; + }, + async runBackgroundBash() { + throw new Error('not used'); + }, + }; + const bash = buildBuiltinTools({ + shellRuns, + shellEnvironment: { GIT_CONFIG_GLOBAL: config }, + permissionProfile: createWorkspaceWritePermissionProfile(), + sandboxManager: new SandboxManager([new MacosSeatbeltBackend()]), + sandboxPlatform: 'darwin', + }).find((candidate) => candidate.name === 'Bash'); + if (!bash) throw new Error('Bash tool missing'); + + await bash.impl( + { command: '/usr/bin/git status --short' }, + { + sessionId: 'session-1', + turnId: 'turn-1', + toolCallId: 'tool-1', + cwd: root, + permissionMode: 'ask', + abortSignal: new AbortController().signal, + emitOutput: () => {}, + }, + ); + + assert.ok(input?.argv); + assert.equal( + input.argv.some((argument) => argument.includes(config)), + false, + ); + assert.equal( + input.argv.some((argument) => argument.includes('RUNTIME_READABLE_FILE')), + false, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + test('Read treats runtime background task refs as whole resources', async () => { const calls: unknown[] = []; const runtimeResources = { diff --git a/packages/runtime/src/__tests__/macos-command-paths.test.ts b/packages/runtime/src/__tests__/macos-command-paths.test.ts new file mode 100644 index 00000000000..0219d9377e5 --- /dev/null +++ b/packages/runtime/src/__tests__/macos-command-paths.test.ts @@ -0,0 +1,470 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { describe, it } from 'node:test'; +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { + createReadOnlyPermissionProfile, + createWorkspaceWritePermissionProfile, +} from '@maka/core/permission-profile'; + +import { + resolveMacosCommandPaths, + resolveMacosDeveloperExecutableRoots, + type MacosDeveloperCommandRunner, +} from '../sandbox/macos-command-paths.js'; + +describe('resolveMacosDeveloperExecutableRoots', () => { + it('accepts canonical and symlinked CommandLineTools layouts', async () => { + const scratch = mkdtempSync(join(tmpdir(), 'maka-clt-')); + const developer = join(scratch, 'CommandLineTools'); + const library = join(developer, 'usr', 'lib'); + const alias = join(scratch, 'selected'); + mkdirSync(library, { recursive: true }); + writeFileSync(join(library, 'libxcrun.dylib'), 'fixture'); + symlinkSync(developer, alias); + try { + assert.deepEqual( + await resolveMacosDeveloperExecutableRoots({ + developerDir: developer, + validateAppleBinary: () => true, + }), + [realpathSync(library)], + ); + assert.deepEqual( + await resolveMacosDeveloperExecutableRoots({ + developerDir: alias, + validateAppleBinary: () => true, + }), + [realpathSync(library)], + ); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } + }); + + it('accepts only the library and SharedFrameworks directories from an Xcode layout', async () => { + const scratch = mkdtempSync(join(tmpdir(), 'maka-xcode-')); + const contents = join(scratch, 'Xcode-beta.app', 'Contents'); + const developer = join(contents, 'Developer'); + const library = join(developer, 'usr', 'lib'); + const frameworks = join(contents, 'SharedFrameworks'); + mkdirSync(library, { recursive: true }); + mkdirSync(frameworks); + writeFileSync(join(library, 'libxcrun.dylib'), 'fixture'); + try { + for (const developerDir of [developer, dirname(contents)]) { + assert.deepEqual( + await resolveMacosDeveloperExecutableRoots({ + developerDir, + validateAppleBinary: () => true, + }), + [realpathSync(library), realpathSync(frameworks)], + ); + } + } finally { + rmSync(scratch, { recursive: true, force: true }); + } + }); + + it('rejects a SharedFrameworks symlink outside the Xcode bundle', async () => { + const scratch = mkdtempSync(join(tmpdir(), 'maka-xcode-frameworks-escape-')); + const contents = join(scratch, 'Xcode.app', 'Contents'); + const developer = join(contents, 'Developer'); + const library = join(developer, 'usr', 'lib'); + mkdirSync(library, { recursive: true }); + writeFileSync(join(library, 'libxcrun.dylib'), 'fixture'); + symlinkSync('/', join(contents, 'SharedFrameworks')); + try { + assert.deepEqual( + await resolveMacosDeveloperExecutableRoots({ + developerDir: developer, + validateAppleBinary: () => true, + }), + [], + ); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } + }); + + it('rejects a toolchain library symlink outside the selected developer directory', async () => { + const scratch = mkdtempSync(join(tmpdir(), 'maka-clt-library-escape-')); + const developer = join(scratch, 'CommandLineTools'); + const externalLibrary = join(scratch, 'external-lib'); + mkdirSync(join(developer, 'usr'), { recursive: true }); + mkdirSync(externalLibrary); + writeFileSync(join(externalLibrary, 'libxcrun.dylib'), 'fixture'); + symlinkSync(externalLibrary, join(developer, 'usr', 'lib')); + try { + assert.deepEqual( + await resolveMacosDeveloperExecutableRoots({ + developerDir: developer, + validateAppleBinary: () => true, + }), + [], + ); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } + }); + + it('rejects root, home, ordinary directories, and unresolved symlinks', async () => { + const scratch = mkdtempSync(join(tmpdir(), 'maka-invalid-developer-')); + const ordinary = join(scratch, 'ordinary'); + const dangling = join(scratch, 'dangling'); + mkdirSync(ordinary); + symlinkSync(join(scratch, 'missing'), dangling); + try { + assert.deepEqual(await resolveMacosDeveloperExecutableRoots({ developerDir: '/' }), []); + assert.deepEqual( + await resolveMacosDeveloperExecutableRoots({ developerDir: scratch, homeDir: scratch }), + [], + ); + assert.deepEqual(await resolveMacosDeveloperExecutableRoots({ developerDir: ordinary }), []); + assert.deepEqual(await resolveMacosDeveloperExecutableRoots({ developerDir: dangling }), []); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } + }); + + it('uses DEVELOPER_DIR before consulting xcode-select', async () => { + let selected = false; + await resolveMacosDeveloperExecutableRoots({ + developerDir: '/', + selectDeveloperDir: () => { + selected = true; + return undefined; + }, + }); + assert.equal(selected, false); + }); + + it('bounds both discovery subprocesses to one second and fails closed on timeout', async () => { + const scratch = mkdtempSync(join(tmpdir(), 'maka-bounded-toolchain-')); + const developer = join(scratch, 'CommandLineTools'); + const library = join(developer, 'usr', 'lib'); + mkdirSync(library, { recursive: true }); + writeFileSync(join(library, 'libxcrun.dylib'), 'fixture'); + const signal = new AbortController().signal; + const calls: Array<{ + executable: string; + args: readonly string[]; + timeout: number; + killSignal: string; + signal?: AbortSignal; + }> = []; + const runCommand: MacosDeveloperCommandRunner = async (executable, args, options) => { + calls.push({ + executable, + args, + timeout: options.timeout, + killSignal: options.killSignal, + signal: options.signal, + }); + if (executable === '/usr/bin/xcode-select') { + return { status: 0, stdout: `${developer}\n` }; + } + return { status: null }; + }; + try { + assert.deepEqual(await resolveMacosDeveloperExecutableRoots({ runCommand, signal }), []); + assert.deepEqual(calls, [ + { + executable: '/usr/bin/xcode-select', + args: ['-p'], + timeout: 1_000, + killSignal: 'SIGKILL', + signal, + }, + { + executable: '/usr/bin/codesign', + args: [ + '--verify', + '--strict', + '-R=anchor apple', + realpathSync(join(library, 'libxcrun.dylib')), + ], + timeout: 1_000, + killSignal: 'SIGKILL', + signal, + }, + ]); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } + }); + + it('fails closed when developer-directory discovery times out', async () => { + const calls: string[] = []; + const runCommand: MacosDeveloperCommandRunner = async (executable) => { + calls.push(executable); + return { status: null }; + }; + + const diagnostics: string[] = []; + assert.deepEqual( + await resolveMacosDeveloperExecutableRoots({ + runCommand, + onDiscoveryFailure: (reason) => diagnostics.push(reason), + }), + [], + ); + assert.deepEqual(calls, ['/usr/bin/xcode-select']); + assert.equal(diagnostics.length, 1); + assert.match(diagnostics[0]!, /xcode-select probe interrupted or timed out/); + diagnostics.length = 0; + assert.deepEqual( + await resolveMacosDeveloperExecutableRoots({ + selectDeveloperDir: () => undefined, + onDiscoveryFailure: (reason) => diagnostics.push(reason), + }), + [], + ); + assert.match(diagnostics[0]!, /no developer directory selected/); + diagnostics.length = 0; + const controller = new AbortController(); + controller.abort(); + assert.deepEqual( + await resolveMacosDeveloperExecutableRoots({ + signal: controller.signal, + onDiscoveryFailure: (reason) => diagnostics.push(reason), + }), + [], + ); + assert.deepEqual(diagnostics, []); + }); + + it('reports signature, layout, and libxcrun containment failures precisely', async () => { + const scratch = mkdtempSync(join(tmpdir(), 'maka-toolchain-reasons-')); + const developer = join(scratch, 'unsupported'); + const library = join(developer, 'usr', 'lib'); + const binary = join(library, 'libxcrun.dylib'); + mkdirSync(library, { recursive: true }); + writeFileSync(binary, 'fixture'); + const diagnostics: string[] = []; + const resolve = (validateAppleBinary: () => boolean) => + resolveMacosDeveloperExecutableRoots({ + developerDir: developer, + validateAppleBinary, + onDiscoveryFailure: (reason) => diagnostics.push(reason), + }); + try { + assert.deepEqual(await resolve(() => false), []); + assert.match(diagnostics.pop()!, /Apple signature verification failed or timed out/); + assert.deepEqual(await resolve(() => true), []); + assert.match(diagnostics.pop()!, /unsupported developer directory layout/); + unlinkSync(binary); + const external = join(scratch, 'external.dylib'); + writeFileSync(external, 'fixture'); + symlinkSync(external, binary); + let validated = false; + assert.deepEqual( + await resolve(() => { + validated = true; + return true; + }), + [], + ); + assert.equal(validated, false); + assert.match(diagnostics.pop()!, /libxcrun is missing or escapes library directory/); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } + }); + + it('returns a canonical root that is unaffected by later selector alias replacement', async () => { + const scratch = mkdtempSync(join(tmpdir(), 'maka-replaced-selection-')); + const first = join(scratch, 'first', 'CommandLineTools'); + const second = join(scratch, 'second', 'CommandLineTools'); + const alias = join(scratch, 'selected'); + for (const developer of [first, second]) { + const library = join(developer, 'usr', 'lib'); + mkdirSync(library, { recursive: true }); + writeFileSync(join(library, 'libxcrun.dylib'), 'fixture'); + } + symlinkSync(first, alias); + try { + const roots = await resolveMacosDeveloperExecutableRoots({ + developerDir: alias, + validateAppleBinary: () => true, + }); + unlinkSync(alias); + symlinkSync(second, alias); + assert.deepEqual(roots, [realpathSync(join(first, 'usr', 'lib'))]); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } + }); + + it('rejects a structurally plausible toolchain containing a non-Mach-O libxcrun', async () => { + const scratch = mkdtempSync(join(tmpdir(), 'maka-unsigned-clt-')); + const developer = join(scratch, 'CommandLineTools'); + const library = join(developer, 'usr', 'lib'); + mkdirSync(library, { recursive: true }); + writeFileSync(join(library, 'libxcrun.dylib'), 'not signed'); + try { + assert.deepEqual(await resolveMacosDeveloperExecutableRoots({ developerDir: developer }), []); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } + }); +}); + +describe('Apple signer validation', { skip: process.platform !== 'darwin' }, () => { + it('rejects a valid ad-hoc-signed dylib in a plausible toolchain', async () => { + const scratch = mkdtempSync(join(tmpdir(), 'maka-adhoc-clt-')); + const developer = join(scratch, 'CommandLineTools'); + const library = join(developer, 'usr', 'lib'); + const binary = join(library, 'libxcrun.dylib'); + mkdirSync(library, { recursive: true }); + try { + const source = join(scratch, 'fixture.c'); + writeFileSync(source, 'int fixture(void) { return 0; }\n'); + const compile = spawnSync('/usr/bin/clang', ['-dynamiclib', source, '-o', binary], { + encoding: 'utf8', + }); + assert.equal(compile.status, 0, compile.stderr); + const sign = spawnSync('/usr/bin/codesign', ['--force', '--sign', '-', binary], { + encoding: 'utf8', + }); + assert.equal(sign.status, 0, sign.stderr); + const verify = spawnSync('/usr/bin/codesign', ['--verify', '--strict', binary], { + encoding: 'utf8', + }); + assert.equal(verify.status, 0, verify.stderr); + assert.deepEqual(await resolveMacosDeveloperExecutableRoots({ developerDir: developer }), []); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } + }); +}); + +describe('resolveMacosCommandPaths', () => { + it('deduplicates default warnings while retrying discovery and reporting different failures', async () => { + const warnings: string[] = []; + const previousWarn = console.warn; + console.warn = (message: string) => warnings.push(message); + let probes = 0; + const profile = createWorkspaceWritePermissionProfile(); + try { + for (let index = 0; index < 2; index++) { + assert.deepEqual( + await resolveMacosCommandPaths( + profile, + {}, + { + runCommand: async () => { + probes++; + return { status: 1 }; + }, + }, + ), + { executableRoots: [] }, + ); + } + assert.equal(probes, 2); + assert.equal( + warnings.filter((message) => message.includes('xcode-select probe failed')).length, + 1, + ); + assert.deepEqual(await resolveMacosCommandPaths(profile, { DEVELOPER_DIR: 'relative' }), { + executableRoots: [], + }); + assert.equal( + warnings.filter((message) => message.includes('developer directory is not absolute')) + .length, + 1, + ); + const diagnostics: string[] = []; + for (let index = 0; index < 2; index++) { + await resolveMacosCommandPaths( + profile, + { DEVELOPER_DIR: 'relative' }, + { + onDiscoveryFailure: (reason) => diagnostics.push(reason), + }, + ); + } + assert.equal(diagnostics.length, 2); + } finally { + console.warn = previousWarn; + } + }); + + it('does not add selected developer roots to restricted read-only profiles', async () => { + let validated = false; + const scratch = mkdtempSync(join(tmpdir(), 'maka-read-only-toolchain-')); + const developer = join(scratch, 'CommandLineTools'); + const library = join(developer, 'usr', 'lib'); + mkdirSync(library, { recursive: true }); + writeFileSync(join(library, 'libxcrun.dylib'), 'fixture'); + try { + assert.deepEqual( + await resolveMacosCommandPaths( + createReadOnlyPermissionProfile(), + { DEVELOPER_DIR: developer }, + { + validateAppleBinary: () => { + validated = true; + return true; + }, + }, + ), + { executableRoots: [] }, + ); + assert.equal(validated, false); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } + }); + + it('adds only validated developer roots to writable command profiles', async () => { + const scratch = mkdtempSync(join(tmpdir(), 'maka-writable-toolchain-')); + const developer = join(scratch, 'CommandLineTools'); + const library = join(developer, 'usr', 'lib'); + mkdirSync(library, { recursive: true }); + writeFileSync(join(library, 'libxcrun.dylib'), 'fixture'); + try { + assert.deepEqual( + await resolveMacosCommandPaths( + createWorkspaceWritePermissionProfile(), + { DEVELOPER_DIR: developer }, + { validateAppleBinary: () => true }, + ), + { executableRoots: [realpathSync(library)] }, + ); + } finally { + rmSync(scratch, { 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 46309bf5df3..9f2b78ae22a 100644 --- a/packages/runtime/src/__tests__/macos-seatbelt-smoke.test.ts +++ b/packages/runtime/src/__tests__/macos-seatbelt-smoke.test.ts @@ -21,7 +21,7 @@ import assert from 'node:assert/strict'; import { after, describe, it } from 'node:test'; import { existsSync } from 'node:fs'; import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; -import { join, resolve } from 'node:path'; +import { basename, dirname, join, resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; import { tmpdir } from 'node:os'; @@ -33,9 +33,16 @@ import { } from '@maka/core/permission-profile'; import { MACOS_SEATBELT_EXECUTABLE, MacosSeatbeltBackend } from '../sandbox/macos-seatbelt.js'; +import { resolveMacosCommandPaths } from '../sandbox/macos-command-paths.js'; import { SandboxManager } from '../sandbox/sandbox-manager.js'; const canRunSeatbelt = process.platform === 'darwin' && existsSync(MACOS_SEATBELT_EXECUTABLE); +const selectedDeveloperDirectory = + process.platform === 'darwin' + ? process.env.DEVELOPER_DIR?.trim() || + spawnSync('/usr/bin/xcode-select', ['-p'], { encoding: 'utf8' }).stdout.trim() + : ''; +const canRunAppleToolchain = selectedDeveloperDirectory.length > 0; async function makeWorkspace(): Promise { return realpath(await mkdtemp(join(tmpdir(), 'maka-seatbelt-workspace-'))); @@ -69,6 +76,10 @@ function runSeatbeltCommand( command: string, profile: PermissionProfile = createWorkspaceWritePermissionProfile(), includeTempRoots = false, + runtimePaths: { + executableRoots?: readonly string[]; + } = {}, + env: NodeJS.ProcessEnv = process.env, ) { const manager = new SandboxManager([new MacosSeatbeltBackend()]); const result = manager.transform({ @@ -81,6 +92,7 @@ function runSeatbeltCommand( pathContext: { workspaceRoots: [workspaceRoot], ...(includeTempRoots ? { tmpdir: tmpdir(), slashTmp: '/tmp' } : {}), + ...runtimePaths, }, }, }); @@ -90,7 +102,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 +158,51 @@ describe('macOS Seatbelt smoke', { skip: !canRunSeatbelt }, () => { assert.equal(child.status, 0, child.stderr); }); + it('starts Apple Git with the selected Apple toolchain without host-side Git config discovery', { + skip: !canRunAppleToolchain, + }, async () => { + const workspaceRoot = await makeWorkspace(); + const emptyHome = await realpath(await mkdtemp(join(tmpdir(), 'maka-git-home-'))); + cleanup.push(workspaceRoot, emptyHome); + await writeFile(join(workspaceRoot, 'visible.txt'), 'visible\n'); + const gitEnvironment = { + ...process.env, + HOME: emptyHome, + DEVELOPER_DIR: selectedDeveloperDirectory, + }; + const setup = spawnSync('/usr/bin/git', ['init'], { + cwd: workspaceRoot, + env: gitEnvironment, + encoding: 'utf8', + }); + assert.equal(setup.status, 0, setup.stderr); + + const runtimePaths = await resolveMacosCommandPaths( + createWorkspaceWritePermissionProfile(), + gitEnvironment, + ); + const canonicalDeveloperDirectory = await realpath(selectedDeveloperDirectory); + const expectedRoots = [await realpath(join(canonicalDeveloperDirectory, 'usr', 'lib'))]; + if (basename(canonicalDeveloperDirectory) !== 'CommandLineTools') { + expectedRoots.push( + await realpath(join(dirname(canonicalDeveloperDirectory), 'SharedFrameworks')), + ); + } + assert.deepEqual(runtimePaths.executableRoots, expectedRoots); + + const child = runSeatbeltCommand( + workspaceRoot, + '/usr/bin/git status --short', + createWorkspaceWritePermissionProfile(), + true, + runtimePaths, + gitEnvironment, + ); + + assert.equal(child.status, 0, child.stderr); + assert.match(child.stdout, /\?\? visible\.txt/); + }); + 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 b4156449390..fdf6b96d709 100644 --- a/packages/runtime/src/__tests__/macos-seatbelt.test.ts +++ b/packages/runtime/src/__tests__/macos-seatbelt.test.ts @@ -370,6 +370,57 @@ describe('buildSeatbeltPolicy', () => { }).policy; assert.match(policy, /\(require-not \(literal "\/outside\/locked\.txt"\)\)/); }); + + it('keeps executable and runtime-readable roots subject to canonicalized exact and parent denies', () => { + const scratch = mkdtempSync(join(tmpdir(), 'maka-seatbelt-runtime-deny-')); + const toolchain = join(scratch, 'toolchain'); + const alias = join(scratch, 'alias'); + mkdirSync(toolchain); + symlinkSync(toolchain, alias); + const profile: PermissionProfile = { + type: 'managed', + name: 'custom', + fileSystem: { + kind: 'restricted', + entries: [ + { kind: 'special', access: 'write', special: ':workspace_roots' }, + { kind: 'path', access: 'deny', path: alias, match: 'exact' }, + { kind: 'path', access: 'deny', path: scratch, match: 'subtree' }, + ], + }, + network: { kind: 'restricted' }, + }; + try { + const result = buildSeatbeltPolicy({ + profile, + pathContext: { + workspaceRoots: ['/repo'], + executableRoots: [toolchain], + runtimeReadableRoots: [toolchain], + }, + }); + assert.ok(result.definitionArgs.includes(`-DEXECUTABLE_ROOT_0=${realpathSync(toolchain)}`)); + const runtimeSection = result.policy.slice( + result.policy.indexOf('(allow file-read* file-test-existence\n'), + result.policy.indexOf('(allow file-read* file-test-existence file-map-executable'), + ); + assert.ok(runtimeSection); + assert.ok(runtimeSection.includes(`(require-not (literal "${realpathSync(toolchain)}"))`)); + assert.ok( + runtimeSection.includes(`(require-not (regex #"^${realpathSync(scratch)}(/.*)?$"))`), + ); + const executableSection = result.policy.slice( + result.policy.indexOf('(allow file-read* file-test-existence file-map-executable'), + ); + assert.match(executableSection, /\(subpath \(param "EXECUTABLE_ROOT_0"\)\)/); + assert.ok(executableSection.includes(`(require-not (literal "${realpathSync(toolchain)}"))`)); + assert.ok( + executableSection.includes(`(require-not (regex #"^${realpathSync(scratch)}(/.*)?$"))`), + ); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } + }); }); describe('createSeatbeltExecArgs', () => { diff --git a/packages/runtime/src/builtin-tools.ts b/packages/runtime/src/builtin-tools.ts index df4d176468c..8222b1518d7 100644 --- a/packages/runtime/src/builtin-tools.ts +++ b/packages/runtime/src/builtin-tools.ts @@ -87,6 +87,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 { resolveMacosCommandPaths } from './sandbox/macos-command-paths.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'; @@ -225,8 +226,8 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT declareSandboxBoundary: options.declareSandboxBoundary !== false, ...(options.sandboxManager ? { - transformCommand: ({ command, pty, requiredBoundary, ctx }) => { - const transformed = sandboxCommand( + transformCommand: async ({ command, pty, requiredBoundary, ctx }) => { + const transformed = await sandboxCommand( options.sandboxManager!, options.permissionProfile, sandboxPlatform, @@ -235,6 +236,7 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT ctx, requiredBoundary, 'background_command', + options.shellEnvironment, ); if (!options.shellEnvironment) return transformed; return { @@ -685,7 +687,7 @@ function buildExecutorBashTool( }); } const transformed = sandboxOptions.sandboxManager - ? sandboxCommand( + ? await sandboxCommand( sandboxOptions.sandboxManager, sandboxOptions.permissionProfile, sandboxOptions.sandboxPlatform, @@ -734,7 +736,7 @@ function buildExecutorBashTool( }; } -function sandboxCommand( +async function sandboxCommand( manager: SandboxManager, explicitProfile: PermissionProfile | undefined, platform: SandboxPlatform, @@ -743,7 +745,8 @@ function sandboxCommand( ctx: MakaToolContext, requiredBoundary?: SandboxBoundaryExpansion, domain: 'command' | 'background_command' = 'command', -): + environment?: Readonly>, +): Promise< | { argv?: readonly string[]; cwd: string; @@ -753,7 +756,8 @@ function sandboxCommand( profileName?: string; onCompletion?: (outcome: { successful: boolean }) => void; } - | undefined { + | undefined +> { const cwd = canonicalExistingPath(ctx.cwd); const boundary = ctx.executionBoundary; if (boundary?.kind === 'bypass' || boundary?.kind === 'external') return undefined; @@ -761,7 +765,7 @@ function sandboxCommand( boundary?.kind === 'managed' ? { profile: boundary.profile, workspaceRoots: [cwd] } : effectivePermissionProfile(explicitProfile, ctx.permissionMode ?? 'ask', cwd); - const env = { ...process.env }; + const env = { ...process.env, ...environment }; if (pty) { if (profileRequiresSandbox(effective.profile)) { throw new SandboxCommandError({ @@ -831,6 +835,15 @@ function sandboxCommand( }); } const onCompletion = preparedProfilePathCompletion(preparedProfile.paths); + // Nonblocking discovery is deliberately adjacent to policy construction. The selected + // path is canonicalized and code-sign validated, but is not fd-pinned; + // replacement after this point remains a documented residual limitation. + const macosPaths = + platform === 'darwin' + ? manager.shouldSandbox(effective.profile) + ? await resolveMacosCommandPaths(effective.profile, env, { signal: ctx.abortSignal }) + : { executableRoots: [] } + : undefined; let result: ReturnType; try { @@ -846,9 +859,12 @@ function sandboxCommand( workspaceRoots: effective.workspaceRoots, tmpdir: tmpdir(), ...(platform === 'win32' ? {} : { slashTmp: '/tmp' }), - ...(platform === 'darwin' + ...(macosPaths ? { - executableRoots: macosRuntimeExecutableRoots(process.execPath), + executableRoots: [ + ...macosRuntimeExecutableRoots(process.execPath), + ...macosPaths.executableRoots, + ], } : {}), ...(platform === 'linux' diff --git a/packages/runtime/src/sandbox/README.md b/packages/runtime/src/sandbox/README.md index 5441f5b7341..867b9230b01 100644 --- a/packages/runtime/src/sandbox/README.md +++ b/packages/runtime/src/sandbox/README.md @@ -38,6 +38,7 @@ Code and focused tests are the final authority. Windows enforcement work is trac - `types.ts` defines sandbox selection, command, path-context, execution-request, and typed failure contracts. - `sandbox-manager.ts` decides whether a profile requires a sandbox, selects a platform backend, and delegates transformation. +- `macos-command-paths.ts` resolves canonical Apple developer runtime directories and verifies the Apple signer before command policy construction. - `macos-seatbelt.ts` builds the Seatbelt policy and wraps inner argv with `/usr/bin/sandbox-exec`. - `linux-sandbox.ts` builds the bubblewrap mounts, namespace arguments, and network seccomp filter. - `linux-capability.ts` proves bubblewrap and namespace availability before selection is usable. @@ -52,6 +53,10 @@ Code and focused tests are the final authority. Windows enforcement work is trac - Unrestricted, disabled, and external profiles do not add a Maka-managed local sandbox. - `require` forces platform sandbox selection; `forbid` selects host execution and is an internal orchestration input, not proof of approval. - macOS selects the Seatbelt backend and fails closed when the backend is unavailable. +- Writable sandboxed macOS commands admit the selected CLT/Xcode library roots only after Apple-anchor signature verification. Selection and verification use nonblocking subprocesses, each with a one-second timeout; failure adds no roots. Read-only and unsandboxed commands perform no toolchain discovery. +- Explicit path denies also apply to executable and runtime-readable roots, including filesystem-worker dependency roots. A deny overlapping required libraries can prevent worker startup; these implementation grants do not override the requested boundary. +- `DEVELOPER_DIR` is a trusted Host-process setting, not a model or plugin input. Apple-signature verification authenticates `libxcrun.dylib`, not the entire toolchain tree; the Host must choose an installation whose library directories it trusts. +- The selected toolchain is canonicalized and revalidated immediately before policy construction, without process-lifetime caching. Its canonical directory is not fd-pinned, so replacement before child startup remains a residual limitation. - Linux selects the bubblewrap backend and fails closed when its executable, namespace probe, or requested profile cannot be enforced. - Windows selects the AppContainer broker only when its packaged native resource exists; otherwise it @@ -96,6 +101,7 @@ A bypass boundary, unrestricted managed profiles, and disabled profiles do not r - Core profile factories, compiler, and matchers: `packages/core/src/__tests__/permission-profile*.test.ts` - Selection and transformation: `packages/runtime/src/__tests__/sandbox-manager.test.ts` +- macOS toolchain selection and signer validation: `packages/runtime/src/__tests__/macos-command-paths.test.ts` - macOS policy and wrapper: `packages/runtime/src/__tests__/macos-seatbelt.test.ts` - macOS platform behavior: `packages/runtime/src/__tests__/macos-seatbelt-smoke.test.ts` - macOS filesystem-worker behavior: `packages/runtime/src/__tests__/filesystem-worker-smoke.test.ts` diff --git a/packages/runtime/src/sandbox/macos-command-paths.ts b/packages/runtime/src/sandbox/macos-command-paths.ts new file mode 100644 index 00000000000..58548fe5021 --- /dev/null +++ b/packages/runtime/src/sandbox/macos-command-paths.ts @@ -0,0 +1,253 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { spawn } from 'node:child_process'; +import { existsSync, realpathSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path'; + +import { isReadOnlyPermissionProfile, type PermissionProfile } from '@maka/core/permission-profile'; + +const XCODE_SELECT_TIMEOUT_MS = 1_000; +const CODESIGN_TIMEOUT_MS = 1_000; + +// Deduplicate diagnostics only: rediscover paths for every command so toolchain +// changes and failed verification never reuse an earlier execution grant. +const reportedDiscoveryFailures = new Set(); +const MAX_REPORTED_DISCOVERY_FAILURES = 16; + +function reportDiscoveryFailure(message: string): void { + if (reportedDiscoveryFailures.has(message)) return; + if (reportedDiscoveryFailures.size >= MAX_REPORTED_DISCOVERY_FAILURES) { + reportedDiscoveryFailures.delete(reportedDiscoveryFailures.values().next().value!); + } + reportedDiscoveryFailures.add(message); + console.warn(`[sandbox:macos] ${message}`); +} + +interface MacosDeveloperCommandResult { + status: number | null; + stdout?: string; +} + +export type MacosDeveloperCommandRunner = ( + executable: string, + args: readonly string[], + options: { + encoding?: 'utf8'; + timeout: number; + killSignal: 'SIGKILL'; + signal?: AbortSignal; + stdio: ['ignore', 'pipe', 'ignore'] | 'ignore'; + }, +) => MacosDeveloperCommandResult | Promise; + +export interface MacosCommandPaths { + executableRoots: readonly string[]; +} + +export interface MacosDeveloperPathOptions { + developerDir?: string; + homeDir?: string; + selectDeveloperDir?: () => string | undefined | Promise; + validateAppleBinary?: (path: string) => boolean | Promise; + runCommand?: MacosDeveloperCommandRunner; + signal?: AbortSignal; + onDiscoveryFailure?: (reason: string) => void; +} + +/** Resolve only the dynamic-library directories used by the selected Apple toolchain. */ +export async function resolveMacosDeveloperExecutableRoots( + options: MacosDeveloperPathOptions = {}, +): Promise { + if (options.signal?.aborted) return []; + const fail = (reason: string): readonly string[] => { + if (!options.signal?.aborted) { + (options.onDiscoveryFailure ?? reportDiscoveryFailure)( + `Apple toolchain discovery failed closed: ${reason}. No additional executable roots granted.`, + ); + } + return []; + }; + const commandRunner = options.runCommand ?? runDeveloperCommand; + const runCommand: MacosDeveloperCommandRunner = (executable, args, commandOptions) => + commandRunner(executable, args, { ...commandOptions, signal: options.signal }); + let probeFailure: string | undefined; + const selected = + options.developerDir?.trim() || + (await ( + options.selectDeveloperDir ?? + (() => + readSelectedDeveloperDirectory(runCommand, (reason) => { + probeFailure = reason; + })) + )()); + if (!selected) return fail(probeFailure ?? 'no developer directory selected'); + if (!isAbsolute(selected)) return fail('developer directory is not absolute'); + + let developerRoot: string; + try { + developerRoot = realpathSync(selected); + if (basename(developerRoot).endsWith('.app')) { + const bundleRoot = developerRoot; + developerRoot = realpathSync(join(bundleRoot, 'Contents', 'Developer')); + if (!isPathWithin(developerRoot, bundleRoot)) + return fail('developer directory escapes bundle'); + } + } catch { + return fail('developer directory cannot be resolved'); + } + + const homeRoot = canonicalDirectory(options.homeDir ?? homedir()); + if (developerRoot === '/' || (homeRoot && isPathWithin(developerRoot, homeRoot))) { + return fail('developer directory is root or inside home'); + } + + const libraryRoot = canonicalDirectory(join(developerRoot, 'usr', 'lib')); + if (!libraryRoot || !isPathWithin(libraryRoot, developerRoot)) { + return fail('toolchain library directory is missing or escapes developer directory'); + } + const xcrunLibrary = canonicalRegularFile(join(libraryRoot, 'libxcrun.dylib')); + if (!xcrunLibrary || !isPathWithin(xcrunLibrary, libraryRoot)) { + return fail('libxcrun is missing or escapes library directory'); + } + if ( + !(await (options.validateAppleBinary ?? ((path) => validateAppleBinary(path, runCommand)))( + xcrunLibrary, + )) + ) { + return fail('Apple signature verification failed or timed out'); + } + + if (basename(developerRoot) === 'CommandLineTools') return [libraryRoot]; + + if (basename(developerRoot) !== 'Developer' || basename(dirname(developerRoot)) !== 'Contents') { + return fail('unsupported developer directory layout'); + } + + const contentsRoot = dirname(developerRoot); + const sharedFrameworks = canonicalDirectory(join(contentsRoot, 'SharedFrameworks')); + if (!sharedFrameworks || !isPathWithin(sharedFrameworks, contentsRoot)) { + return fail('SharedFrameworks is missing or escapes Xcode contents'); + } + return [libraryRoot, sharedFrameworks]; +} + +export async function resolveMacosCommandPaths( + profile: PermissionProfile, + env: Readonly>, + options: Omit = {}, +): Promise { + // Runtime roots are an implementation allowance for writable command + // sessions. They must not silently widen a restricted read-only profile. + if (profile.type === 'managed' && isReadOnlyPermissionProfile(profile)) { + return { executableRoots: [] }; + } + return { + executableRoots: await resolveMacosDeveloperExecutableRoots({ + developerDir: env.DEVELOPER_DIR, + homeDir: env.HOME, + ...options, + }), + }; +} + +async function readSelectedDeveloperDirectory( + runCommand: MacosDeveloperCommandRunner, + onFailure: (reason: string) => void, +): Promise { + const result = await runCommand('/usr/bin/xcode-select', ['-p'], { + encoding: 'utf8', + timeout: XCODE_SELECT_TIMEOUT_MS, + killSignal: 'SIGKILL', + stdio: ['ignore', 'pipe', 'ignore'], + }); + if (result.status !== 0) { + onFailure( + result.status === null + ? 'xcode-select probe interrupted or timed out' + : 'xcode-select probe failed', + ); + return undefined; + } + return result.stdout?.trim() || undefined; +} + +async function validateAppleBinary( + path: string, + runCommand: MacosDeveloperCommandRunner, +): Promise { + const result = await runCommand( + '/usr/bin/codesign', + ['--verify', '--strict', '-R=anchor apple', path], + { + timeout: CODESIGN_TIMEOUT_MS, + killSignal: 'SIGKILL', + stdio: 'ignore', + }, + ); + return result.status === 0; +} + +const runDeveloperCommand: MacosDeveloperCommandRunner = (executable, args, options) => + new Promise((resolve) => { + const child = spawn(executable, [...args], options); + let stdout = ''; + let exceededBuffer = false; + child.stdout?.setEncoding('utf8'); + child.stdout?.on('data', (chunk: string) => { + if (exceededBuffer) return; + stdout += chunk; + if (Buffer.byteLength(stdout) > 64 * 1024) { + exceededBuffer = true; + child.kill('SIGKILL'); + } + }); + child.on('error', () => resolve({ status: null })); + child.on('close', (status) => { + resolve({ + status: exceededBuffer ? null : status, + ...(options.encoding === 'utf8' ? { stdout } : {}), + }); + }); + }); + +function canonicalDirectory(path: string): string | undefined { + try { + const canonical = realpathSync(path); + return statSync(canonical).isDirectory() ? canonical : undefined; + } catch { + return undefined; + } +} + +function canonicalRegularFile(path: string): string | undefined { + try { + if (!existsSync(path)) return undefined; + const canonical = realpathSync(path); + return statSync(canonical).isFile() ? canonical : undefined; + } catch { + return undefined; + } +} + +function isPathWithin(path: string, root: string): boolean { + const delta = relative(root, path); + return delta === '' || (delta !== '..' && !delta.startsWith(`..${sep}`)); +} diff --git a/packages/runtime/src/sandbox/macos-seatbelt.ts b/packages/runtime/src/sandbox/macos-seatbelt.ts index f4bdae5576c..fb003620361 100644 --- a/packages/runtime/src/sandbox/macos-seatbelt.ts +++ b/packages/runtime/src/sandbox/macos-seatbelt.ts @@ -491,15 +491,20 @@ function buildWritableRootsPolicy(roots: ResolvedRoots): string { function buildRuntimeRootsPolicy(roots: ResolvedRoots): string { const sections: string[] = []; + const denyRequirements = deniedRootRequirements(roots.deniedRoots); if (roots.runtimeReadableRoots.length > 0) { const clauses = roots.runtimeReadableRoots - .map((_, index) => ` (subpath (param "RUNTIME_READABLE_ROOT_${index}"))`) + .map((_, index) => + accessRootClause(`(subpath (param "RUNTIME_READABLE_ROOT_${index}"))`, denyRequirements), + ) .join('\n'); sections.push(`(allow file-read* file-test-existence\n${clauses})`); } if (roots.executableRoots.length > 0) { const clauses = roots.executableRoots - .map((_, index) => ` (subpath (param "EXECUTABLE_ROOT_${index}"))`) + .map((_, index) => + accessRootClause(`(subpath (param "EXECUTABLE_ROOT_${index}"))`, denyRequirements), + ) .join('\n'); sections.push(`(allow file-read* file-test-existence file-map-executable\n${clauses})`); } diff --git a/packages/runtime/src/shell-tools.ts b/packages/runtime/src/shell-tools.ts index 7be0ee63dfa..2846edadad6 100644 --- a/packages/runtime/src/shell-tools.ts +++ b/packages/runtime/src/shell-tools.ts @@ -172,6 +172,15 @@ export function buildLocalForegroundBashTool( }); } +interface SandboxedShellCommand { + argv?: readonly string[]; + cwd: string; + env?: NodeJS.ProcessEnv; + fdInputs?: readonly ChildFdInput[]; + sandboxType?: SandboxType; + onCompletion?: (outcome: { successful: boolean }) => void; +} + export function buildManagedBashTool( shellRuns: ShellRunLauncher, options: { @@ -211,16 +220,7 @@ export function buildManagedBashTool( pty: boolean; requiredBoundary?: SandboxBoundaryExpansion; ctx: MakaToolContext; - }) => - | { - argv?: readonly string[]; - cwd: string; - env?: NodeJS.ProcessEnv; - fdInputs?: readonly ChildFdInput[]; - sandboxType?: SandboxType; - onCompletion?: (outcome: { successful: boolean }) => void; - } - | undefined; + }) => SandboxedShellCommand | undefined | Promise; } = {}, ): MakaTool { const shell = options.shell ?? { plan: defaultShellPlan() }; @@ -290,7 +290,7 @@ export function buildManagedBashTool( selectedBashBoundaryExpansion(input), ctx, ); - const transformed = options.transformCommand?.({ + const transformed = await options.transformCommand?.({ command, pty: pty === true, ...(normalizedRequiredBoundary ? { requiredBoundary: normalizedRequiredBoundary } : {}),