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 { RunAuditor } from "../run-audit.js";
|
||||||
import type { SandboxBackend, SandboxCapabilities } from "./types.js";
|
import type { SandboxBackend, SandboxCapabilities } from "./types.js";
|
||||||
|
|
||||||
|
export { SandboxProvisioningPendingError, requireSandboxProvisioningApproval } from "./provisioning-gate.js";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
SandboxBackend,
|
SandboxBackend,
|
||||||
SandboxCapabilities,
|
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