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:
Fusion
2026-05-10 22:44:50 -07:00
committed by gsxdsm
parent 7fd3ccc68b
commit a39985cabf
10 changed files with 316 additions and 19 deletions

View File

@@ -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" });
});
});

View 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);
});
});

View File

@@ -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 },
},
});