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

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
Add agent provisioning policy plumbing for `fn_agent_create`/`fn_agent_delete`, including
`agent_provisioning` approval categorization and action-gate classification updates to avoid
double-approval collisions.

View File

@@ -212,6 +212,7 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
| `verificationFixRetries` | `number` | `2` | Auto-fix retry attempts when verification fails during merge. |
| `buildTimeoutMs` | `number` | `300000` | Build timeout in milliseconds (5 minutes). |
| `requirePlanApproval` | `boolean` | `false` | Require manual approval before planning → todo. |
| `agentProvisioning` | `{ approvalMode?: "always" \| "trusted-only" \| "never"; trustedRoles?: string[]; trustedAgentIds?: string[]; alwaysApproveDelete?: boolean }` | `{}` | Approval policy for `fn_agent_create`/`fn_agent_delete` (`approvalMode` default `trusted-only`, delete approvals default on via `alwaysApproveDelete: true`). |
| `completionDocumentationMode` | `"off" \| "changeset" \| "changelog"` | `"off"` | Controls triage prompt injection for release-note artifacts in future task specs. `"changeset"` requires `.changeset/*.md` workflow guidance; `"changelog"` requires updating an existing changelog file (without inventing a new one); `"off"` disables this automation. |
| `specStalenessEnabled` | `boolean` | `false` | Enforce automatic re-planning for stale plans. |
| `specStalenessMaxAgeMs` | `number` | `21600000` | Spec staleness threshold in ms (6 hours). |

View File

@@ -18,6 +18,7 @@ import {
resolveResearchSettings,
canAgentTakeImplementationTask,
formatRoleMismatchReason,
resolveAgentProvisioningPolicy,
} from "@fusion/core";
import {
getGhErrorMessage,
@@ -2525,9 +2526,24 @@ export default function kbExtension(pi: ExtensionAPI) {
message_response_mode: Type.Optional(Type.Union([Type.Literal("immediate"), Type.Literal("on-heartbeat")])),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const { AgentStore } = await import("@fusion/core");
const { AgentStore, ApprovalRequestStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: getFusionDir(ctx.cwd) });
await agentStore.init();
const store = await getStore(ctx.cwd);
const policy = resolveAgentProvisioningPolicy({
tool: "fn_agent_create",
caller: { id: "user", role: "user", isPrivileged: true },
settings: await store.getSettings(),
});
if (policy.decision === "require-approval") {
const approvalStore = new ApprovalRequestStore((store as unknown as { db: unknown }).db as never);
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 } },
});
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 } };
}
const runtimeConfig: Record<string, unknown> = {
...(params.heartbeat_interval_ms !== undefined ? { heartbeatIntervalMs: params.heartbeat_interval_ms } : {}),
@@ -2535,7 +2551,6 @@ export default function kbExtension(pi: ExtensionAPI) {
...(params.max_concurrent_runs !== undefined ? { maxConcurrentRuns: params.max_concurrent_runs } : {}),
...(params.message_response_mode !== undefined ? { messageResponseMode: params.message_response_mode } : {}),
};
const created = await agentStore.createAgent({
name: params.name,
role: params.role as never,
@@ -2548,7 +2563,7 @@ export default function kbExtension(pi: ExtensionAPI) {
return {
content: [{ type: "text" as const, text: `Created agent ${created.name} (${created.id})` }],
details: { agent: created },
details: { outcome: "created", matchedRule: policy.matchedRule, effectiveMode: policy.effectiveMode, agent: created, agentId: created.id },
};
},
});
@@ -2565,11 +2580,30 @@ export default function kbExtension(pi: ExtensionAPI) {
reassign_to: Type.Optional(Type.String({ description: "Optional replacement agent for assigned tasks" })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const { AgentStore } = await import("@fusion/core");
const { AgentStore, ApprovalRequestStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: getFusionDir(ctx.cwd) });
await agentStore.init();
const store = await getStore(ctx.cwd);
const policy = resolveAgentProvisioningPolicy({
tool: "fn_agent_delete",
caller: { id: "user", role: "user", isPrivileged: true },
settings: await store.getSettings(),
});
if (policy.decision === "require-approval") {
const approvalStore = new ApprovalRequestStore((store as unknown as { db: unknown }).db as never);
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 } },
});
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 } };
}
await agentStore.deleteAgent(params.id, { force: params.force === true, reassignTo: params.reassign_to });
return { content: [{ type: "text" as const, text: `Deleted ${params.id}` }], details: { agentId: params.id } };
return {
content: [{ type: "text" as const, text: `Deleted ${params.id}` }],
details: { outcome: "deleted", matchedRule: policy.matchedRule, effectiveMode: policy.effectiveMode, agentId: params.id },
};
},
});

