diff --git a/packages/runtime/src/__tests__/macos-seatbelt-smoke.test.ts b/packages/runtime/src/__tests__/macos-seatbelt-smoke.test.ts index 011194568d..46309bf5df 100644 --- a/packages/runtime/src/__tests__/macos-seatbelt-smoke.test.ts +++ b/packages/runtime/src/__tests__/macos-seatbelt-smoke.test.ts @@ -68,6 +68,7 @@ function runSeatbeltCommand( workspaceRoot: string, command: string, profile: PermissionProfile = createWorkspaceWritePermissionProfile(), + includeTempRoots = false, ) { const manager = new SandboxManager([new MacosSeatbeltBackend()]); const result = manager.transform({ @@ -79,6 +80,7 @@ function runSeatbeltCommand( profile, pathContext: { workspaceRoots: [workspaceRoot], + ...(includeTempRoots ? { tmpdir: tmpdir(), slashTmp: '/tmp' } : {}), }, }, }); @@ -129,6 +131,21 @@ describe('macOS Seatbelt smoke', { skip: !canRunSeatbelt }, () => { assert.match(readAncestorFile.stderr, /Operation not permitted/); }); + it('allows temp writes when workspace and temp roots use symlinked paths', async () => { + const workspaceRoot = await mkdtemp(join(tmpdir(), 'maka-seatbelt-temp-workspace-')); + const slashTmpFile = join('/tmp', `maka-seatbelt-slash-tmp-${process.pid}-${Date.now()}`); + cleanup.push(workspaceRoot, slashTmpFile); + + const child = runSeatbeltCommand( + workspaceRoot, + `created=$(/usr/bin/mktemp -d "$TMPDIR/maka-seatbelt.XXXXXX") && /usr/bin/touch ${JSON.stringify(slashTmpFile)} && /bin/rm -rf "$created" ${JSON.stringify(slashTmpFile)}`, + createWorkspaceWritePermissionProfile(), + true, + ); + + assert.equal(child.status, 0, child.stderr); + }); + 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 431247fd35..b415644939 100644 --- a/packages/runtime/src/__tests__/macos-seatbelt.test.ts +++ b/packages/runtime/src/__tests__/macos-seatbelt.test.ts @@ -18,6 +18,9 @@ */ import assert from 'node:assert/strict'; +import { chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { describe, it } from 'node:test'; import { @@ -163,10 +166,10 @@ describe('buildSeatbeltPolicy', () => { assert.deepEqual(result.definitionArgs, [ '-DREADABLE_ROOT_0=/repo', '-DREADABLE_ROOT_1=/private/tmp/maka-test', - '-DREADABLE_ROOT_2=/tmp', + `-DREADABLE_ROOT_2=${realpathSync('/tmp')}`, '-DWRITABLE_ROOT_0=/repo', '-DWRITABLE_ROOT_1=/private/tmp/maka-test', - '-DWRITABLE_ROOT_2=/tmp', + `-DWRITABLE_ROOT_2=${realpathSync('/tmp')}`, ]); }); @@ -179,6 +182,95 @@ describe('buildSeatbeltPolicy', () => { ); }); + it('resolves symlinked temp roots before passing them to Seatbelt', () => { + const linkedTempRoot = mkdtempSync(join(tmpdir(), 'maka-seatbelt-root-')); + + try { + const result = buildSeatbeltPolicy({ + profile: createWorkspaceWritePermissionProfile(), + pathContext: { + workspaceRoots: ['/repo'], + tmpdir: linkedTempRoot, + slashTmp: '/tmp', + }, + }); + const canonicalTempRoot = realpathSync(linkedTempRoot); + + assert.ok(result.definitionArgs.includes(`-DREADABLE_ROOT_1=${canonicalTempRoot}`)); + assert.ok(result.definitionArgs.includes(`-DWRITABLE_ROOT_1=${canonicalTempRoot}`)); + } finally { + rmSync(linkedTempRoot, { recursive: true, force: true }); + } + }); + + it('canonicalizes a missing denied leaf through its existing symlinked ancestor', () => { + const scratch = mkdtempSync(join(tmpdir(), 'maka-seatbelt-deny-')); + const realTarget = join(scratch, 'real'); + const linkedTarget = join(scratch, 'link'); + mkdirSync(realTarget); + symlinkSync(realTarget, linkedTarget); + + try { + const profile: PermissionProfile = { + type: 'managed', + name: 'custom', + fileSystem: { + kind: 'restricted', + entries: [ + { kind: 'path', access: 'write', path: linkedTarget, match: 'subtree' }, + { + kind: 'path', + access: 'deny', + path: join(linkedTarget, 'blocked.txt'), + match: 'exact', + }, + ], + }, + network: { kind: 'restricted' }, + }; + const result = buildSeatbeltPolicy({ profile, pathContext: { workspaceRoots: ['/repo'] } }); + const canonicalTarget = realpathSync(linkedTarget); + + assert.ok(result.definitionArgs.includes(`-DWRITABLE_ROOT_0=${canonicalTarget}`)); + assert.ok( + result.policy.includes(`(require-not (literal "${join(canonicalTarget, 'blocked.txt')}"))`), + ); + assert.ok(!result.policy.includes(`(literal "${join(linkedTarget, 'blocked.txt')}")`)); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } + }); + + it('fails policy construction when a root cannot be canonicalized', function (t) { + if (process.getuid?.() === 0) return t.skip('EACCES does not apply to root'); + const scratch = mkdtempSync(join(tmpdir(), 'maka-seatbelt-eacces-')); + const sealed = join(scratch, 'sealed'); + mkdirSync(sealed); + chmodSync(sealed, 0o000); + + try { + const profile: PermissionProfile = { + type: 'managed', + name: 'custom', + fileSystem: { + kind: 'restricted', + entries: [ + { kind: 'path', access: 'write', path: scratch, match: 'subtree' }, + { kind: 'path', access: 'deny', path: join(sealed, 'blocked.txt'), match: 'exact' }, + ], + }, + network: { kind: 'restricted' }, + }; + + assert.throws(() => + buildSeatbeltPolicy({ profile, pathContext: { workspaceRoots: ['/repo'] } }), + ); + } finally { + chmodSync(sealed, 0o700); + rmSync(scratch, { recursive: true, force: true }); + } + }); + it('keeps workspace metadata writable in the standard workspace profile', () => { const policy = policyText(createWorkspaceWritePermissionProfile()); @@ -214,12 +306,14 @@ describe('buildSeatbeltPolicy', () => { }); it('escapes workspace root before building protected metadata regex requirements', () => { + // The workspace does not exist, so only its `/tmp` ancestor is canonicalized. + const workspaceRoot = join(realpathSync('/tmp'), 'repo.(test)+[x]'); const result = buildSeatbeltPolicy({ profile: workspaceWriteProfileWithCustomProtectedMetadata(), pathContext: { workspaceRoots: ['/tmp/repo.(test)+[x]'] }, }); - assert.match(result.policy, /\^\/tmp\/repo\\\.\\\(test\\\)\\\+\\\[x\\\]\/\(\.\*\/\)\?\\\.git/); + assert.ok(result.policy.includes(`#"^${escapeSeatbeltRegex(workspaceRoot)}/(.*/)?\\.git`)); }); it('emits network restricted and enabled policy sections', () => { diff --git a/packages/runtime/src/sandbox/macos-seatbelt.ts b/packages/runtime/src/sandbox/macos-seatbelt.ts index fb0dd5d3a2..f9abb58430 100644 --- a/packages/runtime/src/sandbox/macos-seatbelt.ts +++ b/packages/runtime/src/sandbox/macos-seatbelt.ts @@ -17,6 +17,9 @@ * under the License. */ +import { readlinkSync, realpathSync } from 'node:fs'; +import { basename, dirname, resolve } from 'node:path'; + import type { PermissionProfile } from '@maka/core/permission-profile'; import type { @@ -362,11 +365,16 @@ function resolveRoots(profile: PermissionProfile, pathContext: SandboxPathContex deniedRoots, protectedWritableRoots: profile.fileSystem.protectedMetadata && writableRoots.length > 0 - ? uniqueRoots([...protectedWritableRoots, ...pathContext.workspaceRoots]) + ? uniqueRoots([ + ...protectedWritableRoots, + ...pathContext.workspaceRoots.map(resolveRootPath), + ]) : [], protectedMetadataNames: profile.fileSystem.protectedMetadata?.names ?? [], - runtimeReadableRoots: uniqueRoots(pathContext.runtimeReadableRoots ?? []), - executableRoots: uniqueRoots(pathContext.executableRoots ?? []), + runtimeReadableRoots: uniqueRoots( + (pathContext.runtimeReadableRoots ?? []).map(resolveRootPath), + ), + executableRoots: uniqueRoots((pathContext.executableRoots ?? []).map(resolveRootPath)), }; } @@ -375,23 +383,76 @@ function rootsForEntry( pathContext: SandboxPathContext, ): readonly ResolvedRoot[] { if (entry.kind === 'path') { - return [{ path: entry.path, match: entry.match ?? 'subtree' }]; + return [{ path: resolveRootPath(entry.path), match: entry.match ?? 'subtree' }]; } switch (entry.special) { case ':root': - return [{ path: '/', match: 'subtree' }]; + return [{ path: resolveRootPath('/'), match: 'subtree' }]; case ':workspace_roots': - return pathContext.workspaceRoots.map((path) => ({ path, match: 'subtree' as const })); + return pathContext.workspaceRoots.map((path) => ({ + path: resolveRootPath(path), + match: 'subtree' as const, + })); case ':tmpdir': - return pathContext.tmpdir ? [{ path: pathContext.tmpdir, match: 'subtree' }] : []; + return pathContext.tmpdir + ? [{ path: resolveRootPath(pathContext.tmpdir), match: 'subtree' }] + : []; case ':slash_tmp': - return [{ path: pathContext.slashTmp ?? '/tmp', match: 'subtree' }]; + return [{ path: resolveRootPath(pathContext.slashTmp ?? '/tmp'), match: 'subtree' }]; case ':minimal': - return (pathContext.minimalRoots ?? []).map((path) => ({ path, match: 'subtree' as const })); + return (pathContext.minimalRoots ?? []).map((path) => ({ + path: resolveRootPath(path), + match: 'subtree' as const, + })); } } +const MAX_DANGLING_SYMLINK_HOPS = 40; + +/** + * Seatbelt evaluates kernel-resolved paths, so every root must be emitted in + * canonical form. A root may not exist yet (a deny for a file that has not + * been created), so canonicalize the deepest existing ancestor and re-append + * the missing tail, mirroring `realpathAllowMissing`; an allow and its deny + * then stay in the same path space. Any other resolution failure (EACCES, + * ELOOP, ...) propagates and fails policy construction: emitting a root in + * lexical path space could split an allow from its deny across aliases. + */ +function resolveRootPath(path: string): string { + let cursor = resolve(path); + const missing: string[] = []; + let hops = 0; + while (true) { + try { + return resolve(realpathSync(cursor), ...missing.reverse()); + } catch (error) { + if (!isMissingPathError(error)) throw error; + let link: string | null = null; + try { + link = readlinkSync(cursor); + } catch { + link = null; + } + if (link !== null) { + if (++hops > MAX_DANGLING_SYMLINK_HOPS) + throw new Error(`Root ${JSON.stringify(path)} traverses too many dangling symlinks.`); + cursor = resolve(dirname(cursor), link); + continue; + } + const parent = dirname(cursor); + if (parent === cursor) throw error; + missing.push(basename(cursor)); + cursor = parent; + } + } +} + +function isMissingPathError(error: unknown): boolean { + const code = (error as { code?: unknown } | null)?.code; + return code === 'ENOENT' || code === 'ENOTDIR'; +} + function addUniqueResolvedRoots(target: ResolvedRoot[], roots: readonly ResolvedRoot[]): void { for (const root of roots) { if (!target.some((existing) => existing.path === root.path && existing.match === root.match)) {