feat(FN-4637): complete Step 2 — add bubblewrap policy adapter
Fusion-Task-Id: FN-4637 Fusion-Task-Lineage: 564c5692-3aaf-4396-9306-a395703cf365
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { SandboxPolicyError, fusionWorktreePreset, policyToBwrapArgs, type BubblewrapPolicyContext } from "../../sandbox/bubblewrap-policy.js";
|
||||
|
||||
function baseCtx(overrides: Partial<BubblewrapPolicyContext> = {}): BubblewrapPolicyContext {
|
||||
return {
|
||||
worktreePath: "/repo/.worktrees/fn-1",
|
||||
repoRootPath: "/repo",
|
||||
pnpmStorePath: "/home/u/.pnpm-store",
|
||||
nodeBinPath: "/usr/bin/node",
|
||||
homeDir: "/home/u",
|
||||
pathExists: (path) => !path.includes("missing"),
|
||||
envSource: {
|
||||
PATH: "/usr/bin",
|
||||
HOME: "/home/u",
|
||||
USER: "u",
|
||||
LANG: "en_US.UTF-8",
|
||||
FUSION_RUN_ID: "run-1",
|
||||
SECRET_TOKEN: "hidden",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("policyToBwrapArgs", () => {
|
||||
it.each([
|
||||
{ allowNetwork: true, expected: false },
|
||||
{ allowNetwork: false, expected: true },
|
||||
])("maps allowNetwork=$allowNetwork to --unshare-net=$expected", ({ allowNetwork, expected }) => {
|
||||
const args = policyToBwrapArgs({ allowNetwork }, baseCtx());
|
||||
expect(args.includes("--unshare-net")).toBe(expected);
|
||||
});
|
||||
|
||||
it("includes defaults for writable mounts and env allowlist", () => {
|
||||
const args = policyToBwrapArgs({ allowNetwork: true }, baseCtx());
|
||||
|
||||
expect(args).toContain("--bind");
|
||||
expect(args).toContain("/repo/.worktrees/fn-1");
|
||||
expect(args).toContain("/home/u/.pnpm-store");
|
||||
expect(args).toContain("--tmpfs");
|
||||
expect(args).toContain("/tmp");
|
||||
expect(args).toContain("--setenv");
|
||||
expect(args.join(" ")).toContain("FUSION_RUN_ID run-1");
|
||||
expect(args.join(" ")).not.toContain("SECRET_TOKEN");
|
||||
});
|
||||
|
||||
it("supports additional writable paths and skips missing readonly sources", () => {
|
||||
const args = policyToBwrapArgs(
|
||||
{
|
||||
allowNetwork: true,
|
||||
allowedWritePaths: ["/custom/write"],
|
||||
allowedReadPaths: ["/missing/readonly", "/custom/ro"],
|
||||
},
|
||||
baseCtx(),
|
||||
);
|
||||
|
||||
expect(args.join(" ")).toContain("--bind /custom/write /custom/write");
|
||||
expect(args.join(" ")).toContain("--ro-bind /custom/ro /custom/ro");
|
||||
expect(args.join(" ")).not.toContain("/missing/readonly");
|
||||
});
|
||||
|
||||
it("guards port 4040 unless explicitly overridden", () => {
|
||||
expect(() =>
|
||||
policyToBwrapArgs({ allowNetwork: true, allowedPorts: [4040] }, baseCtx()),
|
||||
).toThrow(SandboxPolicyError);
|
||||
|
||||
expect(() =>
|
||||
policyToBwrapArgs({ allowNetwork: true, allowedPorts: [4040], allowPort4040Override: true }, baseCtx()),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("fusionWorktreePreset includes worktree and pnpm store but not .fusion db path", () => {
|
||||
const preset = fusionWorktreePreset(baseCtx());
|
||||
expect(preset.allowedWritePaths).toContain("/repo/.worktrees/fn-1");
|
||||
expect(preset.allowedWritePaths).toContain("/home/u/.pnpm-store");
|
||||
expect((preset.allowedWritePaths ?? []).some((path) => path.includes(".fusion"))).toBe(false);
|
||||
});
|
||||
});
|
||||
122
packages/engine/src/sandbox/bubblewrap-policy.ts
Normal file
122
packages/engine/src/sandbox/bubblewrap-policy.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
import type { SandboxPolicy } from "./types.js";
|
||||
|
||||
export class SandboxPolicyError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "SandboxPolicyError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface BubblewrapPolicy extends SandboxPolicy {
|
||||
allowedPorts?: number[];
|
||||
allowPort4040Override?: boolean;
|
||||
}
|
||||
|
||||
export interface BubblewrapPolicyContext {
|
||||
worktreePath: string;
|
||||
repoRootPath: string;
|
||||
pnpmStorePath: string;
|
||||
nodeBinPath: string;
|
||||
homeDir: string;
|
||||
tmpDirOverride?: string;
|
||||
pathExists?: (path: string) => boolean;
|
||||
envSource?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
function shouldPassEnv(key: string): boolean {
|
||||
return key === "PATH"
|
||||
|| key === "HOME"
|
||||
|| key === "USER"
|
||||
|| key === "LANG"
|
||||
|| key.startsWith("LC_")
|
||||
|| key.startsWith("NODE_")
|
||||
|| key.startsWith("npm_")
|
||||
|| key.startsWith("PNPM_")
|
||||
|| key === "CI"
|
||||
|| key.startsWith("FUSION_");
|
||||
}
|
||||
|
||||
function uniq(paths: string[]): string[] {
|
||||
return [...new Set(paths.map((p) => resolve(p)))];
|
||||
}
|
||||
|
||||
export function fusionWorktreePreset(ctx: BubblewrapPolicyContext): BubblewrapPolicy {
|
||||
return {
|
||||
allowNetwork: true,
|
||||
allowedReadPaths: [ctx.repoRootPath],
|
||||
allowedWritePaths: [ctx.worktreePath, ctx.pnpmStorePath],
|
||||
};
|
||||
}
|
||||
|
||||
export function policyToBwrapArgs(policy: BubblewrapPolicy, ctx: BubblewrapPolicyContext): string[] {
|
||||
if (policy.allowedPorts?.includes(4040) && policy.allowPort4040Override !== true) {
|
||||
throw new SandboxPolicyError("Port 4040 is reserved and cannot be allowed in sandbox policy.");
|
||||
}
|
||||
|
||||
const pathExists = ctx.pathExists ?? (() => true);
|
||||
const tmpDir = ctx.tmpDirOverride ?? "/tmp";
|
||||
|
||||
const args = [
|
||||
"--die-with-parent",
|
||||
"--unshare-pid",
|
||||
"--unshare-uts",
|
||||
"--unshare-ipc",
|
||||
"--new-session",
|
||||
"--proc",
|
||||
"/proc",
|
||||
"--dev",
|
||||
"/dev",
|
||||
"--clearenv",
|
||||
"--tmpfs",
|
||||
tmpDir,
|
||||
];
|
||||
|
||||
if (!policy.allowNetwork) {
|
||||
args.push("--unshare-net");
|
||||
}
|
||||
|
||||
const writablePaths = uniq([
|
||||
ctx.worktreePath,
|
||||
ctx.pnpmStorePath,
|
||||
...(policy.allowedWritePaths ?? []),
|
||||
]);
|
||||
|
||||
for (const path of writablePaths) {
|
||||
if (!pathExists(path)) continue;
|
||||
args.push("--bind", path, path);
|
||||
}
|
||||
|
||||
const nodeInstallDir = dirname(ctx.nodeBinPath);
|
||||
const readonlyPaths = uniq([
|
||||
...((policy.allowedReadPaths ?? []).length ? (policy.allowedReadPaths ?? []) : [ctx.repoRootPath]),
|
||||
...(ctx.repoRootPath !== ctx.worktreePath ? [ctx.repoRootPath] : []),
|
||||
"/usr",
|
||||
"/bin",
|
||||
"/lib",
|
||||
"/lib64",
|
||||
"/etc/resolv.conf",
|
||||
"/etc/ssl",
|
||||
"/etc/ca-certificates",
|
||||
nodeInstallDir,
|
||||
]).filter((path) => !writablePaths.includes(path));
|
||||
|
||||
for (const path of readonlyPaths) {
|
||||
if (!pathExists(path)) continue;
|
||||
args.push("--ro-bind", path, path);
|
||||
}
|
||||
|
||||
const env = {
|
||||
...(ctx.envSource ?? process.env),
|
||||
...(policy.env ?? {}),
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (!value || !shouldPassEnv(key)) continue;
|
||||
args.push("--setenv", key, value);
|
||||
}
|
||||
|
||||
args.push("--chdir", ctx.worktreePath);
|
||||
return args;
|
||||
}
|
||||
Reference in New Issue
Block a user