View File

@@ -0,0 +1,107 @@
import type { AgentProvisioningApprovalMode, ApprovalRequest, ProjectSettings } from "./types.js";
type AgentProvisioningSettings = Pick<ProjectSettings, "agentProvisioning">;
export type AgentProvisioningTool = "fn_agent_create" | "fn_agent_delete";
export interface AgentProvisioningPolicyInput {
tool: AgentProvisioningTool;
caller?: { id: string; role?: string; isPrivileged?: boolean };
settings: AgentProvisioningSettings | undefined;
}
export interface AgentProvisioningPolicyDecision {
decision: "allow" | "require-approval" | "deny";
reason: string;
matchedRule:
| "privileged-caller"
| "trusted-agent-id"
| "trusted-role"
| "approval-mode-always"
| "approval-mode-trusted-only"
| "approval-mode-never"
| "delete-always-approve"
| "missing-caller";
effectiveMode: AgentProvisioningApprovalMode;
}
function normalizeMode(settings: AgentProvisioningSettings | undefined): AgentProvisioningApprovalMode {
return settings?.agentProvisioning?.approvalMode ?? "trusted-only";
}
export function resolveAgentProvisioningPolicy(input: AgentProvisioningPolicyInput): AgentProvisioningPolicyDecision {
const effectiveMode = normalizeMode(input.settings);
const caller = input.caller;
if (!caller) {
return { decision: "deny", reason: "missing caller", matchedRule: "missing-caller", effectiveMode };
}
if (caller.isPrivileged === true) {
return { decision: "allow", reason: "privileged caller", matchedRule: "privileged-caller", effectiveMode };
}
if (effectiveMode === "never") {
return { decision: "allow", reason: "approval mode never", matchedRule: "approval-mode-never", effectiveMode };
}
const alwaysApproveDelete = input.settings?.agentProvisioning?.alwaysApproveDelete ?? true;
if (input.tool === "fn_agent_delete" && alwaysApproveDelete) {
return {
decision: "require-approval",
reason: "delete requires approval by policy",
matchedRule: "delete-always-approve",
effectiveMode,
};
}
const trustedAgentIds = input.settings?.agentProvisioning?.trustedAgentIds ?? [];
if (trustedAgentIds.includes(caller.id)) {
return { decision: "allow", reason: "trusted agent id", matchedRule: "trusted-agent-id", effectiveMode };
}
const trustedRoles = (input.settings?.agentProvisioning?.trustedRoles ?? []).map((role) => role.toLowerCase());
if (caller.role && trustedRoles.includes(caller.role.toLowerCase())) {
return { decision: "allow", reason: "trusted role", matchedRule: "trusted-role", effectiveMode };
}
if (effectiveMode === "always") {
return {
decision: "require-approval",
reason: "approval mode always",
matchedRule: "approval-mode-always",
effectiveMode,
};
}
return {
decision: "require-approval",
reason: "trusted-only requires trusted caller",
matchedRule: "approval-mode-trusted-only",
effectiveMode,
};
}
export function extractAgentProvisioningRequest(approvalRequest: ApprovalRequest): {
tool: AgentProvisioningTool;
params: Record<string, unknown>;
} {
if (approvalRequest.targetAction.category !== "agent_provisioning") {
throw new Error(`Approval request ${approvalRequest.id} is not an agent_provisioning request`);
}
const context = approvalRequest.targetAction.context;
if (!context || typeof context !== "object") {
throw new Error(`Approval request ${approvalRequest.id} is missing provisioning context`);
}
const tool = context.tool;
if (tool !== "fn_agent_create" && tool !== "fn_agent_delete") {
throw new Error(`Approval request ${approvalRequest.id} has invalid provisioning tool`);
}
const params = context.params;
if (!params || typeof params !== "object") {
throw new Error(`Approval request ${approvalRequest.id} has invalid provisioning params`);
}
return { tool, params: params as Record<string, unknown> };
}

