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:
5
.changeset/fn-3724-action-gate-exemptions.md
Normal file
5
.changeset/fn-3724-action-gate-exemptions.md
Normal 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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user