feat(FN-4640): complete Step 3 — add sandbox audit decorator

Fusion-Task-Id: FN-4640
Fusion-Task-Lineage: 4a91265f-1714-4854-b08d-7ddb06074253
This commit is contained in:
Fusion (runfusion.ai)
2026-05-15 15:58:49 -07:00
committed by gsxdsm
parent b28b1949a6
commit 115db18d17
4 changed files with 226 additions and 13 deletions

View File

@@ -0,0 +1,87 @@
import { describe, expect, it, vi } from "vitest";
import type { RunAuditor } from "../../run-audit.js";
import { withSandboxAudit } from "../audit.js";
import type { SandboxBackend, SandboxPolicy, SandboxRunOptions, SandboxRunResult } from "../types.js";
function makeBackend(runImpl?: (command: string, options: SandboxRunOptions) => Promise<SandboxRunResult>): SandboxBackend {
return {
capabilities: () => ({
id: "native",
supportsNetworkPolicy: false,
supportsFilesystemPolicy: false,
supportsStreaming: true,
platform: "any",
}),
prepare: vi.fn(async (policy: SandboxPolicy) => {
policy.onFallback?.({ fromBackendId: "sandbox-exec", toBackendId: "native", reason: "unavailable" });
}),
run: runImpl ?? vi.fn(async () => ({ stdout: "ok", stderr: "", exitCode: 0, signal: null, timedOut: false, bufferExceeded: false })),
runStreaming: vi.fn(async () => ({ outcome: "success", stdout: "", stderr: "", bufferOverflow: false })),
dispose: vi.fn(async () => {}),
};
}
function makeAuditor() {
return {
git: vi.fn(async () => {}),
database: vi.fn(async () => {}),
filesystem: vi.fn(async () => {}),
sandbox: vi.fn(async () => {}),
} satisfies RunAuditor;
}
describe("withSandboxAudit", () => {
it("emits prepare once and fallback callback", async () => {
const auditor = makeAuditor();
const backend = withSandboxAudit(makeBackend(), auditor);
await backend.prepare({ allowNetwork: false });
await backend.prepare({ allowNetwork: false });
const prepareEvents = auditor.sandbox.mock.calls.filter(([input]) => input.type === "sandbox:prepare");
const fallbackEvents = auditor.sandbox.mock.calls.filter(([input]) => input.type === "sandbox:fallback");
expect(prepareEvents).toHaveLength(1);
expect(fallbackEvents).toHaveLength(2);
});
it("emits run on success", async () => {
const auditor = makeAuditor();
const backend = withSandboxAudit(makeBackend(), auditor);
await backend.run("echo hello", { cwd: "/tmp", timeoutMs: 10_000, maxBuffer: 1000 });
expect(auditor.sandbox).toHaveBeenCalledWith(
expect.objectContaining({ type: "sandbox:run", target: "native" }),
);
});
it("emits failure for non-zero exit, timeout, and buffer overflow", async () => {
const auditor = makeAuditor();
const backend = withSandboxAudit(
makeBackend(async () => ({ stdout: "", stderr: "err", exitCode: 1, signal: null, timedOut: true, bufferExceeded: true })),
auditor,
);
await backend.run("bad", { cwd: "/tmp", timeoutMs: 1000, maxBuffer: 10 });
expect(auditor.sandbox).toHaveBeenCalledWith(
expect.objectContaining({ type: "sandbox:failure", target: "native" }),
);
});
it("emits failure and rethrows on thrown error", async () => {
const auditor = makeAuditor();
const backend = withSandboxAudit(
makeBackend(async () => {
throw new Error("boom");
}),
auditor,
);
await expect(backend.run("explode", { cwd: "/tmp", timeoutMs: 1000, maxBuffer: 10 })).rejects.toThrow("boom");
expect(auditor.sandbox).toHaveBeenCalledWith(
expect.objectContaining({ type: "sandbox:failure", target: "native" }),
);
});
});

View File