View File

@@ -1,5 +1,5 @@
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeConflictStrategy, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeConflictStrategy, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export { AGENT_VALID_TRANSITIONS } from "./types.js";
export type { TaskReviewData, TaskReviewSummary, TaskReviewItem } from "./types.js";
export * from "./mesh-replication-protocol.js";
@@ -70,6 +70,15 @@ export type { ReflectionStoreEvents } from "./reflection-store.js";
export { MessageStore } from "./message-store.js";
export type { MessageStoreEvents } from "./message-store.js";
export { ApprovalRequestStore } from "./approval-request-store.js";
export {
resolveAgentProvisioningPolicy,
extractAgentProvisioningRequest,
} from "./agent-provisioning-policy.js";
export type {
AgentProvisioningTool,
AgentProvisioningPolicyInput,
AgentProvisioningPolicyDecision,
} from "./agent-provisioning-policy.js";
export { TaskStore } from "./store.js";
export {
createDistributedTaskIdAllocator,

View File

@@ -212,6 +212,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
verificationFixRetries: 3,
buildTimeoutMs: 300_000,
requirePlanApproval: false,
agentProvisioning: {},
specStalenessEnabled: false,
specStalenessMaxAgeMs: 6 * 60 * 60 * 1000,
taskStuckTimeoutMs: 600_000,

View File

@@ -2169,6 +2169,13 @@ export interface ProjectSettings {
* remain in triage with status "awaiting-approval" until a user approves
* or rejects the plan. Default: false. */
requirePlanApproval?: boolean;
/** Approval policy for agent provisioning tools (fn_agent_create/fn_agent_delete). */
agentProvisioning?: {
approvalMode?: AgentProvisioningApprovalMode;
trustedRoles?: string[];
trustedAgentIds?: string[];
alwaysApproveDelete?: boolean;
};
/** When true, enforces that task specifications (PROMPT.md) are refreshed if they
* become stale. Stale specs are detected based on specStalenessMaxAgeMs.
* Default: false. */
@@ -4019,8 +4026,12 @@ export const AGENT_PERMISSION_POLICY_ACTION_CATEGORIES: readonly PermanentAgentS
"task_agent_mutation",
] as const;
export const AGENT_PROVISIONING_APPROVAL_MODES = ["always", "trusted-only", "never"] as const;
export type AgentProvisioningApprovalMode = (typeof AGENT_PROVISIONING_APPROVAL_MODES)[number];
/** A single runtime action category governed by permission policy. */
export type AgentPermissionPolicyActionCategory = PermanentAgentSensitiveActionCategory;
export type ApprovalRequestActionCategory = AgentPermissionPolicyActionCategory | "agent_provisioning";
/** How a runtime action category is handled by permission policy. */
export type AgentPermissionPolicyDisposition = "allow" | "block" | "require-approval";
@@ -4100,13 +4111,13 @@ export type LegacyAgentPermissionPolicyActionCategory =
/** Canonical + compatibility action-category input accepted at boundaries. */
export type ApprovalRequestActionCategoryInput =
| AgentPermissionPolicyActionCategory
| ApprovalRequestActionCategory
| LegacyAgentPermissionPolicyActionCategory;
/** Normalize legacy action-category aliases to canonical v1 categories. */
export function normalizeApprovalRequestActionCategory(
category: ApprovalRequestActionCategoryInput,
): AgentPermissionPolicyActionCategory {
): ApprovalRequestActionCategory {
switch (category) {
case "file_write":
case "file_delete":
@@ -4118,6 +4129,8 @@ export function normalizeApprovalRequestActionCategory(
case "task_mutation":
case "agent_mutation":
return "task_agent_mutation";
case "agent_provisioning":
return "agent_provisioning";
default:
return category;
}
@@ -4125,7 +4138,7 @@ export function normalizeApprovalRequestActionCategory(
/** Action payload gated by an approval request. */
export interface ApprovalRequestTargetAction {
category: AgentPermissionPolicyActionCategory;
category: ApprovalRequestActionCategory;
action: string;
summary: string;
resourceType: string;

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 ─────────────────────────────────────────────────