import type { AgentPermissionPolicy, AgentPermissionPolicyActionCategory, AgentPermissionPolicyDisposition, ApprovalRequestStatus, } from "@fusion/core"; import { ACTION_GATE_NETWORK_API_TOOLS, ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS, COORDINATION_EXEMPT_TOOLS, READONLY_BUILTIN_TOOLS, classifyGitCommand, } from "./gating-classifications.js"; import { runtimeLog } from "./logger.js"; export type AgentActionGateResourceType = "file" | "git" | "task" | "agent" | "research" | "command" | "other"; export interface AgentActionGateDecision { disposition: "allow" | "block" | "require-approval"; category: AgentPermissionPolicyActionCategory | "exempt"; toolName: string; operation: string; summary: string; resourceType: AgentActionGateResourceType; resourceId?: string; approvalDedupeKey: string; metadata: Record; } export interface AgentActionGateContext { agentId: string; agentName: string; isEphemeral: boolean; taskId?: string; runId?: string; permissionPolicy: AgentPermissionPolicy; createApprovalRequest: (decision: AgentActionGateDecision, args: Record) => Promise; findApprovalByDedupeKey?: (dedupeKey: string) => Promise<{ id: string; status: ApprovalRequestStatus } | null>; /** @deprecated Use findApprovalByDedupeKey */ findPendingApprovalByDedupeKey?: (dedupeKey: string) => Promise<{ id: string } | null>; pauseForApproval?: (info: { approvalRequestId: string; decision: AgentActionGateDecision }) => Promise; markApprovalCompleted?: (approvalRequestId: string) => Promise; } // FN-3724: Internal Fusion runtime/coordinator tools never perform external mutations. // They must bypass user-configurable approval/block policies so permanent-agent heartbeats cannot deadlock. const DEFAULT_EXEMPT_TOOLS = COORDINATION_EXEMPT_TOOLS; let _exemptTools: Set | null = null; function getExemptTools(): Set { if (!_exemptTools) { _exemptTools = new Set(DEFAULT_EXEMPT_TOOLS); } return _exemptTools; } /** * Reloads the exempt-tools registry used by the action gate. * If no tool list is provided, the canonical default exemption set is restored. */ export function reloadExemptTools(newTools?: string[]): string[] { const nextTools = newTools ?? [...DEFAULT_EXEMPT_TOOLS]; _exemptTools = new Set(nextTools); const toolNames = [..._exemptTools]; runtimeLog.log(`[action-gate] Reloaded exempt tools (${toolNames.length})`); return toolNames; } /** * Adds a tool to the exempt-tools registry at runtime. */ export function addToExemptTools(toolName: string): string[] { const nextTools = new Set(getExemptTools()); nextTools.add(toolName); _exemptTools = new Set(nextTools); const toolNames = [..._exemptTools]; runtimeLog.log(`[action-gate] Added exempt tool: ${toolName}`); return toolNames; } export function getExemptToolNames(): string[] { return [...getExemptTools()]; } const TASK_AGENT_MANAGEMENT_TOOLS = ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS; const NETWORK_API_TOOLS = ACTION_GATE_NETWORK_API_TOOLS; const READONLY_DISCOVERY_TOOLS = READONLY_BUILTIN_TOOLS; function normalizeArgs(args: unknown): Record { return args && typeof args === "object" ? (args as Record) : {}; } function extractShellCommand(args: Record): string { const command = args.command; return typeof command === "string" ? command.trim() : ""; } export function computeApprovalDedupeKey(input: { agentId: string; taskId?: string; toolName: string; category: string; resourceType: AgentActionGateResourceType; resourceId?: string; operation: string; }): string { return [ input.agentId, input.taskId ?? "", input.toolName, input.category, input.resourceType, input.resourceId ?? "", input.operation, ].join("|"); } export function evaluateAgentActionGate(params: { agentId: string; taskId?: string; toolName: string; args: unknown; permissionPolicy: AgentPermissionPolicy; }): AgentActionGateDecision { const args = normalizeArgs(params.args); let category: AgentPermissionPolicyActionCategory | "exempt" = "exempt"; let operation = params.toolName; let resourceType: AgentActionGateResourceType = "other"; let resourceId: string | undefined; if (params.toolName === "bash") { const command = extractShellCommand(args); const git = classifyGitCommand(command); if (git?.write) { category = "git_write"; operation = git.operation; resourceType = "git"; } else { category = "command_execution"; operation = git?.operation ?? "shell command"; resourceType = git ? "git" : "command"; } } else if (params.toolName === "write" || params.toolName === "edit") { category = "file_write_delete"; operation = params.toolName; resourceType = "file"; resourceId = typeof args.path === "string" ? args.path : undefined; } else if (getExemptTools().has(params.toolName)) { category = "exempt"; operation = params.toolName; } else if (READONLY_DISCOVERY_TOOLS.has(params.toolName)) { category = "command_execution"; operation = params.toolName; resourceType = "file"; } else if (TASK_AGENT_MANAGEMENT_TOOLS.has(params.toolName)) { category = "task_agent_mutation"; operation = params.toolName; resourceType = params.toolName.includes("agent") || params.toolName.includes("spawn") ? "agent" : "task"; } else if (NETWORK_API_TOOLS.has(params.toolName)) { category = "network_api"; operation = params.toolName; resourceType = "research"; } const disposition: AgentPermissionPolicyDisposition | "allow" = category === "exempt" ? "allow" : params.permissionPolicy.rules[category]; const dedupeKey = computeApprovalDedupeKey({ agentId: params.agentId, taskId: params.taskId, toolName: params.toolName, category, resourceType, resourceId, operation, }); return { disposition, category, toolName: params.toolName, operation, summary: `${params.toolName}: ${operation}`, resourceType, ...(resourceId ? { resourceId } : {}), approvalDedupeKey: dedupeKey, metadata: {}, }; } export function resolveGateOutcome( decision: AgentActionGateDecision, latestRequest: { id: string; status: ApprovalRequestStatus } | null, ): { outcome: "allow" | "block" | "execute-once-then-complete" | "wait-for-approval"; approvalRequestId?: string } { if (decision.disposition === "allow") { return { outcome: "allow" }; } if (decision.disposition === "block") { return { outcome: "block" }; } if (!latestRequest) { return { outcome: "wait-for-approval" }; } if (latestRequest.status === "pending") { return { outcome: "wait-for-approval", approvalRequestId: latestRequest.id }; } if (latestRequest.status === "approved") { return { outcome: "execute-once-then-complete", approvalRequestId: latestRequest.id }; } if (latestRequest.status === "denied") { return { outcome: "block", approvalRequestId: latestRequest.id }; } return { outcome: "wait-for-approval" }; } export function buildGateRejection(decision: AgentActionGateDecision, reason: string) { return { content: [{ type: "text", text: reason }], isError: true, ok: false, error: reason, decision, }; }