feat(FN-4638): complete Step 3 — add sandbox-exec policy adapter
Fusion-Task-Id: FN-4638 Fusion-Task-Lineage: a6dcc3d9-b7ab-42c1-af88-c9e09f770d92
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
fusionWorktreePreset,
|
||||
policyToSbplProfile,
|
||||
SandboxPolicyError,
|
||||
sbplEscape,
|
||||
type SandboxExecContext,
|
||||
} from "../../sandbox/sandbox-exec-policy.js";
|
||||
|
||||
const ctx: SandboxExecContext = {
|
||||
worktreePath: "/tmp/worktree",
|
||||
repoRootPath: "/tmp/repo",
|
||||
pnpmStorePath: "/Users/test/Library/pnpm/store",
|
||||
nodeBinPath: "/usr/local/bin/node",
|
||||
homeDir: "/Users/test",
|
||||
};
|
||||
|
||||
describe("sandbox-exec policy", () => {
|
||||
it("escapes sbpl paths", () => {
|
||||
expect(sbplEscape('a\\b"c d')).toBe('a\\\\b\\"c d');
|
||||
expect(sbplEscape("emoji-📦")).toContain("\\x");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ allowNetwork: true, expected: "(allow network-outbound)" },
|
||||
{ allowNetwork: false, expected: "(deny network*)" },
|
||||
])("emits network clauses", ({ allowNetwork, expected }) => {
|
||||
const profile = policyToSbplProfile({ allowNetwork }, ctx);
|
||||
expect(profile).toContain("(version 1)");
|
||||
expect(profile).toContain(expected);
|
||||
});
|
||||
|
||||
it("includes defaults plus custom read/write paths", () => {
|
||||
const profile = policyToSbplProfile(
|
||||
{
|
||||
allowNetwork: true,
|
||||
allowedWritePaths: ["/tmp/custom write"],
|
||||
allowedReadPaths: ["/opt/custom-read"],
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(profile).toContain("(allow file-write* (subpath \"/tmp/worktree\"))");
|
||||
expect(profile).toContain("(allow file-write* (subpath \"/Users/test/Library/pnpm/store\"))");
|
||||
expect(profile).toContain("(allow file-write* (subpath \"/tmp/custom write\"))");
|
||||
expect(profile).toContain("(allow file-read* (subpath \"/opt/custom-read\"))");
|
||||
});
|
||||
|
||||
it("guards port 4040", () => {
|
||||
expect(() => policyToSbplProfile({ allowNetwork: true, allowedPorts: [4040] }, ctx)).toThrow(SandboxPolicyError);
|
||||
});
|
||||
|
||||
it("guards fusion writes", () => {
|
||||
expect(() =>
|
||||
policyToSbplProfile(
|
||||
{
|
||||
allowNetwork: true,
|
||||
allowedWritePaths: ["/tmp/repo/.fusion/tasks"],
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).toThrow(SandboxPolicyError);
|
||||
});
|
||||
|
||||
it("preset enables pnpm-friendly paths", () => {
|
||||
const profile = policyToSbplProfile(fusionWorktreePreset(ctx), ctx);
|
||||
expect(profile).toContain("(allow file-write* (subpath \"/tmp/worktree\"))");
|
||||
expect(profile).toContain("(allow file-write* (subpath \"/Users/test/Library/pnpm/store\"))");
|
||||
expect(profile).toContain("(deny network-bind (local ip \"*:4040\"))");
|
||||
});
|
||||
});
|
||||
103
packages/engine/src/sandbox/sandbox-exec-policy.ts
Normal file
103
packages/engine/src/sandbox/sandbox-exec-policy.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
import {
|
||||
SBPL_BASE_ALLOW,
|
||||
SBPL_FILE_READ_BASE,
|
||||
SBPL_HEADER,
|
||||
SBPL_NETWORK_ALLOW_OUTBOUND,
|
||||
SBPL_NETWORK_DENY_ALL,
|
||||
SBPL_TMP_WRITE,
|
||||
} from "./sandbox-exec-profile-templates.js";
|
||||
import type { SandboxPolicy } from "./types.js";
|
||||
|
||||
export class SandboxPolicyError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "SandboxPolicyError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface SandboxExecPolicy extends SandboxPolicy {
|
||||
failureMode?: "fail-hard" | "fallback-native";
|
||||
allowedPorts?: number[];
|
||||
allowPort4040Override?: boolean;
|
||||
}
|
||||
|
||||
export interface SandboxExecContext {
|
||||
worktreePath: string;
|
||||
repoRootPath: string;
|
||||
pnpmStorePath: string;
|
||||
nodeBinPath: string;
|
||||
homeDir: string;
|
||||
tmpDirOverride?: string;
|
||||
}
|
||||
|
||||
function uniq(paths: string[]): string[] {
|
||||
return [...new Set(paths.map((p) => resolve(p)))];
|
||||
}
|
||||
|
||||
export function sbplEscape(value: string): string {
|
||||
const bytes = Buffer.from(value, "utf8");
|
||||
let out = "";
|
||||
for (const byte of bytes) {
|
||||
if (byte === 0x22) out += '\\"';
|
||||
else if (byte === 0x5c) out += "\\\\";
|
||||
else if (byte >= 0x20 && byte <= 0x7e) out += String.fromCharCode(byte);
|
||||
else out += `\\x${byte.toString(16).padStart(2, "0")}`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function asSubpath(path: string): string {
|
||||
return `(subpath "${sbplEscape(path)}")`;
|
||||
}
|
||||
|
||||
function ensureNoFusionWrites(paths: string[], repoRootPath: string): void {
|
||||
const fusionRoot = resolve(repoRootPath, ".fusion");
|
||||
const fusionDb = resolve(fusionRoot, "fusion.db");
|
||||
for (const candidate of paths) {
|
||||
const resolved = resolve(candidate);
|
||||
if (resolved === fusionRoot || resolved === fusionDb || resolved.startsWith(`${fusionRoot}/`)) {
|
||||
throw new SandboxPolicyError("Sandbox policy cannot include writable paths under .fusion/.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function fusionWorktreePreset(ctx: SandboxExecContext): SandboxExecPolicy {
|
||||
return {
|
||||
allowNetwork: true,
|
||||
allowedReadPaths: [ctx.repoRootPath],
|
||||
allowedWritePaths: [ctx.worktreePath, ctx.pnpmStorePath],
|
||||
};
|
||||
}
|
||||
|
||||
export function policyToSbplProfile(policy: SandboxExecPolicy, ctx: SandboxExecContext): string {
|
||||
if (policy.allowedPorts?.includes(4040) && policy.allowPort4040Override !== true) {
|
||||
throw new SandboxPolicyError("Port 4040 is reserved and cannot be allowed in sandbox policy.");
|
||||
}
|
||||
|
||||
const tmpDir = resolve(ctx.tmpDirOverride ?? "/private/tmp");
|
||||
const nodeDir = dirname(ctx.nodeBinPath);
|
||||
|
||||
const writePaths = uniq([ctx.worktreePath, ctx.pnpmStorePath, ...(policy.allowedWritePaths ?? [])]);
|
||||
ensureNoFusionWrites(writePaths, ctx.repoRootPath);
|
||||
|
||||
const readPaths = uniq([
|
||||
...((policy.allowedReadPaths ?? []).length ? (policy.allowedReadPaths ?? []) : [ctx.repoRootPath]),
|
||||
...(ctx.repoRootPath !== ctx.worktreePath ? [ctx.repoRootPath] : []),
|
||||
nodeDir,
|
||||
]).filter((path) => !writePaths.includes(path));
|
||||
|
||||
const lines = [SBPL_HEADER, SBPL_BASE_ALLOW, SBPL_FILE_READ_BASE, SBPL_TMP_WRITE, `(allow file-read* ${asSubpath(tmpDir)})`, `(allow file-write* ${asSubpath(tmpDir)})`];
|
||||
|
||||
for (const readPath of readPaths) {
|
||||
lines.push(`(allow file-read* ${asSubpath(readPath)})`);
|
||||
}
|
||||
|
||||
for (const writePath of writePaths) {
|
||||
lines.push(`(allow file-write* ${asSubpath(writePath)})`);
|
||||
}
|
||||
|
||||
lines.push(policy.allowNetwork ? SBPL_NETWORK_ALLOW_OUTBOUND : SBPL_NETWORK_DENY_ALL);
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
Reference in New Issue
Block a user