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:
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { evaluateAgentActionGate, computeApprovalDedupeKey } from "../agent-action-gate.js";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
addToExemptTools,
|
||||
computeApprovalDedupeKey,
|
||||
evaluateAgentActionGate,
|
||||
reloadExemptTools,
|
||||
} from "../agent-action-gate.js";
|
||||
import type { AgentPermissionPolicy } from "@fusion/core";
|
||||
|
||||
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 = {
|
||||
...unrestrictedPolicy,
|
||||
presetId: "approval-required",
|
||||
@@ -26,6 +42,9 @@ const approvalPolicy: AgentPermissionPolicy = {
|
||||
};
|
||||
|
||||
describe("agent-action-gate", () => {
|
||||
beforeEach(() => {
|
||||
reloadExemptTools();
|
||||
});
|
||||
it("classifies write/edit as file_write_delete", () => {
|
||||
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 });
|
||||
@@ -93,34 +112,12 @@ describe("agent-action-gate", () => {
|
||||
"fn_update_identity",
|
||||
"fn_reflect_on_performance",
|
||||
])("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 });
|
||||
expect(decision.disposition).toBe("allow");
|
||||
expect(decision.category).toBe("exempt");
|
||||
});
|
||||
|
||||
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({
|
||||
agentId: "a1",
|
||||
toolName: "bash",
|
||||
@@ -138,6 +135,64 @@ describe("agent-action-gate", () => {
|
||||
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", () => {
|
||||
const result = evaluateAgentActionGate({ agentId: "a1", toolName: "write", args: { path: "a.ts" }, permissionPolicy: approvalPolicy });
|
||||
expect(result.disposition).toBe("require-approval");
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
AgentPermissionPolicyActionCategory,
|
||||
AgentPermissionPolicyDisposition,
|
||||
} from "@fusion/core";
|
||||
import { runtimeLog } from "./logger.js";
|
||||
|
||||
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.
|
||||
// They must bypass user-configurable approval/block policies so permanent-agent heartbeats cannot deadlock.
|
||||
const EXEMPT_TOOLS = new Set([
|
||||
const DEFAULT_EXEMPT_TOOLS = [
|
||||
"read",
|
||||
"find",
|
||||
"grep",
|
||||
@@ -55,7 +56,44 @@ const EXEMPT_TOOLS = new Set([
|
||||
"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",
|
||||
@@ -68,6 +106,8 @@ const TASK_AGENT_MANAGEMENT_TOOLS = new Set([
|
||||
|
||||
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",
|
||||
@@ -209,9 +249,13 @@ export function evaluateAgentActionGate(params: {
|
||||
operation = params.toolName;
|
||||
resourceType = "file";
|
||||
resourceId = typeof args.path === "string" ? args.path : undefined;
|
||||
} else if (EXEMPT_TOOLS.has(params.toolName)) {
|
||||
} 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;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { AgentLogger, type AgentLoggerOptions, summarizeToolArgs } from "./agent-logger.js";
|
||||
export { reloadExemptTools, addToExemptTools, getExemptToolNames } from "./agent-action-gate.js";
|
||||
export {
|
||||
createTaskCreateTool,
|
||||
createTaskDocumentReadTool,
|
||||
|
||||
Reference in New Issue
Block a user