fix(FN-3724): exempt internal coordination tools from action gate
- Expand action-gate exempt tool list to include internal heartbeat, task/agent coordination, messaging, evaluation, identity, and memory bookkeeping calls - Prioritize exempt-tool classification in gate evaluation to ensure permanent-agent sessions cannot deadlock on internal runtime operations - Add targeted engine tests covering exempt internal tool behavior and permanent-agent gating interactions - Document the internal tool exemption policy in docs/agents.md - Add a patch changeset for @runfusion/fusion describing the exemption behavior Fusion-Task-Id: FN-3724
This commit is contained in:
5
.changeset/fn-3724-exempt-internal-tools.md
Normal file
5
.changeset/fn-3724-exempt-internal-tools.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Exempt internal Fusion runtime coordination tools from permanent-agent action-gate policy enforcement so heartbeat completion and engine coordination calls cannot deadlock behind approval/block rules.
|
||||||
@@ -86,6 +86,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.
|
||||||
|
|
||||||
Interim enforcement behavior (persistence-integrated, pre-resume lifecycle):
|
Interim enforcement behavior (persistence-integrated, pre-resume lifecycle):
|
||||||
|
|
||||||
|
|||||||
@@ -71,11 +71,11 @@ describe("agent-action-gate", () => {
|
|||||||
|
|
||||||
it("classifies explicit network and management tools", () => {
|
it("classifies explicit network and management tools", () => {
|
||||||
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_research_run", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("network_api");
|
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_research_run", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("network_api");
|
||||||
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_create", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation");
|
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_create", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("exempt");
|
||||||
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_add_dep", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation");
|
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_add_dep", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation");
|
||||||
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_delegate_task", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation");
|
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_delegate_task", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("exempt");
|
||||||
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_update_agent_config", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation");
|
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_update_agent_config", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation");
|
||||||
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_update_identity", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation");
|
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_update_identity", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("exempt");
|
||||||
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_spawn_agent", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation");
|
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_spawn_agent", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -84,6 +84,60 @@ describe("agent-action-gate", () => {
|
|||||||
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_update", args: {}, permissionPolicy: approvalPolicy }).disposition).toBe("allow");
|
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_update", args: {}, permissionPolicy: approvalPolicy }).disposition).toBe("allow");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"fn_heartbeat_done",
|
||||||
|
"fn_task_create",
|
||||||
|
"fn_delegate_task",
|
||||||
|
"fn_send_message",
|
||||||
|
"fn_memory_append",
|
||||||
|
"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",
|
||||||
|
args: { command: "git commit -m x" },
|
||||||
|
permissionPolicy: lockedDownPolicy,
|
||||||
|
});
|
||||||
|
const writeDecision = evaluateAgentActionGate({
|
||||||
|
agentId: "a1",
|
||||||
|
toolName: "write",
|
||||||
|
args: { path: "a.ts", content: "x" },
|
||||||
|
permissionPolicy: lockedDownPolicy,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(bashDecision.disposition).toBe("block");
|
||||||
|
expect(writeDecision.disposition).toBe("block");
|
||||||
|
});
|
||||||
|
|
||||||
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");
|
||||||
|
|||||||
@@ -619,6 +619,33 @@ describe("wrapToolsWithActionGate", () => {
|
|||||||
expect(createApprovalRequest).toHaveBeenCalledTimes(1);
|
expect(createApprovalRequest).toHaveBeenCalledTimes(1);
|
||||||
expect(tool.execute).not.toHaveBeenCalled();
|
expect(tool.execute).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes through exempt internal tools under locked-down policy", async () => {
|
||||||
|
const execute = vi.fn().mockResolvedValue({ ok: true });
|
||||||
|
const tool = { name: "fn_heartbeat_done", label: "Heartbeat Done", description: "", parameters: {}, execute };
|
||||||
|
const { wrapToolsWithActionGate } = await import("../pi.js");
|
||||||
|
const wrapped = wrapToolsWithActionGate([tool as any], {
|
||||||
|
agentId: "agent-1",
|
||||||
|
agentName: "Agent",
|
||||||
|
isEphemeral: false,
|
||||||
|
taskId: "FN-1",
|
||||||
|
permissionPolicy: {
|
||||||
|
presetId: "locked-down",
|
||||||
|
rules: {
|
||||||
|
"git_write": "block",
|
||||||
|
"file_write_delete": "block",
|
||||||
|
"command_execution": "block",
|
||||||
|
"network_api": "block",
|
||||||
|
"task_agent_mutation": "block",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createApprovalRequest: vi.fn(),
|
||||||
|
findPendingApprovalByDedupeKey: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await (wrapped[0] as any).execute("t1", {});
|
||||||
|
expect(execute).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("createFnAgent", () => {
|
describe("createFnAgent", () => {
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export interface AgentActionGateContext {
|
|||||||
findPendingApprovalByDedupeKey: (dedupeKey: string) => Promise<unknown | null>;
|
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 EXEMPT_TOOLS = new Set([
|
const EXEMPT_TOOLS = new Set([
|
||||||
"read",
|
"read",
|
||||||
"find",
|
"find",
|
||||||
@@ -42,6 +44,17 @@ const EXEMPT_TOOLS = new Set([
|
|||||||
"fn_memory_search",
|
"fn_memory_search",
|
||||||
"fn_memory_get",
|
"fn_memory_get",
|
||||||
"fn_read_messages",
|
"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",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const TASK_AGENT_MANAGEMENT_TOOLS = new Set([
|
const TASK_AGENT_MANAGEMENT_TOOLS = new Set([
|
||||||
@@ -196,6 +209,9 @@ 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)) {
|
||||||
|
category = "exempt";
|
||||||
|
operation = params.toolName;
|
||||||
} 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;
|
||||||
@@ -204,9 +220,6 @@ export function evaluateAgentActionGate(params: {
|
|||||||
category = "network_api";
|
category = "network_api";
|
||||||
operation = params.toolName;
|
operation = params.toolName;
|
||||||
resourceType = "research";
|
resourceType = "research";
|
||||||
} else if (EXEMPT_TOOLS.has(params.toolName)) {
|
|
||||||
category = "exempt";
|
|
||||||
operation = params.toolName;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const disposition: AgentPermissionPolicyDisposition | "allow" = category === "exempt"
|
const disposition: AgentPermissionPolicyDisposition | "allow" = category === "exempt"
|
||||||
|
|||||||
Reference in New Issue
Block a user