@@ -0,0 +1,105 @@
import { createLogger } from "../logger.js";
import type { RunAuditor } from "../run-audit.js";
import type {
SandboxBackend,
SandboxCapabilities,
SandboxFallbackEvent,
SandboxPolicy,
SandboxRunOptions,
SandboxRunResult,
} from "./types.js";
const log = createLogger("sandbox-audit");
async function emitSandboxAudit(
auditor: RunAuditor,
type: "sandbox:prepare" | "sandbox:run" | "sandbox:failure" | "sandbox:fallback",
target: string,
metadata?: Record<string, unknown>,
): Promise<void> {
try {
await auditor.sandbox({ type, target, metadata });
} catch (error) {
log.warn(`Failed to emit ${type} audit event: ${error instanceof Error ? error.message : String(error)}`);
}
}
function makeFallbackPolicy(policy: SandboxPolicy, backendId: SandboxCapabilities["id"], auditor: RunAuditor): SandboxPolicy {
return {
...policy,
onFallback: (event: SandboxFallbackEvent) => {
void emitSandboxAudit(auditor, "sandbox:fallback", backendId, {
backendId,
fromBackendId: event.fromBackendId,
toBackendId: event.toBackendId,
reason: event.reason,
});
policy.onFallback?.(event);
},
};
}
export function withSandboxAudit(backend: SandboxBackend, auditor: RunAuditor): SandboxBackend {
let prepared = false;
const capabilities = backend.capabilities();
const backendId = capabilities.id;
return {
capabilities: () => capabilities,
prepare: async (policy: SandboxPolicy) => {
await backend.prepare(makeFallbackPolicy(policy, backendId, auditor));
if (!prepared) {
prepared = true;
await emitSandboxAudit(auditor, "sandbox:prepare", backendId, {
backendId,
supportsNetworkPolicy: capabilities.supportsNetworkPolicy,
supportsFilesystemPolicy: capabilities.supportsFilesystemPolicy,
});
}
},
run: async (command: string, options: SandboxRunOptions): Promise<SandboxRunResult> => {
const startedAt = Date.now();
const commandSnippet = command.slice(0, 200);
try {
const result = await backend.run(command, options);
const durationMs = Date.now() - startedAt;
await emitSandboxAudit(auditor, "sandbox:run", backendId, {
backendId,
command: commandSnippet,
cwd: options.cwd,
timeoutMs: options.timeoutMs,
exitCode: result.exitCode,
durationMs,
timedOut: false,
bufferExceeded: false,
});
if (result.exitCode !== 0 || result.timedOut || result.bufferExceeded) {
await emitSandboxAudit(auditor, "sandbox:failure", backendId, {
backendId,
command: commandSnippet,
exitCode: result.exitCode,
signal: result.signal,
timedOut: result.timedOut,
bufferExceeded: result.bufferExceeded,
stderrExcerpt: result.stderr.slice(0, 500),
});
}
return result;
} catch (error) {
await emitSandboxAudit(auditor, "sandbox:failure", backendId, {
backendId,
command: commandSnippet,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
}
},
runStreaming: async (command, options) => backend.runStreaming(command, options),
dispose: async () => {
await backend.dispose();
},
};
}

View File

@@ -1,11 +1,14 @@
import { BubblewrapBackend } from "./bubblewrap-backend.js";
import { NativeSandboxBackend } from "./native.js";
import { SandboxExecBackend } from "./sandbox-exec-backend.js";
import { withSandboxAudit } from "./audit.js";
import type { RunAuditor } from "../run-audit.js";
import type { SandboxBackend, SandboxCapabilities } from "./types.js";
export type {
SandboxBackend,
SandboxCapabilities,
SandboxFallbackEvent,
SandboxPolicy,
SandboxRunOptions,
SandboxRunResult,
@@ -23,21 +26,32 @@ export function __resetSandboxBackendForTests(): void {
sandboxBackendOverrideForTests = null;
}
export function resolveSandboxBackend(options?: { backendId?: SandboxCapabilities["id"] }): SandboxBackend {
if (sandboxBackendOverrideForTests) {
return sandboxBackendOverrideForTests;
}
if (options?.backendId === "bubblewrap" && process.platform === "linux") {
return new BubblewrapBackend();
}
if (options?.backendId === "sandbox-exec") {
if (process.platform === "darwin") {
return new SandboxExecBackend();
export function resolveSandboxBackend(options?: {
backendId?: SandboxCapabilities["id"];
auditor?: RunAuditor;
}): SandboxBackend {
const resolved = (() => {
if (sandboxBackendOverrideForTests) {
return sandboxBackendOverrideForTests;
}
if (options?.backendId === "bubblewrap" && process.platform === "linux") {
return new BubblewrapBackend();
}
if (options?.backendId === "sandbox-exec") {
if (process.platform === "darwin") {
return new SandboxExecBackend();
}
return new NativeSandboxBackend();
}
return new NativeSandboxBackend();
})();
if (!options?.auditor) {
return resolved;
}
return new NativeSandboxBackend();
return withSandboxAudit(resolved, options.auditor);
}

View File

@@ -1,3 +1,9 @@
export interface SandboxFallbackEvent {
fromBackendId: SandboxCapabilities["id"];
toBackendId: SandboxCapabilities["id"];
reason: string;
}
export interface SandboxPolicy {
allowNetwork: boolean;
/** @future Backends with filesystem isolation will enforce this. */
@@ -5,6 +11,7 @@ export interface SandboxPolicy {
/** @future Backends with filesystem isolation will enforce this. */
allowedWritePaths?: string[];
env?: NodeJS.ProcessEnv;
onFallback?: (event: SandboxFallbackEvent) => void;
}
export interface SandboxRunOptions {