Files
fusion/packages/engine/src/agent-action-gate.ts
Fusion 2005a0f6e8 feat(FN-3731): add action-gate reload endpoint for reloadable exempt-tools
Adds a reload endpoint for action-gate exempt tools, exposing `POST /api/system/reload-exempt-tools` on the dashboard to hot-reload the engine's mutable EXEMPT_TOOLS registry without a restart. The endpoint is wired through `agent-action-gate.ts` with test coverage for reloadable behavior.

Fusion-Task-Id: FN-3731
2026-05-08 07:18:58 -07:00

305 lines
8.8 KiB
TypeScript

import type {
AgentPermissionPolicy,
AgentPermissionPolicyActionCategory,
AgentPermissionPolicyDisposition,
} from "@fusion/core";
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<string, unknown>;
}
export interface AgentActionGateContext {
agentId: string;
agentName: string;
isEphemeral: boolean;
taskId?: string;
runId?: string;
permissionPolicy: AgentPermissionPolicy;
createApprovalRequest: (decision: AgentActionGateDecision, args: Record<string, unknown>) => Promise<unknown>;
findPendingApprovalByDedupeKey: (dedupeKey: string) => Promise<unknown | null>;
}
// 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 = [
"read",
"find",
"grep",
"ls",
"fn_task_update",
"fn_task_log",
"fn_task_done",
"fn_task_document_write",
"fn_task_document_read",
"fn_memory_search",
"fn_memory_get",
"fn_read_messages",
"fn_heartbeat_done",
"fn_task_create",
"fn_delegate_task",
"fn_list_agents",
"fn_agent_show",
"fn_agent_org_chart",
"fn_send_message",
"fn_memory_append",
"fn_read_evaluations",
"fn_update_identity",
"fn_reflect_on_performance",
] as const;
let _exemptTools: Set<string> | null = null;
function getExemptTools(): Set<string> {
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 = new Set([
"fn_task_create",
"fn_task_add_dep",
"fn_delegate_task",
"fn_spawn_agent",
"fn_update_agent_config",
"fn_update_identity",
]);
const NETWORK_API_TOOLS = new Set(["fn_research_run"]);
const READONLY_DISCOVERY_TOOLS = new Set(["read", "find", "grep", "ls"]);
const GIT_WRITE_SUBCOMMANDS = new Set([
"add",
"commit",
"merge",
"rebase",
"cherry-pick",
"am",
"apply",
"stash",
"tag",
"push",
"reset",
"rm",
"mv",
"clean",
]);
const GIT_READONLY_SUBCOMMANDS = new Set([
"status",
"diff",
"log",
"show",
"rev-parse",
]);
function normalizeArgs(args: unknown): Record<string, unknown> {
return args && typeof args === "object" ? (args as Record<string, unknown>) : {};
}
function extractShellCommand(args: Record<string, unknown>): string {
const command = args.command;
return typeof command === "string" ? command.trim() : "";
}
function classifyGitCommand(command: string): { write: boolean; operation: string } | null {
const match = command.match(/(?:^|&&|\|\||;|\n)\s*git\s+([^\s]+)/);
if (!match) return null;
const sub = match[1]?.trim() ?? "";
if (!sub) return { write: false, operation: "git" };
if (GIT_READONLY_SUBCOMMANDS.has(sub)) {
if (sub === "rev-parse" && /--show-current\b/.test(command)) {
return { write: false, operation: "git rev-parse --show-current" };
}
return { write: false, operation: `git ${sub}` };
}
if (sub === "branch") {
const mutatingFlags = /\s-d\b|\s-D\b|\s-m\b|\s-M\b|\s-c\b|\s-C\b/.test(command);
if (mutatingFlags) {
return { write: true, operation: "git branch" };
}
const tail = command.replace(/^[\s\S]*?\bgit\s+branch\b/, "").trim();
const hasPositionalArg = tail.length > 0 && !tail.startsWith("-");
if (hasPositionalArg) {
return { write: true, operation: "git branch" };
}
return { write: false, operation: /--show-current\b/.test(command) ? "git branch --show-current" : "git branch" };
}
if (sub === "switch") {
return { write: /\s-c\b/.test(command), operation: /\s-c\b/.test(command) ? "git switch -c" : "git switch" };
}
if (sub === "checkout") {
return { write: /\s-b\b/.test(command), operation: /\s-b\b/.test(command) ? "git checkout -b" : "git checkout" };
}
if (sub === "pull") {
return { write: /--rebase\b/.test(command), operation: /--rebase\b/.test(command) ? "git pull --rebase" : "git pull" };
}
if (sub === "restore") {
return { write: /--staged\b/.test(command), operation: /--staged\b/.test(command) ? "git restore --staged" : "git restore" };
}
if (sub === "remote") {
const write = /\s+add\b|\s+remove\b|\s+rename\b|\s+set-url\b/.test(command);
return { write, operation: /\s-v\b/.test(command) ? "git remote -v" : "git remote" };
}
if (sub === "worktree") {
if (/\s+add\b/.test(command)) return { write: true, operation: "git worktree add" };
if (/\s+remove\b/.test(command)) return { write: true, operation: "git worktree remove" };
return { write: false, operation: "git worktree" };
}
return { write: GIT_WRITE_SUBCOMMANDS.has(sub), operation: `git ${sub}` };
}
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 buildGateRejection(decision: AgentActionGateDecision, reason: string) {
return {
content: [{ type: "text", text: reason }],
isError: true,
ok: false,
error: reason,
decision,
};
}