Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions packages/runtime/src/__tests__/macos-seatbelt-smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -79,6 +80,7 @@ function runSeatbeltCommand(
profile,
pathContext: {
workspaceRoots: [workspaceRoot],
...(includeTempRoots ? { tmpdir: tmpdir(), slashTmp: '/tmp' } : {}),
},
},
});
Expand Down Expand Up @@ -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-')));
Expand Down
100 changes: 97 additions & 3 deletions packages/runtime/src/__tests__/macos-seatbelt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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')}`,
]);
});

Expand All @@ -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());

Expand Down Expand Up @@ -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', () => {
Expand Down
79 changes: 70 additions & 9 deletions packages/runtime/src/sandbox/macos-seatbelt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)),
};
}

Expand All @@ -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)) {
Expand Down