feat(FN-3953): wire agent provisioning approval policy into engine tools

Merged agent provisioning approval policies into the engine with guard controls that restrict which agents can be created and by whom, integrated through the CLI extension and core policy definitions.

Fusion-Task-Id: FN-3953
This commit is contained in:
Fusion
2026-05-10 15:27:36 -07:00
committed by gsxdsm
parent aa031ab601
commit f6a1862f93
10 changed files with 347 additions and 19 deletions

View File

@@ -11,8 +11,8 @@ import { appendFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/p
import { existsSync } from "node:fs";
import { createHash } from "node:crypto";
import { join, relative, resolve } from "node:path";
import type { AgentStore, AgentState, AgentCapability, AgentUpdateInput, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore } from "@fusion/core";
import { DASHBOARD_USER_ID, dailyMemoryPath, ensureOpenClawMemoryFiles, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, normalizeMessageParticipant, resolveMemoryBackend, resolveResearchSettings, resolveTitleSummarizerSettingsModel, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh, summarizeTitle } from "@fusion/core";
import type { AgentStore, AgentState, AgentCapability, AgentUpdateInput, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore, ApprovalRequestStore, ProjectSettings } from "@fusion/core";
import { DASHBOARD_USER_ID, dailyMemoryPath, ensureOpenClawMemoryFiles, extractAgentProvisioningRequest, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, normalizeMessageParticipant, resolveAgentProvisioningPolicy, resolveMemoryBackend, resolveResearchSettings, resolveTitleSummarizerSettingsModel, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh, summarizeTitle } from "@fusion/core";
import { ResearchOrchestrator } from "./research-orchestrator.js";
import { ResearchProviderRegistry } from "./research/provider-registry.js";
import { ResearchStepRunner } from "./research-step-runner.js";
@@ -21,6 +21,7 @@ import { Type, type Static } from "@mariozechner/pi-ai";
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";
// ── Tool parameter schemas (canonical definitions) ────────────────────────
@@ -1463,10 +1464,17 @@ export function createUpdateAgentConfigTool(agentStore: AgentStore, callingAgent
* @param taskStore - TaskStore for task creation
* @returns ToolDefinition for the `fn_delegate_task` tool
*/
type AgentProvisioningToolOptions = {
hireApprovalEnabled?: boolean;
approvalRequestStore?: ApprovalRequestStore;
settingsProvider?: () => Promise<ProjectSettings | undefined>;
runAuditor?: RunAuditor;
};
export function createAgentCreateTool(
agentStore: AgentStore,
callingAgentId: string,
options?: { hireApprovalEnabled?: boolean },
options?: AgentProvisioningToolOptions,
): ToolDefinition {
return {
name: "fn_agent_create",
@@ -1485,6 +1493,52 @@ export function createAgentCreateTool(
};
}
const settings = await options?.settingsProvider?.();
const fallbackSettings = !options?.settingsProvider && !options?.approvalRequestStore
? { agentProvisioning: { approvalMode: "never" as const } }
: settings;
const policy = resolveAgentProvisioningPolicy({
tool: "fn_agent_create",
caller: caller ? { id: caller.id, role: caller.role, isPrivileged: privileged } : undefined,
settings: fallbackSettings,
});
await options?.runAuditor?.database({ type: "agent:create:requested", target: callingAgentId, metadata: { policy } });
if (policy.decision === "require-approval") {
if (!options?.approvalRequestStore) {
await options?.runAuditor?.database({ type: "agent:create:denied", target: callingAgentId, metadata: { policy, reason: "approval-store-missing" } });
return {
content: [{ type: "text" as const, text: `DENIED: agent provisioning requires approval but approval storage is unavailable (${policy.matchedRule})` }],
details: { outcome: "denied", matchedRule: policy.matchedRule, effectiveMode: policy.effectiveMode },
};
}
const request = options.approvalRequestStore.create({
requester: { actorId: callingAgentId, actorType: "agent", actorName: caller?.name ?? callingAgentId },
targetAction: {
category: "agent_provisioning",
action: "create",
summary: `Create agent ${params.name} (${params.role})`,
resourceType: "agent",
resourceId: "",
context: { tool: "fn_agent_create", params },
},
});
return {
content: [{ type: "text" as const, text: `Approval required to create agent ${params.name}. Request ${request.id} created.` }],
details: { outcome: "pending_approval", approvalRequestId: request.id, matchedRule: policy.matchedRule, effectiveMode: policy.effectiveMode },
};
}
if (policy.decision === "deny") {
await options?.runAuditor?.database({ type: "agent:create:denied", target: callingAgentId, metadata: { policy } });
return {
content: [{ type: "text" as const, text: `DENIED: agent provisioning blocked by policy (${policy.matchedRule})` }],
details: { outcome: "denied", matchedRule: policy.matchedRule, effectiveMode: policy.effectiveMode },
};
}
const runtimeConfig: Record<string, unknown> = {
...(params.heartbeat_interval_ms !== undefined ? { heartbeatIntervalMs: params.heartbeat_interval_ms } : {}),
...(params.heartbeat_timeout_ms !== undefined ? { heartbeatTimeoutMs: params.heartbeat_timeout_ms } : {}),
@@ -1509,15 +1563,20 @@ export function createAgentCreateTool(
});
}
await options?.runAuditor?.database({ type: "agent:create:approved", target: created.id, metadata: { policy, autoApproved: true } });
return {
content: [{ type: "text" as const, text: `Created agent ${created.name} (${created.id})${options?.hireApprovalEnabled ? " in pending_approval" : ""}` }],
details: { agent: created, pendingApproval: options?.hireApprovalEnabled === true },
details: { outcome: "created", matchedRule: policy.matchedRule, effectiveMode: policy.effectiveMode, agent: created, agentId: created.id, pendingApproval: options?.hireApprovalEnabled === true },
};
},
};
}
export function createAgentDeleteTool(agentStore: AgentStore, callingAgentId: string): ToolDefinition {
export function createAgentDeleteTool(
agentStore: AgentStore,
callingAgentId: string,
options?: AgentProvisioningToolOptions,
): ToolDefinition {
return {
name: "fn_agent_delete",
label: "Delete Agent",
@@ -1527,7 +1586,10 @@ export function createAgentDeleteTool(agentStore: AgentStore, callingAgentId: st
const caller = await agentStore.getAgent(callingAgentId);
const target = await agentStore.getAgent(params.agent_id);
if (!target) {
return { content: [{ type: "text" as const, text: `ERROR: Agent ${params.agent_id} not found` }], details: {} };
return {
content: [{ type: "text" as const, text: `ERROR: Agent ${params.agent_id} not found` }],
details: { outcome: "denied", matchedRule: "missing-target", effectiveMode: "trusted-only", agentId: params.agent_id },
};
}
const privileged = isCallerPrivileged(caller);
@@ -1542,6 +1604,52 @@ export function createAgentDeleteTool(agentStore: AgentStore, callingAgentId: st
return { content: [{ type: "text" as const, text: `ERROR: Cannot delete ephemeral/runtime agent ${params.agent_id}` }], details: {} };
}
const settings = await options?.settingsProvider?.();
const fallbackSettings = !options?.settingsProvider && !options?.approvalRequestStore
? { agentProvisioning: { approvalMode: "never" as const } }
: settings;
const policy = resolveAgentProvisioningPolicy({
tool: "fn_agent_delete",
caller: caller ? { id: caller.id, role: caller.role, isPrivileged: privileged } : undefined,
settings: fallbackSettings,
});
await options?.runAuditor?.database({ type: "agent:delete:requested", target: target.id, metadata: { policy } });
if (policy.decision === "require-approval") {
if (!options?.approvalRequestStore) {
await options?.runAuditor?.database({ type: "agent:delete:denied", target: target.id, metadata: { policy, reason: "approval-store-missing" } });
return {
content: [{ type: "text" as const, text: `DENIED: agent delete requires approval but approval storage is unavailable (${policy.matchedRule})` }],
details: { outcome: "denied", matchedRule: policy.matchedRule, effectiveMode: policy.effectiveMode, agentId: target.id },
};
}
const request = options.approvalRequestStore.create({
requester: { actorId: callingAgentId, actorType: "agent", actorName: caller?.name ?? callingAgentId },
targetAction: {
category: "agent_provisioning",
action: "delete",
summary: `Delete agent ${target.name} (${target.id})`,
resourceType: "agent",
resourceId: target.id,
context: { tool: "fn_agent_delete", params },
},
});
return {
content: [{ type: "text" as const, text: `Approval required to delete agent ${target.name}. Request ${request.id} created.` }],
details: { outcome: "pending_approval", approvalRequestId: request.id, matchedRule: policy.matchedRule, effectiveMode: policy.effectiveMode, agentId: target.id },
};
}
if (policy.decision === "deny") {
await options?.runAuditor?.database({ type: "agent:delete:denied", target: target.id, metadata: { policy } });
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: target.id },
};
}
try {
await agentStore.deleteAgent(params.agent_id, { force: params.force === true, reassignTo: params.reassign_to });
} catch (error) {
@@ -1549,14 +1657,50 @@ export function createAgentDeleteTool(agentStore: AgentStore, callingAgentId: st
return { content: [{ type: "text" as const, text: `ERROR: ${message}` }], details: {} };
}
await options?.runAuditor?.database({ type: "agent:delete:approved", target: target.id, metadata: { policy, autoApproved: true } });
return {
content: [{ type: "text" as const, text: `Deleted agent ${target.name} (${target.id})` }],
details: { agentId: target.id },
details: { outcome: "deleted", matchedRule: policy.matchedRule, effectiveMode: policy.effectiveMode, agentId: target.id },
};
},
};
}
export async function executeApprovedAgentProvisioning(
approvalRequest: { id: string; status: string; targetAction: { resourceId: string } } & Parameters<typeof extractAgentProvisioningRequest>[0],
deps: { agentStore: AgentStore },
): Promise<{ deletedId: string } | Awaited<ReturnType<AgentStore["createAgent"]>>> {
if (approvalRequest.status !== "approved") {
throw new Error(`Approval request ${approvalRequest.id} must be approved before provisioning execution`);
}
const { tool, params } = extractAgentProvisioningRequest(approvalRequest);
if (tool === "fn_agent_create") {
const runtimeConfig: Record<string, unknown> = {
...(typeof params.heartbeat_interval_ms === "number" ? { heartbeatIntervalMs: params.heartbeat_interval_ms } : {}),
...(typeof params.heartbeat_timeout_ms === "number" ? { heartbeatTimeoutMs: params.heartbeat_timeout_ms } : {}),
...(typeof params.max_concurrent_runs === "number" ? { maxConcurrentRuns: params.max_concurrent_runs } : {}),
...(typeof params.message_response_mode === "string" ? { messageResponseMode: params.message_response_mode } : {}),
};
return deps.agentStore.createAgent({
name: String(params.name),
role: String(params.role) as never,
...(typeof params.soul === "string" ? { soul: params.soul } : {}),
...(typeof params.instructions_text === "string" ? { instructionsText: params.instructions_text } : {}),
...(typeof params.instructions_path === "string" ? { instructionsPath: params.instructions_path } : {}),
reportsTo: typeof params.reportsTo === "string" ? params.reportsTo : undefined,
...(Object.keys(runtimeConfig).length > 0 ? { runtimeConfig } : {}),
});
}
await deps.agentStore.deleteAgent(approvalRequest.targetAction.resourceId, {
force: params.force === true,
reassignTo: typeof params.reassign_to === "string" ? params.reassign_to : undefined,
});
return { deletedId: approvalRequest.targetAction.resourceId };
}
export function createDelegateTaskTool(
agentStore: AgentStore,
taskStore: TaskStore,

View File

@@ -5,8 +5,12 @@ export const READONLY_BUILTIN_TOOLS: ReadonlySet<string> = new Set(["read", "fin
export const FILE_WRITE_BUILTIN_TOOLS: ReadonlySet<string> = new Set(["write", "edit"]);
const SHARED_TASK_AGENT_TOOLS = ["fn_task_add_dep", "fn_spawn_agent", "fn_update_agent_config", "fn_agent_create", "fn_agent_delete"] as const;
const PROVISIONING_TOOLS = ["fn_agent_create", "fn_agent_delete"] as const;
const ACTION_GATE_TASK_AGENT_ONLY_TOOLS = ["fn_task_create", "fn_delegate_task", "fn_update_identity"] as const;
const ACTION_GATE_SHARED_TASK_AGENT_TOOLS = SHARED_TASK_AGENT_TOOLS.filter(
(tool) => !(PROVISIONING_TOOLS as readonly string[]).includes(tool),
);
const PERMANENT_TASK_AGENT_ONLY_TOOLS = [
"fn_task_pause",
"fn_task_unpause",
@@ -36,8 +40,10 @@ export const TASK_AGENT_MUTATION_TOOLS: ReadonlySet<string> = new Set([
...PERMANENT_TASK_AGENT_ONLY_TOOLS,
]);
// FN-3953: provisioning tools are gated by dedicated agent_provisioning policy;
// keep them out of action-gate task_agent_mutation to avoid double approval rows.
export const ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS: ReadonlySet<string> = new Set([
...SHARED_TASK_AGENT_TOOLS,
...ACTION_GATE_SHARED_TASK_AGENT_TOOLS,
...ACTION_GATE_TASK_AGENT_ONLY_TOOLS,
]);

View File

@@ -91,7 +91,13 @@ export type DatabaseMutationType =
| "task:unpause"
| "task:dependency:add"
| "document:write"
| "workflow-step:result";
| "workflow-step:result"
| "agent:create:requested"
| "agent:create:approved"
| "agent:create:denied"
| "agent:delete:requested"
| "agent:delete:approved"
| "agent:delete:denied";
// ── Filesystem mutation types ─────────────────────────────────────────────────