feat(FN-4641): complete Step 3 — add engine provisioning gate helper
Fusion-Task-Id: FN-4641 Fusion-Task-Lineage: 50ac1fd2-8c88-4a29-9ebb-962a6b5c772c
This commit is contained in:
committed by
gsxdsm
parent
9ef00e1197
commit
a6a72ee1ff
148
packages/engine/src/sandbox/__tests__/provisioning-gate.test.ts
Normal file
148
packages/engine/src/sandbox/__tests__/provisioning-gate.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { ApprovalRequest } from "@fusion/core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
SandboxProvisioningPendingError,
|
||||
requireSandboxProvisioningApproval,
|
||||
} from "../provisioning-gate.js";
|
||||
|
||||
function makeApproval(id: string, status: ApprovalRequest["status"]): ApprovalRequest {
|
||||
return {
|
||||
id,
|
||||
status,
|
||||
requester: { actorId: "agent-1", actorType: "agent", actorName: "Executor" },
|
||||
targetAction: {
|
||||
category: "sandbox_provisioning",
|
||||
action: "provision",
|
||||
summary: "Provision sandbox backend",
|
||||
resourceType: "command",
|
||||
resourceId: "install-bubblewrap",
|
||||
context: { backendId: "bubblewrap", operation: "install-bubblewrap", params: {} },
|
||||
},
|
||||
taskId: "FN-4641",
|
||||
runId: "run-1",
|
||||
requestedAt: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("requireSandboxProvisioningApproval", () => {
|
||||
it("allows auto-approved backend", async () => {
|
||||
const createApprovalRequest = vi.fn();
|
||||
const result = await requireSandboxProvisioningApproval({
|
||||
backendId: "native",
|
||||
operation: "prepare",
|
||||
description: "Prepare native backend",
|
||||
context: {
|
||||
requester: { actorId: "agent-1", actorType: "agent", actorName: "Executor" },
|
||||
settings: undefined,
|
||||
createApprovalRequest,
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({ outcome: "allow" });
|
||||
expect(createApprovalRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates pending request and pauses for approval in default mode", async () => {
|
||||
const createApprovalRequest = vi.fn(async () => makeApproval("apr-1", "pending"));
|
||||
const pauseForApproval = vi.fn(async () => undefined);
|
||||
|
||||
await expect(
|
||||
requireSandboxProvisioningApproval({
|
||||
backendId: "bubblewrap",
|
||||
operation: "install",
|
||||
description: "Install bubblewrap",
|
||||
context: {
|
||||
taskId: "FN-4641",
|
||||
requester: { actorId: "agent-1", actorType: "agent", actorName: "Executor" },
|
||||
settings: undefined,
|
||||
createApprovalRequest,
|
||||
pauseForApproval,
|
||||
},
|
||||
}),
|
||||
).rejects.toBeInstanceOf(SandboxProvisioningPendingError);
|
||||
|
||||
expect(createApprovalRequest).toHaveBeenCalledTimes(1);
|
||||
expect(pauseForApproval).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns execute-once-then-complete after approval exists", async () => {
|
||||
const result = await requireSandboxProvisioningApproval({
|
||||
backendId: "bubblewrap",
|
||||
operation: "install",
|
||||
description: "Install bubblewrap",
|
||||
context: {
|
||||
taskId: "FN-4641",
|
||||
requester: { actorId: "agent-1", actorType: "agent", actorName: "Executor" },
|
||||
settings: undefined,
|
||||
createApprovalRequest: vi.fn(async () => null),
|
||||
findApprovalByDedupeKey: vi.fn(async () => ({ id: "apr-1", status: "approved" })),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ outcome: "execute-once-then-complete", approvalRequestId: "apr-1" });
|
||||
});
|
||||
|
||||
it("throws block when prior approval was denied", async () => {
|
||||
await expect(
|
||||
requireSandboxProvisioningApproval({
|
||||
backendId: "bubblewrap",
|
||||
operation: "install",
|
||||
description: "Install bubblewrap",
|
||||
context: {
|
||||
requester: { actorId: "agent-1", actorType: "agent", actorName: "Executor" },
|
||||
settings: undefined,
|
||||
createApprovalRequest: vi.fn(async () => null),
|
||||
findApprovalByDedupeKey: vi.fn(async () => ({ id: "apr-1", status: "denied" })),
|
||||
},
|
||||
}),
|
||||
).rejects.toBeInstanceOf(SandboxProvisioningPendingError);
|
||||
});
|
||||
|
||||
it("allows never mode without creating approval", async () => {
|
||||
const createApprovalRequest = vi.fn(async () => makeApproval("apr-1", "pending"));
|
||||
const result = await requireSandboxProvisioningApproval({
|
||||
backendId: "bubblewrap",
|
||||
operation: "install",
|
||||
description: "Install bubblewrap",
|
||||
context: {
|
||||
requester: { actorId: "agent-1", actorType: "agent", actorName: "Executor" },
|
||||
settings: { sandboxProvisioning: { approvalMode: "never" } },
|
||||
createApprovalRequest,
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({ outcome: "allow" });
|
||||
expect(createApprovalRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("produces stable dedupe key for identical inputs", async () => {
|
||||
const createApprovalRequest = vi.fn(async () => makeApproval("apr-1", "pending"));
|
||||
|
||||
const first = requireSandboxProvisioningApproval({
|
||||
backendId: "bubblewrap",
|
||||
operation: "install",
|
||||
description: "Install bubblewrap",
|
||||
context: {
|
||||
taskId: "FN-4641",
|
||||
requester: { actorId: "agent-1", actorType: "agent", actorName: "Executor" },
|
||||
settings: undefined,
|
||||
createApprovalRequest,
|
||||
},
|
||||
}).catch((error) => error as SandboxProvisioningPendingError);
|
||||
|
||||
const second = requireSandboxProvisioningApproval({
|
||||
backendId: "bubblewrap",
|
||||
operation: "install",
|
||||
description: "Install bubblewrap",
|
||||
context: {
|
||||
taskId: "FN-4641",
|
||||
requester: { actorId: "agent-1", actorType: "agent", actorName: "Executor" },
|
||||
settings: undefined,
|
||||
createApprovalRequest,
|
||||
},
|
||||
}).catch((error) => error as SandboxProvisioningPendingError);
|
||||
|
||||
const [firstError, secondError] = await Promise.all([first, second]);
|
||||
expect(firstError.dedupeKey).toBe(secondError.dedupeKey);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,8 @@ import { withSandboxAudit } from "./audit.js";
|
||||
import type { RunAuditor } from "../run-audit.js";
|
||||
import type { SandboxBackend, SandboxCapabilities } from "./types.js";
|
||||
|
||||
export { SandboxProvisioningPendingError, requireSandboxProvisioningApproval } from "./provisioning-gate.js";
|
||||
|
||||
export type {
|
||||
SandboxBackend,
|
||||
SandboxCapabilities,
|
||||
|
||||
171
packages/engine/src/sandbox/provisioning-gate.ts
Normal file
171
packages/engine/src/sandbox/provisioning-gate.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import type {
|
||||
ApprovalRequest,
|
||||
ApprovalRequestActorSnapshot,
|
||||
ApprovalRequestStatus,
|
||||
ProjectSettings,
|
||||
} from "@fusion/core";
|
||||
import { resolveSandboxProvisioningPolicy } from "@fusion/core";
|
||||
import {
|
||||
computeApprovalDedupeKey,
|
||||
resolveGateOutcome,
|
||||
type AgentActionGateDecision,
|
||||
} from "../agent-action-gate.js";
|
||||
|
||||
export class SandboxProvisioningPendingError extends Error {
|
||||
readonly approvalRequestId: string;
|
||||
readonly dedupeKey: string;
|
||||
readonly decision: AgentActionGateDecision;
|
||||
|
||||
constructor(params: {
|
||||
message: string;
|
||||
approvalRequestId: string;
|
||||
dedupeKey: string;
|
||||
decision: AgentActionGateDecision;
|
||||
}) {
|
||||
super(params.message);
|
||||
this.name = "SandboxProvisioningPendingError";
|
||||
this.approvalRequestId = params.approvalRequestId;
|
||||
this.dedupeKey = params.dedupeKey;
|
||||
this.decision = params.decision;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SandboxProvisioningGateContext {
|
||||
taskId?: string;
|
||||
runId?: string;
|
||||
requester: ApprovalRequestActorSnapshot;
|
||||
settings: Pick<ProjectSettings, "sandboxProvisioning"> | undefined;
|
||||
createApprovalRequest: (input: {
|
||||
category: "sandbox_provisioning";
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
}) => Promise<ApprovalRequest | null>;
|
||||
findApprovalByDedupeKey?: (dedupeKey: string) => Promise<{ id: string; status: ApprovalRequestStatus } | null>;
|
||||
pauseForApproval?: (info: { approvalRequestId: string; decision: AgentActionGateDecision }) => Promise<void>;
|
||||
}
|
||||
|
||||
export async function requireSandboxProvisioningApproval(input: {
|
||||
backendId: string;
|
||||
operation: string;
|
||||
description: string;
|
||||
params?: Record<string, unknown>;
|
||||
context: SandboxProvisioningGateContext;
|
||||
}): Promise<{ outcome: "allow" | "execute-once-then-complete"; approvalRequestId?: string }> {
|
||||
const { backendId, operation, description, context } = input;
|
||||
const params = input.params ?? {};
|
||||
|
||||
const dedupeKey = computeApprovalDedupeKey({
|
||||
agentId: context.requester.actorId,
|
||||
taskId: context.taskId,
|
||||
toolName: `sandbox:provisioning:${backendId}`,
|
||||
category: "sandbox_provisioning",
|
||||
resourceType: "command",
|
||||
resourceId: operation,
|
||||
operation,
|
||||
});
|
||||
|
||||
const policyDecision = resolveSandboxProvisioningPolicy({
|
||||
backendId,
|
||||
operation,
|
||||
caller: {
|
||||
id: context.requester.actorId,
|
||||
role: context.requester.actorType === "agent" ? "agent" : context.requester.actorType,
|
||||
isPrivileged: context.requester.actorType === "user",
|
||||
},
|
||||
settings: context.settings,
|
||||
});
|
||||
|
||||
const decision: AgentActionGateDecision = {
|
||||
disposition:
|
||||
policyDecision.decision === "require-approval"
|
||||
? "require-approval"
|
||||
: policyDecision.decision === "allow"
|
||||
? "allow"
|
||||
: "block",
|
||||
category: "command_execution",
|
||||
toolName: `sandbox:provisioning:${backendId}`,
|
||||
operation,
|
||||
summary: description,
|
||||
resourceType: "command",
|
||||
resourceId: operation,
|
||||
approvalDedupeKey: dedupeKey,
|
||||
metadata: {
|
||||
backendId,
|
||||
operation,
|
||||
description,
|
||||
policyReason: policyDecision.reason,
|
||||
policyRule: policyDecision.matchedRule,
|
||||
policyMode: policyDecision.effectiveMode,
|
||||
},
|
||||
};
|
||||
|
||||
if (policyDecision.decision === "allow") {
|
||||
return { outcome: "allow" };
|
||||
}
|
||||
|
||||
if (policyDecision.decision === "deny") {
|
||||
throw new SandboxProvisioningPendingError({
|
||||
message: `Sandbox provisioning denied: ${policyDecision.reason}`,
|
||||
approvalRequestId: "",
|
||||
dedupeKey,
|
||||
decision,
|
||||
});
|
||||
}
|
||||
|
||||
const existing = context.findApprovalByDedupeKey ? await context.findApprovalByDedupeKey(dedupeKey) : null;
|
||||
const gateOutcome = resolveGateOutcome(decision, existing);
|
||||
|
||||
if (gateOutcome.outcome === "execute-once-then-complete") {
|
||||
return { outcome: "execute-once-then-complete", approvalRequestId: gateOutcome.approvalRequestId };
|
||||
}
|
||||
|
||||
if (gateOutcome.outcome === "block") {
|
||||
throw new SandboxProvisioningPendingError({
|
||||
message: "Sandbox provisioning approval denied",
|
||||
approvalRequestId: gateOutcome.approvalRequestId ?? "",
|
||||
dedupeKey,
|
||||
decision,
|
||||
});
|
||||
}
|
||||
|
||||
if (gateOutcome.approvalRequestId) {
|
||||
throw new SandboxProvisioningPendingError({
|
||||
message: "Sandbox provisioning approval pending",
|
||||
approvalRequestId: gateOutcome.approvalRequestId,
|
||||
dedupeKey,
|
||||
decision,
|
||||
});
|
||||
}
|
||||
|
||||
const created = await context.createApprovalRequest({
|
||||
category: "sandbox_provisioning",
|
||||
toolName: `sandbox:provisioning:${backendId}`,
|
||||
args: {
|
||||
backendId,
|
||||
operation,
|
||||
description,
|
||||
params,
|
||||
approvalDedupeKey: dedupeKey,
|
||||
},
|
||||
});
|
||||
|
||||
if (!created) {
|
||||
throw new SandboxProvisioningPendingError({
|
||||
message: "Sandbox provisioning approval request was not created",
|
||||
approvalRequestId: "",
|
||||
dedupeKey,
|
||||
decision,
|
||||
});
|
||||
}
|
||||
|
||||
if (context.pauseForApproval) {
|
||||
await context.pauseForApproval({ approvalRequestId: created.id, decision });
|
||||
}
|
||||
|
||||
throw new SandboxProvisioningPendingError({
|
||||
message: "Sandbox provisioning approval pending",
|
||||
approvalRequestId: created.id,
|
||||
dedupeKey,
|
||||
decision,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user