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
This commit is contained in:
Fusion
2026-05-08 07:12:40 -07:00
committed by gsxdsm
parent de78113bf2
commit 2005a0f6e8
7 changed files with 226 additions and 29 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Add a runtime action-gate exempt-tools reload API so operators can refresh exemptions without restarting the engine process.

View File

@@ -87,6 +87,7 @@ Unknown/unclassified tool fallback:
- In permanent-agent sessions, unknown tools default to `require-approval` (fail-safe). - In permanent-agent sessions, unknown tools default to `require-approval` (fail-safe).
- Category `none` only yields `allow` when the tool is positively recognized as read-only. - Category `none` only yields `allow` when the tool is positively recognized as read-only.
- Internal Fusion runtime coordination tools (heartbeat completion, task/agent coordination, messaging, evaluations, identity reflection, memory bookkeeping) are exempt by design and always allowed so permanent-agent heartbeats can complete. - Internal Fusion runtime coordination tools (heartbeat completion, task/agent coordination, messaging, evaluations, identity reflection, memory bookkeeping) are exempt by design and always allowed so permanent-agent heartbeats can complete.
- Operators can reload the in-memory exempt-tool registry at runtime via `POST /api/action-gate/reload` (optional body `{ "tools": string[] }`) to apply exemption-list updates without restarting the engine process.
Interim enforcement behavior (persistence-integrated, pre-resume lifecycle): Interim enforcement behavior (persistence-integrated, pre-resume lifecycle):

View File

