feat(FN-3724): add coordination tools and permanent-agent action gating

This merge adds a direct-mode review tab to the task detail modal (FN-3278), improves mobile mailbox keyboard handling and fixes request hangs during wake dispatch (FN-3750/FN-3751), and reclassifies coordination tools with lock exemptions in the agent gating system (FN-3724), alongside GitHub route

Fusion-Task-Id: FN-3724
This commit is contained in:
Fusion
2026-05-08 11:39:07 -07:00
committed by gsxdsm
parent aa548f81bf
commit 9f5720724f
5 changed files with 98 additions and 32 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Exempt internal Fusion coordination tools (heartbeat-done, task/document/memory writes used for coordination, delegation, identity, reflection) from the permanent-agent action gate so heartbeats cannot deadlock under restrictive permission policies. Mirrors the existing action-gate exemption set onto the sibling permanent-agent gating path.

View File

@@ -3,10 +3,31 @@ import {
addToExemptTools,
computeApprovalDedupeKey,
evaluateAgentActionGate,
getExemptToolNames,
reloadExemptTools,
} from "../agent-action-gate.js";
import type { AgentPermissionPolicy } from "@fusion/core";
const FN_3548_COORDINATION_TOOLS = [
"fn_heartbeat_done",
"fn_task_create",
"fn_task_log",
"fn_task_document_write",
"fn_task_document_read",
"fn_delegate_task",
"fn_list_agents",
"fn_agent_show",
"fn_agent_org_chart",
"fn_send_message",
"fn_read_messages",
"fn_memory_search",
"fn_memory_get",
"fn_memory_append",
"fn_read_evaluations",
"fn_update_identity",
"fn_reflect_on_performance",
] as const;
const unrestrictedPolicy: AgentPermissionPolicy = {
presetId: "unrestricted",
rules: {
@@ -103,20 +124,16 @@ describe("agent-action-gate", () => {
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) => {
it.each(FN_3548_COORDINATION_TOOLS)("always allows newly exempt internal tool %s under locked-down policies", (toolName) => {
const decision = evaluateAgentActionGate({ agentId: "a1", toolName, args: {}, permissionPolicy: lockedDownPolicy });
expect(decision.disposition).toBe("allow");
expect(decision.category).toBe("exempt");
});
it("keeps FN-3548 coordination tool list in exempt registry", () => {
expect(getExemptToolNames()).toEqual(expect.arrayContaining([...FN_3548_COORDINATION_TOOLS]));
});
it("keeps bash and write blocked under locked-down policy", () => {
const bashDecision = evaluateAgentActionGate({
agentId: "a1",

View File

@@ -4,6 +4,26 @@ import {
resolvePermanentAgentToolDecision,
} from "../permanent-agent-gating.js";
const FN_3548_COORDINATION_TOOLS = [
"fn_heartbeat_done",
"fn_task_create",
"fn_task_log",
"fn_task_document_write",
"fn_task_document_read",
"fn_delegate_task",
"fn_list_agents",
"fn_agent_show",
"fn_agent_org_chart",
"fn_send_message",
"fn_read_messages",
"fn_memory_search",
"fn_memory_get",
"fn_memory_append",
"fn_read_evaluations",
"fn_update_identity",
"fn_reflect_on_performance",
] as const;
describe("permanent-agent-gating", () => {
it("classifies builtin coding tools", () => {
expect(classifyPermanentAgentToolCall("write").category).toBe("file_write_delete");
@@ -15,12 +35,12 @@ describe("permanent-agent-gating", () => {
});
it("classifies shared fn tools by behavior", () => {
expect(classifyPermanentAgentToolCall("fn_task_create").category).toBe("task_agent_mutation");
expect(classifyPermanentAgentToolCall("fn_delegate_task").category).toBe("task_agent_mutation");
expect(classifyPermanentAgentToolCall("fn_task_create").category).toBe("none");
expect(classifyPermanentAgentToolCall("fn_delegate_task").category).toBe("none");
expect(classifyPermanentAgentToolCall("fn_update_agent_config").category).toBe("task_agent_mutation");
expect(classifyPermanentAgentToolCall("fn_update_identity").category).toBe("task_agent_mutation");
expect(classifyPermanentAgentToolCall("fn_task_document_write").category).toBe("file_write_delete");
expect(classifyPermanentAgentToolCall("fn_memory_append").category).toBe("file_write_delete");
expect(classifyPermanentAgentToolCall("fn_update_identity").category).toBe("none");
expect(classifyPermanentAgentToolCall("fn_task_document_write").category).toBe("none");
expect(classifyPermanentAgentToolCall("fn_memory_append").category).toBe("none");
expect(classifyPermanentAgentToolCall("fn_research_run").category).toBe("network_api");
expect(classifyPermanentAgentToolCall("fn_task_show").category).toBe("none");
expect(classifyPermanentAgentToolCall("fn_research_get").category).toBe("none");
@@ -35,7 +55,7 @@ describe("permanent-agent-gating", () => {
classifyPermanentAgentToolCall("write").category,
classifyPermanentAgentToolCall("bash", { command: "echo hi" }).category,
classifyPermanentAgentToolCall("fn_research_run").category,
classifyPermanentAgentToolCall("fn_task_create").category,
classifyPermanentAgentToolCall("fn_update_agent_config").category,
classifyPermanentAgentToolCall("read").category,
];
@@ -74,6 +94,32 @@ describe("permanent-agent-gating", () => {
expect(decision.disposition).toBe("allow");
});
it.each(FN_3548_COORDINATION_TOOLS)("classifies FN-3548 coordination tool %s as recognized none", (toolName) => {
expect(classifyPermanentAgentToolCall(toolName)).toEqual({ category: "none", recognized: true });
});
it.each(FN_3548_COORDINATION_TOOLS)("allows FN-3548 coordination tool %s under locked-down policy", (toolName) => {
const decision = resolvePermanentAgentToolDecision({
toolName,
gating: {
permissionPolicy: {
presetId: "locked-down",
rules: {
git_write: "block",
file_write_delete: "block",
command_execution: "block",
network_api: "block",
task_agent_mutation: "block",
},
},
},
});
expect(decision.disposition).toBe("allow");
expect(decision.category).toBe("none");
expect(decision.recognized).toBe(true);
});
it("resolves disposition from policy for sensitive categories", () => {
const blockDecision = resolvePermanentAgentToolDecision({
toolName: "write",
@@ -89,7 +135,7 @@ describe("permanent-agent-gating", () => {
expect(blockDecision.disposition).toBe("block");
const approvalDecision = resolvePermanentAgentToolDecision({
toolName: "fn_task_create",
toolName: "fn_update_agent_config",
gating: {
permissionPolicy: {
presetId: "approval-required",

View File

@@ -445,8 +445,8 @@ describe("wrapToolsWithPermanentAgentGating", () => {
expect(tool.execute).not.toHaveBeenCalled();
});
it("requires approval for mutating fn_* tools and never executes mutation", async () => {
const tool = { name: "fn_task_create", label: "Task Create", description: "", parameters: {}, execute: vi.fn() };
it("allows exempt internal coordination fn_* tools without approval", async () => {
const tool = { name: "fn_task_create", label: "Task Create", description: "", parameters: {}, execute: vi.fn().mockResolvedValue({ ok: true }) };
const createApprovalRequest = vi.fn().mockResolvedValue({ id: "apr-fn-1" });
const { wrapToolsWithPermanentAgentGating } = await import("../pi.js");
const wrapped = wrapToolsWithPermanentAgentGating([tool as any], {
@@ -467,15 +467,9 @@ describe("wrapToolsWithPermanentAgentGating", () => {
});
const result = await (wrapped[0] as any).execute("t1", { description: "create" });
expect((result as any).isError).toBe(true);
expect((result as any).details).toEqual(expect.objectContaining({
disposition: "require-approval",
category: "task_agent_mutation",
toolName: "fn_task_create",
approvalRequestId: "apr-fn-1",
}));
expect(createApprovalRequest).toHaveBeenCalledTimes(1);
expect(tool.execute).not.toHaveBeenCalled();
expect(result).toEqual({ ok: true });
expect(createApprovalRequest).not.toHaveBeenCalled();
expect(tool.execute).toHaveBeenCalledTimes(1);
});
it("keeps read-only tools allowed without approval-request creation", async () => {

View File

@@ -19,8 +19,9 @@ export interface PermanentAgentToolDecision extends PermanentAgentToolClassifica
const READONLY_BUILTIN_TOOLS = new Set(["read", "grep", "find", "ls"]);
const FILE_WRITE_TOOLS = new Set(["write", "edit"]);
// FN-3724 / FN-3548: heartbeat-completion and internal coordination tools must remain
// category "none" so restrictive permanent-agent policies cannot deadlock heartbeats.
const TASK_AGENT_MUTATION_TOOLS = new Set([
"fn_task_create",
"fn_task_add_dep",
"fn_task_pause",
"fn_task_unpause",
@@ -42,16 +43,12 @@ const TASK_AGENT_MUTATION_TOOLS = new Set([
"fn_feature_link_task",
"fn_agent_stop",
"fn_agent_start",
"fn_delegate_task",
"fn_update_agent_config",
"fn_update_identity",
"fn_spawn_agent",
"fn_task_add_dep",
]);
const FILE_WRITE_DELETE_TOOLS = new Set([
"fn_task_document_write",
"fn_memory_append",
"fn_task_attach",
]);
@@ -64,7 +61,10 @@ const NETWORK_API_TOOLS = new Set([
const READONLY_FN_TOOLS = new Set([
"fn_task_list",
"fn_task_show",
"fn_task_create",
"fn_task_document_write",
"fn_task_document_read",
"fn_delegate_task",
"fn_research_list",
"fn_research_get",
"fn_insight_list",
@@ -83,8 +83,12 @@ const READONLY_FN_TOOLS = new Set([
"fn_task_log",
"fn_task_done",
"fn_heartbeat_done",
"fn_memory_append",
"fn_send_message",
"fn_read_messages",
"fn_update_identity",
"fn_reflect_on_performance",
"fn_read_evaluations",
]);
const MUTATING_GIT_SUBCOMMANDS = new Set([