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:
7
.changeset/FN-3791-agent-provisioning-approval-guards.md
Normal file
7
.changeset/FN-3791-agent-provisioning-approval-guards.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Add approval-policy guards for `fn_agent_create` and `fn_agent_delete` with
|
||||
`agentProvisioning` project settings, pending-approval outcomes, and approval-route
|
||||
execution/audit handling for approved and denied provisioning requests.
|
||||
@@ -311,13 +311,15 @@ Six tools enable inter-agent coordination — discovering agents, provisioning/d
|
||||
|
||||
Create a non-ephemeral agent that reports to the caller (or, for CEO-level callers, any `reportsTo` target).
|
||||
|
||||
Provisioning can be policy-gated (`projectSettings.agentProvisioning`). Tool responses include `details.outcome` of `created`, `pending_approval`, or `denied`. Pending requests are resolved via dashboard/API approval decision route (`POST /api/approvals/:id/decision`), which executes deferred creation on approve.
|
||||
Provisioning is policy-gated via `projectSettings.agentProvisioning` (`approvalMode`, `trustedRoles`, `trustedAgentIds`, `alwaysApproveDelete`). Tool responses use `details.outcome` values `created`, `deleted`, `pending_approval`, or `denied`. Pending requests are resolved via dashboard/API approval decision route (`POST /api/approvals/:id/decision`), which executes deferred provisioning on approve.
|
||||
|
||||
### `agent_delete` Tool
|
||||
|
||||
Delete a non-ephemeral direct report. If the target holds a task checkout lease, deletion is blocked unless `force: true`. Assigned tasks can be reassigned via `reassign_to` or released/unassigned.
|
||||
|
||||
Provisioning policy also applies to deletes (`details.outcome`: `deleted`, `pending_approval`, or `denied`). Approval decisions emit provisioning audit events (`agent:create:approved|denied`, `agent:delete:approved|denied`) tied to the original run/task metadata.
|
||||
Provisioning policy also applies to deletes (`details.outcome`: `deleted`, `pending_approval`, or `denied`). Provisioning emits audit events `agent:create:{requested,approved,denied}` and `agent:delete:{requested,approved,denied}` tied to the originating run/task metadata.
|
||||
|
||||
Out of scope in FN-3791: `spawn_agent` (ephemeral child worktree lifecycle). Follow-up task: "Evaluate approval guards for `spawn_agent` (ephemeral worktree children)".
|
||||
|
||||
### `list_agents` Tool
|
||||
|
||||
|
||||
@@ -107,9 +107,24 @@ Agent provisioning approvals (`agent_provisioning` category):
|
||||
- `fn_agent_create` / `fn_agent_delete` can return `pending_approval` under `projectSettings.agentProvisioning` policy (`approvalMode`, trusted roles/IDs, `alwaysApproveDelete`).
|
||||
- Approval request is persisted with provisioning context (`tool` + `params`) and visible in mailbox/API approval queues.
|
||||
- Dashboard/API decision route `POST /api/approvals/:id/decision` executes deferred provisioning on `approve` via engine dispatcher (`executeApprovedAgentProvisioning`) and never executes on `deny`.
|
||||
- Decision handling emits run-audit mutations: `agent:create:approved`, `agent:create:denied`, `agent:delete:approved`, `agent:delete:denied` using original request task/run/requester linkage.
|
||||
- Decision handling emits run-audit mutations: `agent:create:{requested,approved,denied}` and `agent:delete:{requested,approved,denied}` using original request task/run/requester linkage.
|
||||
- Malformed provisioning context or failed execution returns 500 from the decision route (no silent approval).
|
||||
|
||||
Resolver decision table (`resolveAgentProvisioningPolicy`):
|
||||
|
||||
| matchedRule | decision | Notes |
|
||||
| --- | --- | --- |
|
||||
| `missing-caller` | `deny` | Caller context missing. |
|
||||
| `privileged-caller` | `allow` | Bypasses trust checks and `alwaysApproveDelete`. |
|
||||
| `approval-mode-never` | `allow` | Global short-circuit, including deletes. |
|
||||
| `delete-always-approve` | `require-approval` | Default delete behavior when not short-circuited. |
|
||||
| `trusted-agent-id` | `allow` | Exact caller ID allowlist match. |
|
||||
| `trusted-role` | `allow` | Case-insensitive role allowlist match. |
|
||||
| `approval-mode-trusted-only` | `require-approval` | Untrusted fallback in default mode. |
|
||||
| `approval-mode-always` | `require-approval` | Approval always required unless privileged/never mode. |
|
||||
|
||||
Out of scope in FN-3791: `spawn_agent` (ephemeral child worktree lifecycle). Follow-up task: "Evaluate approval guards for `spawn_agent` (ephemeral worktree children)".
|
||||
|
||||
Default and legacy fallback behavior:
|
||||
|
||||
- New **non-ephemeral/permanent** agents persist a normalized `permissionPolicy` using preset `unrestricted` when not explicitly provided.
|
||||
|
||||
@@ -270,7 +270,7 @@ Create a new non-ephemeral agent.
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `name` | string | ✓ | Agent name |
|
||||
| `role` | string | ✓ | Agent role/capability |
|
||||
| `role` | union | ✓ | Agent role/capability |
|
||||
| `soul` | string | — | Agent personality/identity text |
|
||||
| `instructions_text` | string | — | Inline custom instructions |
|
||||
| `instructions_path` | string | — | Path to instructions markdown |
|
||||
@@ -286,7 +286,7 @@ Delete a non-ephemeral agent.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `id` | string | ✓ | Agent ID to delete |
|
||||
| `agent_id` | string | ✓ | Agent ID to delete |
|
||||
| `force` | boolean | — | Force delete when holding checkout |
|
||||
| `reassign_to` | string | — | Optional replacement agent for assigned tasks |
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import kbExtension from "../extension.js";
|
||||
|
||||
function createMockAPI() {
|
||||
const tools = new Map<string, any>();
|
||||
return {
|
||||
registerTool(def: any) {
|
||||
tools.set(def.name, def);
|
||||
},
|
||||
registerCommand() {},
|
||||
registerShortcut() {},
|
||||
registerFlag() {},
|
||||
on() {},
|
||||
tools,
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("extension agent provisioning tools", () => {
|
||||
it("creates and deletes agents as privileged user caller", async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), "fn-ext-provision-"));
|
||||
try {
|
||||
const api = createMockAPI();
|
||||
kbExtension(api);
|
||||
const createTool = api.tools.get("fn_agent_create");
|
||||
const deleteTool = api.tools.get("fn_agent_delete");
|
||||
|
||||
const name = `Provisioned-${Date.now()}`;
|
||||
const createResult = await createTool.execute("call-1", { name, role: "executor" }, undefined, undefined, { cwd });
|
||||
expect(createResult.details.outcome).toBe("created");
|
||||
const createdId = createResult.details.agentId as string;
|
||||
expect(createdId).toBeTruthy();
|
||||
|
||||
const deleteResult = await deleteTool.execute("call-2", { agent_id: createdId }, undefined, undefined, { cwd });
|
||||
expect(deleteResult.details.outcome).toBe("deleted");
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2530,7 +2530,14 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
description: "Create a new non-ephemeral agent.",
|
||||
parameters: Type.Object({
|
||||
name: Type.String({ description: "Agent name" }),
|
||||
role: Type.String({ description: "Agent role/capability" }),
|
||||
role: Type.Union([
|
||||
Type.Literal("triage"),
|
||||
Type.Literal("executor"),
|
||||
Type.Literal("reviewer"),
|
||||
Type.Literal("merger"),
|
||||
Type.Literal("engineer"),
|
||||
Type.Literal("custom"),
|
||||
], { description: "Agent role/capability" }),
|
||||
soul: Type.Optional(Type.String({ description: "Agent personality/identity text" })),
|
||||
instructions_text: Type.Optional(Type.String({ description: "Inline custom instructions" })),
|
||||
instructions_path: Type.Optional(Type.String({ description: "Path to instructions markdown" })),
|
||||
@@ -2545,14 +2552,22 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
const agentStore = new AgentStore({ rootDir: getFusionDir(ctx.cwd) });
|
||||
await agentStore.init();
|
||||
const store = await getStore(ctx.cwd);
|
||||
const caller = { id: "user", role: "user", isPrivileged: true } as const;
|
||||
const policy = resolveAgentProvisioningPolicy({
|
||||
tool: "fn_agent_create",
|
||||
caller: { id: "user", role: "user", isPrivileged: true },
|
||||
caller,
|
||||
settings: await store.getSettings(),
|
||||
});
|
||||
|
||||
if (!caller.isPrivileged && params.reportsTo !== undefined && params.reportsTo !== caller.id) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: "ERROR: You can only create agents that report to you" }],
|
||||
details: { outcome: "denied", matchedRule: "privileged-caller", effectiveMode: policy.effectiveMode },
|
||||
};
|
||||
}
|
||||
|
||||
if (policy.decision === "require-approval") {
|
||||
const approvalStore = new ApprovalRequestStore((store as unknown as { db: unknown }).db as never);
|
||||
const approvalStore = new ApprovalRequestStore(store.getDatabase());
|
||||
const request = approvalStore.create({
|
||||
requester: { actorId: "user", actorType: "user", actorName: "CLI User" },
|
||||
targetAction: { category: "agent_provisioning", action: "create", summary: `Create agent ${params.name} (${params.role})`, resourceType: "agent", resourceId: "", context: { tool: "fn_agent_create", params } },
|
||||
@@ -2590,7 +2605,7 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
label: "fn: Delete Agent",
|
||||
description: "Delete a non-ephemeral agent.",
|
||||
parameters: Type.Object({
|
||||
id: Type.String({ description: "Agent ID to delete" }),
|
||||
agent_id: Type.String({ description: "Agent ID to delete" }),
|
||||
force: Type.Optional(Type.Boolean({ description: "Force delete when holding checkout" })),
|
||||
reassign_to: Type.Optional(Type.String({ description: "Optional replacement agent for assigned tasks" })),
|
||||
}),
|
||||
@@ -2599,25 +2614,33 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
const agentStore = new AgentStore({ rootDir: getFusionDir(ctx.cwd) });
|
||||
await agentStore.init();
|
||||
const store = await getStore(ctx.cwd);
|
||||
const caller = { id: "user", role: "user", isPrivileged: true } as const;
|
||||
const policy = resolveAgentProvisioningPolicy({
|
||||
tool: "fn_agent_delete",
|
||||
caller: { id: "user", role: "user", isPrivileged: true },
|
||||
caller,
|
||||
settings: await store.getSettings(),
|
||||
});
|
||||
|
||||
if (policy.decision === "require-approval") {
|
||||
const approvalStore = new ApprovalRequestStore((store as unknown as { db: unknown }).db as never);
|
||||
const approvalStore = new ApprovalRequestStore(store.getDatabase());
|
||||
const request = approvalStore.create({
|
||||
requester: { actorId: "user", actorType: "user", actorName: "CLI User" },
|
||||
targetAction: { category: "agent_provisioning", action: "delete", summary: `Delete agent ${params.id}`, resourceType: "agent", resourceId: params.id, context: { tool: "fn_agent_delete", params } },
|
||||
targetAction: { category: "agent_provisioning", action: "delete", summary: `Delete agent ${params.agent_id}`, resourceType: "agent", resourceId: params.agent_id, context: { tool: "fn_agent_delete", params } },
|
||||
});
|
||||
return { content: [{ type: "text" as const, text: `Approval required. Request ${request.id} created.` }], details: { outcome: "pending_approval", approvalRequestId: request.id, matchedRule: policy.matchedRule, effectiveMode: policy.effectiveMode, agentId: params.id } };
|
||||
return { content: [{ type: "text" as const, text: `Approval required. Request ${request.id} created.` }], details: { outcome: "pending_approval", approvalRequestId: request.id, matchedRule: policy.matchedRule, effectiveMode: policy.effectiveMode, agentId: params.agent_id } };
|
||||
}
|
||||
|
||||
await agentStore.deleteAgent(params.id, { force: params.force === true, reassignTo: params.reassign_to });
|
||||
if (policy.decision === "deny") {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `DENIED: agent delete blocked by policy (${policy.matchedRule})` }],
|
||||
details: { outcome: "denied", matchedRule: policy.matchedRule, effectiveMode: policy.effectiveMode, agentId: params.agent_id },
|
||||
};
|
||||
}
|
||||
|
||||
await agentStore.deleteAgent(params.agent_id, { force: params.force === true, reassignTo: params.reassign_to });
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Deleted ${params.id}` }],
|
||||
details: { outcome: "deleted", matchedRule: policy.matchedRule, effectiveMode: policy.effectiveMode, agentId: params.id },
|
||||
content: [{ type: "text" as const, text: `Deleted ${params.agent_id}` }],
|
||||
details: { outcome: "deleted", matchedRule: policy.matchedRule, effectiveMode: policy.effectiveMode, agentId: params.agent_id },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -319,6 +319,21 @@ describe("approval routes", async () => {
|
||||
expect(state.runAuditEvents.at(-1)).toMatchObject({ mutationType: "agent:delete:approved", runId: "run-2" });
|
||||
});
|
||||
|
||||
it("denies provisioning delete without execution and records denied audit", async () => {
|
||||
const app = createApp();
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/approvals/apr-4/decision",
|
||||
JSON.stringify({ decision: "deny" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(executeApprovedAgentProvisioning).not.toHaveBeenCalled();
|
||||
expect(state.provisionedAgents.has("target-1")).toBe(true);
|
||||
expect(state.runAuditEvents.at(-1)).toMatchObject({ mutationType: "agent:delete:denied", runId: "run-2" });
|
||||
});
|
||||
|
||||
it("returns 500 for malformed provisioning request context", async () => {
|
||||
const app = createApp();
|
||||
const res = await request(
|
||||
@@ -332,7 +347,8 @@ describe("approval routes", async () => {
|
||||
expect(res.body.error).toContain("Malformed agent provisioning request");
|
||||
});
|
||||
|
||||
it("returns 409 for invalid transition", async () => { const app = createApp();
|
||||
it("returns 409 for invalid transition", async () => {
|
||||
const app = createApp();
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
|
||||
@@ -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