@@ -44,11 +44,20 @@ const mockCentralListProjects = vi.fn().mockResolvedValue([]);
const mockCentralInit = vi.fn().mockResolvedValue(undefined); const mockCentralInit = vi.fn().mockResolvedValue(undefined);
const mockCentralClose = vi.fn().mockResolvedValue(undefined); const mockCentralClose = vi.fn().mockResolvedValue(undefined);
const mockCentralReconcileProjectStatuses = vi.fn().mockResolvedValue(undefined); const mockCentralReconcileProjectStatuses = vi.fn().mockResolvedValue(undefined);
const { mockPerformUpdateCheck, mockClearUpdateCheckCache, mockExecSync, mockExecFile } = vi.hoisted(() => ({ const {
mockPerformUpdateCheck,
mockClearUpdateCheckCache,
mockExecSync,
mockExecFile,
mockReloadExemptTools,
mockGetExemptToolNames,
} = vi.hoisted(() => ({
mockPerformUpdateCheck: vi.fn(), mockPerformUpdateCheck: vi.fn(),
mockClearUpdateCheckCache: vi.fn(), mockClearUpdateCheckCache: vi.fn(),
mockExecSync: vi.fn(), mockExecSync: vi.fn(),
mockExecFile: vi.fn(), mockExecFile: vi.fn(),
mockReloadExemptTools: vi.fn(),
mockGetExemptToolNames: vi.fn().mockReturnValue(["read", "find"]),
})); }));
vi.mock("../update-check.js", async () => { vi.mock("../update-check.js", async () => {
@@ -111,6 +120,8 @@ vi.mock("@fusion/core", async (importOriginal) => {
vi.mock("@fusion/engine", async () => { vi.mock("@fusion/engine", async () => {
const { createEngineMock } = await import("../test/mockCoreEngine.js"); const { createEngineMock } = await import("../test/mockCoreEngine.js");
return createEngineMock({ return createEngineMock({
reloadExemptTools: mockReloadExemptTools,
getExemptToolNames: mockGetExemptToolNames,
createFnAgent: vi.fn(async (options?: { onText?: (delta: string) => void }) => ({ createFnAgent: vi.fn(async (options?: { onText?: (delta: string) => void }) => ({
session: { session: {
state: { state: {
@@ -310,6 +321,54 @@ describe("route registrar ordering invariants", () => {
}); });
}); });
describe("POST /api/action-gate/reload", () => {
function buildApp(store: TaskStore, options?: Parameters<typeof createApiRoutes>[1]) {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, options));
return app;
}
beforeEach(() => {
mockReloadExemptTools.mockReset();
mockGetExemptToolNames.mockReset();
mockGetExemptToolNames.mockReturnValue(["read", "find"]);
});
it("reloads defaults when no tools body is provided", async () => {
const store = createMockStore();
const res = await REQUEST(buildApp(store), "POST", "/api/action-gate/reload", JSON.stringify({}), {
"content-type": "application/json",
});
expect(res.status).toBe(200);
expect(mockReloadExemptTools).toHaveBeenCalledWith();
expect(res.body).toEqual({ ok: true, tools: ["read", "find"] });
});
it("reloads explicit tool list when tools are provided", async () => {
const store = createMockStore();
mockGetExemptToolNames.mockReturnValue(["custom_tool"]);
const res = await REQUEST(buildApp(store), "POST", "/api/action-gate/reload", JSON.stringify({ tools: ["custom_tool"] }), {
"content-type": "application/json",
});
expect(res.status).toBe(200);
expect(mockReloadExemptTools).toHaveBeenCalledWith(["custom_tool"]);
expect(res.body).toEqual({ ok: true, tools: ["custom_tool"] });
});
it("returns bad request when tools is not a string array", async () => {
const store = createMockStore();
const res = await REQUEST(buildApp(store), "POST", "/api/action-gate/reload", JSON.stringify({ tools: ["ok", 1] }), {
"content-type": "application/json",
});
expect(res.status).toBe(400);
expect(mockReloadExemptTools).not.toHaveBeenCalled();
});
});
describe("GET /api/system-stats", () => { describe("GET /api/system-stats", () => {
const projectId = "proj-system-stats"; const projectId = "proj-system-stats";

View File

@@ -295,7 +295,12 @@ async function discoverDashboardPiExtensions(cwd: string): Promise<PiExtensionSe
}; };
} }
import { createFnAgent as engineCreateFnAgentForRefine, promptWithFallback as enginePromptWithFallback } from "@fusion/engine"; import {
createFnAgent as engineCreateFnAgentForRefine,
getExemptToolNames as engineGetExemptToolNames,
promptWithFallback as enginePromptWithFallback,
reloadExemptTools as engineReloadExemptTools,
} from "@fusion/engine";
// Test-injectable override; defaults to the statically imported engine binding. // Test-injectable override; defaults to the statically imported engine binding.
let createFnAgentForRefine: typeof import("@fusion/engine").createFnAgent | undefined = engineCreateFnAgentForRefine; let createFnAgentForRefine: typeof import("@fusion/engine").createFnAgent | undefined = engineCreateFnAgentForRefine;
@@ -1269,6 +1274,33 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
} }
}); });
/**
* POST /api/action-gate/reload
* Reloads action-gate exempt tools from defaults or a provided override list.
*/
router.post("/action-gate/reload", async (req, res) => {
try {
const body = (req.body && typeof req.body === "object") ? (req.body as Record<string, unknown>) : {};
const hasTools = Object.hasOwn(body, "tools");
if (hasTools) {
const tools = body.tools;
if (!Array.isArray(tools) || tools.some((tool) => typeof tool !== "string")) {
throw badRequest("Request body must provide tools as string[] when present");
}
engineReloadExemptTools(tools);
} else {
engineReloadExemptTools();
}
res.json({ ok: true, tools: engineGetExemptToolNames() });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/** /**
* GET /api/executor/stats * GET /api/executor/stats
* Returns executor status metadata for dashboard status surfaces. * Returns executor status metadata for dashboard status surfaces.

View File

@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import { evaluateAgentActionGate, computeApprovalDedupeKey } from "../agent-action-gate.js"; import {
addToExemptTools,
computeApprovalDedupeKey,
evaluateAgentActionGate,
reloadExemptTools,
} from "../agent-action-gate.js";
import type { AgentPermissionPolicy } from "@fusion/core"; import type { AgentPermissionPolicy } from "@fusion/core";
const unrestrictedPolicy: AgentPermissionPolicy = { const unrestrictedPolicy: AgentPermissionPolicy = {
@@ -13,6 +18,17 @@ const unrestrictedPolicy: AgentPermissionPolicy = {
}, },
}; };
const lockedDownPolicy: AgentPermissionPolicy = {
presetId: "locked-down",
rules: {
"git_write": "block",
"file_write_delete": "block",
"command_execution": "block",
"network_api": "block",
"task_agent_mutation": "block",
},
};
const approvalPolicy: AgentPermissionPolicy = { const approvalPolicy: AgentPermissionPolicy = {
...unrestrictedPolicy, ...unrestrictedPolicy,
presetId: "approval-required", presetId: "approval-required",
@@ -26,6 +42,9 @@ const approvalPolicy: AgentPermissionPolicy = {
}; };
describe("agent-action-gate", () => { describe("agent-action-gate", () => {
beforeEach(() => {
reloadExemptTools();
});
it("classifies write/edit as file_write_delete", () => { it("classifies write/edit as file_write_delete", () => {
const write = evaluateAgentActionGate({ agentId: "a1", toolName: "write", args: { path: "a.ts" }, permissionPolicy: unrestrictedPolicy }); const write = evaluateAgentActionGate({ agentId: "a1", toolName: "write", args: { path: "a.ts" }, permissionPolicy: unrestrictedPolicy });
const edit = evaluateAgentActionGate({ agentId: "a1", toolName: "edit", args: { path: "a.ts" }, permissionPolicy: unrestrictedPolicy }); const edit = evaluateAgentActionGate({ agentId: "a1", toolName: "edit", args: { path: "a.ts" }, permissionPolicy: unrestrictedPolicy });
@@ -93,34 +112,12 @@ describe("agent-action-gate", () => {
"fn_update_identity", "fn_update_identity",
"fn_reflect_on_performance", "fn_reflect_on_performance",
])("always allows newly exempt internal tool %s under locked-down policies", (toolName) => { ])("always allows newly exempt internal tool %s under locked-down policies", (toolName) => {
const lockedDownPolicy: AgentPermissionPolicy = {
presetId: "locked-down",
rules: {
"git_write": "block",
"file_write_delete": "block",
"command_execution": "block",
"network_api": "block",
"task_agent_mutation": "block",
},
};
const decision = evaluateAgentActionGate({ agentId: "a1", toolName, args: {}, permissionPolicy: lockedDownPolicy }); const decision = evaluateAgentActionGate({ agentId: "a1", toolName, args: {}, permissionPolicy: lockedDownPolicy });
expect(decision.disposition).toBe("allow"); expect(decision.disposition).toBe("allow");
expect(decision.category).toBe("exempt"); expect(decision.category).toBe("exempt");
}); });
it("keeps bash and write blocked under locked-down policy", () => { it("keeps bash and write blocked under locked-down policy", () => {
const lockedDownPolicy: AgentPermissionPolicy = {
presetId: "locked-down",
rules: {
"git_write": "block",
"file_write_delete": "block",
"command_execution": "block",
"network_api": "block",
"task_agent_mutation": "block",
},
};
const bashDecision = evaluateAgentActionGate({ const bashDecision = evaluateAgentActionGate({
agentId: "a1", agentId: "a1",
toolName: "bash", toolName: "bash",
@@ -138,6 +135,64 @@ describe("agent-action-gate", () => {
expect(writeDecision.disposition).toBe("block"); expect(writeDecision.disposition).toBe("block");
}); });
it("uses default exemptions without reload", () => {
const decision = evaluateAgentActionGate({
agentId: "a1",
toolName: "read",
args: {},
permissionPolicy: lockedDownPolicy,
});
expect(decision.category).toBe("exempt");
expect(decision.disposition).toBe("allow");
});
it("reloadExemptTools can replace and restore exemptions", () => {
reloadExemptTools(["custom_tool"]);
const customDecision = evaluateAgentActionGate({
agentId: "a1",
toolName: "custom_tool",
args: {},
permissionPolicy: lockedDownPolicy,
});
expect(customDecision.category).toBe("exempt");
expect(customDecision.disposition).toBe("allow");
reloadExemptTools([]);
const readBlocked = evaluateAgentActionGate({
agentId: "a1",
toolName: "read",
args: {},
permissionPolicy: lockedDownPolicy,
});
expect(readBlocked.category).toBe("command_execution");
expect(readBlocked.disposition).toBe("block");
reloadExemptTools();
const readRestored = evaluateAgentActionGate({
agentId: "a1",
toolName: "read",
args: {},
permissionPolicy: lockedDownPolicy,
});
expect(readRestored.category).toBe("exempt");
expect(readRestored.disposition).toBe("allow");
});
it("addToExemptTools exempts a custom tool", () => {
addToExemptTools("my_new_tool");
const decision = evaluateAgentActionGate({
agentId: "a1",
toolName: "my_new_tool",
args: {},
permissionPolicy: lockedDownPolicy,
});
expect(decision.category).toBe("exempt");
expect(decision.disposition).toBe("allow");
});
it("resolves disposition from policy", () => { it("resolves disposition from policy", () => {
const result = evaluateAgentActionGate({ agentId: "a1", toolName: "write", args: { path: "a.ts" }, permissionPolicy: approvalPolicy }); const result = evaluateAgentActionGate({ agentId: "a1", toolName: "write", args: { path: "a.ts" }, permissionPolicy: approvalPolicy });
expect(result.disposition).toBe("require-approval"); expect(result.disposition).toBe("require-approval");

View File

@@ -3,6 +3,7 @@ import type {
AgentPermissionPolicyActionCategory, AgentPermissionPolicyActionCategory,
AgentPermissionPolicyDisposition, AgentPermissionPolicyDisposition,
} from "@fusion/core"; } from "@fusion/core";
import { runtimeLog } from "./logger.js";
export type AgentActionGateResourceType = "file" | "git" | "task" | "agent" | "research" | "command" | "other"; export type AgentActionGateResourceType = "file" | "git" | "task" | "agent" | "research" | "command" | "other";
@@ -31,7 +32,7 @@ export interface AgentActionGateContext {
// FN-3724: Internal Fusion runtime/coordinator tools never perform external mutations. // 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. // They must bypass user-configurable approval/block policies so permanent-agent heartbeats cannot deadlock.
const EXEMPT_TOOLS = new Set([ const DEFAULT_EXEMPT_TOOLS = [
"read", "read",
"find", "find",
"grep", "grep",
@@ -55,7 +56,44 @@ const EXEMPT_TOOLS = new Set([
"fn_read_evaluations", "fn_read_evaluations",
"fn_update_identity", "fn_update_identity",
"fn_reflect_on_performance", "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([ const TASK_AGENT_MANAGEMENT_TOOLS = new Set([
"fn_task_create", "fn_task_create",
@@ -68,6 +106,8 @@ const TASK_AGENT_MANAGEMENT_TOOLS = new Set([
const NETWORK_API_TOOLS = new Set(["fn_research_run"]); 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([ const GIT_WRITE_SUBCOMMANDS = new Set([
"add", "add",
"commit", "commit",
@@ -209,9 +249,13 @@ export function evaluateAgentActionGate(params: {
operation = params.toolName; operation = params.toolName;
resourceType = "file"; resourceType = "file";
resourceId = typeof args.path === "string" ? args.path : undefined; resourceId = typeof args.path === "string" ? args.path : undefined;
} else if (EXEMPT_TOOLS.has(params.toolName)) { } else if (getExemptTools().has(params.toolName)) {
category = "exempt"; category = "exempt";
operation = params.toolName; 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)) { } else if (TASK_AGENT_MANAGEMENT_TOOLS.has(params.toolName)) {
category = "task_agent_mutation"; category = "task_agent_mutation";
operation = params.toolName; operation = params.toolName;

View File

@@ -1,4 +1,5 @@
export { AgentLogger, type AgentLoggerOptions, summarizeToolArgs } from "./agent-logger.js"; export { AgentLogger, type AgentLoggerOptions, summarizeToolArgs } from "./agent-logger.js";
export { reloadExemptTools, addToExemptTools, getExemptToolNames } from "./agent-action-gate.js";
export { export {
createTaskCreateTool, createTaskCreateTool,
createTaskDocumentReadTool, createTaskDocumentReadTool,