feat(FN-3791): add agent provisioning approval guards and policy enforcemen
Implements approval guards for agent provisioning (FN-3791), adding policy-gated create/delete flows with dedupe logic, CLI tool alignment, engine run-audit coverage, and corresponding test suites, plus documentation updates and a regression fix for verification/tool docs sync. Fusion-Task-Id: FN-3791
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Agent, AgentStore, ApprovalRequestStore, ProjectSettings } from "@fusion/core";
|
||||
import { createAgentCreateTool, createAgentDeleteTool, executeApprovedAgentProvisioning } from "../agent-tools.js";
|
||||
|
||||
function makeAgent(overrides: Partial<Agent> = {}): Agent {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: "agent-caller",
|
||||
name: "Caller",
|
||||
role: "executor",
|
||||
reportsTo: "agent-root",
|
||||
state: "idle",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
metadata: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const withProvisioning = (agentProvisioning: NonNullable<ProjectSettings["agentProvisioning"]>): ProjectSettings => ({
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 2,
|
||||
pollIntervalMs: 5000,
|
||||
groupOverlappingFiles: true,
|
||||
autoMerge: false,
|
||||
autoResolveConflicts: true,
|
||||
agentProvisioning,
|
||||
});
|
||||
|
||||
describe("agent provisioning approval tools", () => {
|
||||
let agentStore: AgentStore;
|
||||
let approvalRequestStore: ApprovalRequestStore;
|
||||
|
||||
beforeEach(() => {
|
||||
const caller = makeAgent({ id: "agent-caller", role: "executor" });
|
||||
const target = makeAgent({ id: "agent-target", reportsTo: "agent-caller" });
|
||||
agentStore = {
|
||||
getAgent: vi.fn(async (id: string) => (id === caller.id ? caller : id === target.id ? target : null)),
|
||||
createAgent: vi.fn(async (input: any) => makeAgent({ id: "agent-created", name: input.name, role: input.role })),
|
||||
deleteAgent: vi.fn(async () => undefined),
|
||||
} as unknown as AgentStore;
|
||||
|
||||
approvalRequestStore = {
|
||||
create: vi.fn((input: any) => ({
|
||||
id: "APR-1",
|
||||
status: "pending",
|
||||
requester: input.requester,
|
||||
targetAction: input.targetAction,
|
||||
})),
|
||||
} as unknown as ApprovalRequestStore;
|
||||
});
|
||||
|
||||
it("creates pending approval for untrusted create and includes approvalDedupeKey", async () => {
|
||||
const tool = createAgentCreateTool(agentStore, "agent-caller", {
|
||||
approvalRequestStore,
|
||||
settingsProvider: async () => withProvisioning({ approvalMode: "trusted-only" }),
|
||||
});
|
||||
|
||||
const result = await tool.execute("s", { name: "New Agent", role: "executor" } as any, undefined as any, undefined as any, undefined as any);
|
||||
|
||||
expect((result.details as any).outcome).toBe("pending_approval");
|
||||
expect(approvalRequestStore.create).toHaveBeenCalledTimes(1);
|
||||
const context = vi.mocked(approvalRequestStore.create).mock.calls[0]?.[0]?.targetAction?.context as any;
|
||||
expect(context.tool).toBe("fn_agent_create");
|
||||
expect(typeof context.approvalDedupeKey).toBe("string");
|
||||
expect(context.approvalDedupeKey.length).toBeGreaterThan(0);
|
||||
expect(agentStore.createAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("auto-approves trusted role create", async () => {
|
||||
vi.mocked(agentStore.getAgent).mockResolvedValueOnce(makeAgent({ id: "agent-caller", role: "ceo" as any }));
|
||||
const tool = createAgentCreateTool(agentStore, "agent-caller", {
|
||||
approvalRequestStore,
|
||||
settingsProvider: async () => withProvisioning({ approvalMode: "trusted-only", trustedRoles: ["ceo"] }),
|
||||
});
|
||||
|
||||
const result = await tool.execute("s", { name: "New Agent", role: "executor" } as any, undefined as any, undefined as any, undefined as any);
|
||||
expect((result.details as any).outcome).toBe("created");
|
||||
expect(agentStore.createAgent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("delete requires approval by default and includes approvalDedupeKey", async () => {
|
||||
const tool = createAgentDeleteTool(agentStore, "agent-caller", {
|
||||
approvalRequestStore,
|
||||
settingsProvider: async () => withProvisioning({ approvalMode: "trusted-only", trustedAgentIds: ["agent-caller"] }),
|
||||
});
|
||||
|
||||
const result = await tool.execute("s", { agent_id: "agent-target" } as any, undefined as any, undefined as any, undefined as any);
|
||||
|
||||
expect((result.details as any).outcome).toBe("pending_approval");
|
||||
const context = vi.mocked(approvalRequestStore.create).mock.calls[0]?.[0]?.targetAction?.context as any;
|
||||
expect(context.tool).toBe("fn_agent_delete");
|
||||
expect(typeof context.approvalDedupeKey).toBe("string");
|
||||
expect(agentStore.deleteAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows trusted delete when alwaysApproveDelete is false", async () => {
|
||||
const tool = createAgentDeleteTool(agentStore, "agent-caller", {
|
||||
approvalRequestStore,
|
||||
settingsProvider: async () => withProvisioning({
|
||||
approvalMode: "trusted-only",
|
||||
trustedAgentIds: ["agent-caller"],
|
||||
alwaysApproveDelete: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await tool.execute("s", { agent_id: "agent-target" } as any, undefined as any, undefined as any, undefined as any);
|
||||
expect((result.details as any).outcome).toBe("deleted");
|
||||
expect(agentStore.deleteAgent).toHaveBeenCalledWith("agent-target", { force: false, reassignTo: undefined });
|
||||
});
|
||||
|
||||
it("executeApprovedAgentProvisioning creates/deletes from request payload", async () => {
|
||||
const created = await executeApprovedAgentProvisioning({
|
||||
id: "APR-C",
|
||||
status: "approved",
|
||||
targetAction: {
|
||||
category: "agent_provisioning",
|
||||
action: "create",
|
||||
summary: "",
|
||||
resourceType: "agent",
|
||||
resourceId: "",
|
||||
context: { tool: "fn_agent_create", params: { name: "X", role: "executor" } },
|
||||
},
|
||||
} as any, { agentStore });
|
||||
expect((created as Agent).name).toBe("X");
|
||||
|
||||
const deleted = await executeApprovedAgentProvisioning({
|
||||
id: "APR-D",
|
||||
status: "approved",
|
||||
targetAction: {
|
||||
category: "agent_provisioning",
|
||||
action: "delete",
|
||||
summary: "",
|
||||
resourceType: "agent",
|
||||
resourceId: "agent-target",
|
||||
context: { tool: "fn_agent_delete", params: { agent_id: "agent-target" } },
|
||||
},
|
||||
} as any, { agentStore });
|
||||
expect(deleted).toEqual({ deletedId: "agent-target" });
|
||||
});
|
||||
});
|
||||
32
packages/engine/src/__tests__/run-audit.test.ts
Normal file
32
packages/engine/src/__tests__/run-audit.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TaskStore, RunAuditEventInput } from "@fusion/core";
|
||||
import { createRunAuditor, type DatabaseMutationType } from "../run-audit.js";
|
||||
|
||||
class AuditStoreStub {
|
||||
events: RunAuditEventInput[] = [];
|
||||
recordRunAuditEvent(event: RunAuditEventInput): void {
|
||||
this.events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
describe("run-audit provisioning mutation types", () => {
|
||||
it("accepts provisioning mutation types and records them", async () => {
|
||||
const store = new AuditStoreStub();
|
||||
const auditor = createRunAuditor(store as unknown as TaskStore, { runId: "r1", agentId: "a1", taskId: "FN-1" });
|
||||
|
||||
const types: DatabaseMutationType[] = [
|
||||
"agent:create:requested",
|
||||
"agent:create:approved",
|
||||
"agent:create:denied",
|
||||
"agent:delete:requested",
|
||||
"agent:delete:approved",
|
||||
"agent:delete:denied",
|
||||
];
|
||||
|
||||
for (const type of types) {
|
||||
await auditor.database({ type, target: "agent-x" });
|
||||
}
|
||||
|
||||
expect(store.events.map((event) => event.mutationType)).toEqual(types);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import type { AgentReflectionService } from "./agent-reflection.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { fetchWebContent, WebFetchError } from "./web-fetch.js";
|
||||
import type { RunAuditor } from "./run-audit.js";
|
||||
import { computeApprovalDedupeKey } from "./agent-action-gate.js";
|
||||
|
||||
// ── Tool parameter schemas (canonical definitions) ────────────────────────
|
||||
|
||||
@@ -1514,6 +1515,15 @@ export function createAgentCreateTool(
|
||||
};
|
||||
}
|
||||
|
||||
const approvalDedupeKey = computeApprovalDedupeKey({
|
||||
agentId: callingAgentId,
|
||||
toolName: "fn_agent_create",
|
||||
category: "agent_provisioning",
|
||||
resourceType: "agent",
|
||||
resourceId: reportsTo,
|
||||
operation: `create:${params.name}:${params.role}:${reportsTo}`,
|
||||
});
|
||||
|
||||
const request = options.approvalRequestStore.create({
|
||||
requester: { actorId: callingAgentId, actorType: "agent", actorName: caller?.name ?? callingAgentId },
|
||||
targetAction: {
|
||||
@@ -1522,7 +1532,7 @@ export function createAgentCreateTool(
|
||||
summary: `Create agent ${params.name} (${params.role})`,
|
||||
resourceType: "agent",
|
||||
resourceId: "",
|
||||
context: { tool: "fn_agent_create", params },
|
||||
context: { tool: "fn_agent_create", params, approvalDedupeKey },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1625,6 +1635,15 @@ export function createAgentDeleteTool(
|
||||
};
|
||||
}
|
||||
|
||||
const approvalDedupeKey = computeApprovalDedupeKey({
|
||||
agentId: callingAgentId,
|
||||
toolName: "fn_agent_delete",
|
||||
category: "agent_provisioning",
|
||||
resourceType: "agent",
|
||||
resourceId: target.id,
|
||||
operation: `delete:${target.id}:${params.force === true ? "force" : "normal"}:${params.reassign_to ?? ""}`,
|
||||
});
|
||||
|
||||
const request = options.approvalRequestStore.create({
|
||||
requester: { actorId: callingAgentId, actorType: "agent", actorName: caller?.name ?? callingAgentId },
|
||||
targetAction: {
|
||||
@@ -1633,7 +1652,7 @@ export function createAgentDeleteTool(
|
||||
summary: `Delete agent ${target.name} (${target.id})`,
|
||||
resourceType: "agent",
|
||||
resourceId: target.id,
|
||||
context: { tool: "fn_agent_delete", params },
|
||||
context: { tool: "fn_agent_delete", params, approvalDedupeKey },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user