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:
committed by
gsxdsm
parent
b28b1949a6
commit
115db18d17
87
packages/engine/src/sandbox/__tests__/audit.test.ts
Normal file
87
packages/engine/src/sandbox/__tests__/audit.test.ts
Normal 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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
105
packages/engine/src/sandbox/audit.ts
Normal file
105
packages/engine/src/sandbox/audit.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user