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,
|
addToExemptTools,
|
||||||
computeApprovalDedupeKey,
|
computeApprovalDedupeKey,
|
||||||
evaluateAgentActionGate,
|
evaluateAgentActionGate,
|
||||||
|
getExemptToolNames,
|
||||||
reloadExemptTools,
|
reloadExemptTools,
|
||||||
} from "../agent-action-gate.js";
|
} from "../agent-action-gate.js";
|
||||||
import type { AgentPermissionPolicy } from "@fusion/core";
|
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 = {
|
const unrestrictedPolicy: AgentPermissionPolicy = {
|
||||||
presetId: "unrestricted",
|
presetId: "unrestricted",
|
||||||
rules: {
|
rules: {
|
||||||
@@ -103,20 +124,16 @@ 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([
|
it.each(FN_3548_COORDINATION_TOOLS)("always allows newly exempt internal tool %s under locked-down policies", (toolName) => {
|
||||||
"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 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 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", () => {
|
it("keeps bash and write blocked under locked-down policy", () => {
|
||||||
const bashDecision = evaluateAgentActionGate({
|
const bashDecision = evaluateAgentActionGate({
|
||||||
agentId: "a1",
|
agentId: "a1",
|
||||||
|
|||||||
@@ -4,6 +4,26 @@ import {
|
|||||||
resolvePermanentAgentToolDecision,
|
resolvePermanentAgentToolDecision,
|
||||||
} from "../permanent-agent-gating.js";
|
} 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", () => {
|
describe("permanent-agent-gating", () => {
|
||||||
it("classifies builtin coding tools", () => {
|
it("classifies builtin coding tools", () => {
|
||||||
expect(classifyPermanentAgentToolCall("write").category).toBe("file_write_delete");
|
expect(classifyPermanentAgentToolCall("write").category).toBe("file_write_delete");
|
||||||
@@ -15,12 +35,12 @@ describe("permanent-agent-gating", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("classifies shared fn tools by behavior", () => {
|
it("classifies shared fn tools by behavior", () => {
|
||||||
expect(classifyPermanentAgentToolCall("fn_task_create").category).toBe("task_agent_mutation");
|
expect(classifyPermanentAgentToolCall("fn_task_create").category).toBe("none");
|
||||||
expect(classifyPermanentAgentToolCall("fn_delegate_task").category).toBe("task_agent_mutation");
|
expect(classifyPermanentAgentToolCall("fn_delegate_task").category).toBe("none");
|
||||||
expect(classifyPermanentAgentToolCall("fn_update_agent_config").category).toBe("task_agent_mutation");
|
expect(classifyPermanentAgentToolCall("fn_update_agent_config").category).toBe("task_agent_mutation");
|
||||||
expect(classifyPermanentAgentToolCall("fn_update_identity").category).toBe("task_agent_mutation");
|
expect(classifyPermanentAgentToolCall("fn_update_identity").category).toBe("none");
|
||||||
expect(classifyPermanentAgentToolCall("fn_task_document_write").category).toBe("file_write_delete");
|
expect(classifyPermanentAgentToolCall("fn_task_document_write").category).toBe("none");
|
||||||
expect(classifyPermanentAgentToolCall("fn_memory_append").category).toBe("file_write_delete");
|
expect(classifyPermanentAgentToolCall("fn_memory_append").category).toBe("none");
|
||||||
expect(classifyPermanentAgentToolCall("fn_research_run").category).toBe("network_api");
|
expect(classifyPermanentAgentToolCall("fn_research_run").category).toBe("network_api");
|
||||||
expect(classifyPermanentAgentToolCall("fn_task_show").category).toBe("none");
|
expect(classifyPermanentAgentToolCall("fn_task_show").category).toBe("none");
|
||||||
expect(classifyPermanentAgentToolCall("fn_research_get").category).toBe("none");
|
expect(classifyPermanentAgentToolCall("fn_research_get").category).toBe("none");
|
||||||
@@ -35,7 +55,7 @@ describe("permanent-agent-gating", () => {
|
|||||||
classifyPermanentAgentToolCall("write").category,
|
classifyPermanentAgentToolCall("write").category,
|
||||||
classifyPermanentAgentToolCall("bash", { command: "echo hi" }).category,
|
classifyPermanentAgentToolCall("bash", { command: "echo hi" }).category,
|
||||||
classifyPermanentAgentToolCall("fn_research_run").category,
|
classifyPermanentAgentToolCall("fn_research_run").category,
|
||||||
classifyPermanentAgentToolCall("fn_task_create").category,
|
classifyPermanentAgentToolCall("fn_update_agent_config").category,
|
||||||
classifyPermanentAgentToolCall("read").category,
|
classifyPermanentAgentToolCall("read").category,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -74,6 +94,32 @@ describe("permanent-agent-gating", () => {
|
|||||||
expect(decision.disposition).toBe("allow");
|
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", () => {
|
it("resolves disposition from policy for sensitive categories", () => {
|
||||||
const blockDecision = resolvePermanentAgentToolDecision({
|
const blockDecision = resolvePermanentAgentToolDecision({
|
||||||
toolName: "write",
|
toolName: "write",
|
||||||
@@ -89,7 +135,7 @@ describe("permanent-agent-gating", () => {
|
|||||||
expect(blockDecision.disposition).toBe("block");
|
expect(blockDecision.disposition).toBe("block");
|
||||||
|
|
||||||
const approvalDecision = resolvePermanentAgentToolDecision({
|
const approvalDecision = resolvePermanentAgentToolDecision({
|
||||||
toolName: "fn_task_create",
|
toolName: "fn_update_agent_config",
|
||||||
gating: {
|
gating: {
|
||||||
permissionPolicy: {
|
permissionPolicy: {
|
||||||
presetId: "approval-required",
|
presetId: "approval-required",
|
||||||
|
|||||||
@@ -445,8 +445,8 @@ describe("wrapToolsWithPermanentAgentGating", () => {
|
|||||||
expect(tool.execute).not.toHaveBeenCalled();
|
expect(tool.execute).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("requires approval for mutating fn_* tools and never executes mutation", async () => {
|
it("allows exempt internal coordination fn_* tools without approval", async () => {
|
||||||
const tool = { name: "fn_task_create", label: "Task Create", description: "", parameters: {}, execute: vi.fn() };
|
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 createApprovalRequest = vi.fn().mockResolvedValue({ id: "apr-fn-1" });
|
||||||
const { wrapToolsWithPermanentAgentGating } = await import("../pi.js");
|
const { wrapToolsWithPermanentAgentGating } = await import("../pi.js");
|
||||||
const wrapped = wrapToolsWithPermanentAgentGating([tool as any], {
|
const wrapped = wrapToolsWithPermanentAgentGating([tool as any], {
|
||||||
@@ -467,15 +467,9 @@ describe("wrapToolsWithPermanentAgentGating", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await (wrapped[0] as any).execute("t1", { description: "create" });
|
const result = await (wrapped[0] as any).execute("t1", { description: "create" });
|
||||||
expect((result as any).isError).toBe(true);
|
expect(result).toEqual({ ok: true });
|
||||||
expect((result as any).details).toEqual(expect.objectContaining({
|
expect(createApprovalRequest).not.toHaveBeenCalled();
|
||||||
disposition: "require-approval",
|
expect(tool.execute).toHaveBeenCalledTimes(1);
|
||||||
category: "task_agent_mutation",
|
|
||||||
toolName: "fn_task_create",
|
|
||||||
approvalRequestId: "apr-fn-1",
|
|
||||||
}));
|
|
||||||
expect(createApprovalRequest).toHaveBeenCalledTimes(1);
|
|
||||||
expect(tool.execute).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps read-only tools allowed without approval-request creation", async () => {
|
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 READONLY_BUILTIN_TOOLS = new Set(["read", "grep", "find", "ls"]);
|
||||||
const FILE_WRITE_TOOLS = new Set(["write", "edit"]);
|
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([
|
const TASK_AGENT_MUTATION_TOOLS = new Set([
|
||||||
"fn_task_create",
|
|
||||||
"fn_task_add_dep",
|
"fn_task_add_dep",
|
||||||
"fn_task_pause",
|
"fn_task_pause",
|
||||||
"fn_task_unpause",
|
"fn_task_unpause",
|
||||||
@@ -42,16 +43,12 @@ const TASK_AGENT_MUTATION_TOOLS = new Set([
|
|||||||
"fn_feature_link_task",
|
"fn_feature_link_task",
|
||||||
"fn_agent_stop",
|
"fn_agent_stop",
|
||||||
"fn_agent_start",
|
"fn_agent_start",
|
||||||
"fn_delegate_task",
|
|
||||||
"fn_update_agent_config",
|
"fn_update_agent_config",
|
||||||
"fn_update_identity",
|
|
||||||
"fn_spawn_agent",
|
"fn_spawn_agent",
|
||||||
"fn_task_add_dep",
|
"fn_task_add_dep",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const FILE_WRITE_DELETE_TOOLS = new Set([
|
const FILE_WRITE_DELETE_TOOLS = new Set([
|
||||||
"fn_task_document_write",
|
|
||||||
"fn_memory_append",
|
|
||||||
"fn_task_attach",
|
"fn_task_attach",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -64,7 +61,10 @@ const NETWORK_API_TOOLS = new Set([
|
|||||||
const READONLY_FN_TOOLS = new Set([
|
const READONLY_FN_TOOLS = new Set([
|
||||||
"fn_task_list",
|
"fn_task_list",
|
||||||
"fn_task_show",
|
"fn_task_show",
|
||||||
|
"fn_task_create",
|
||||||
|
"fn_task_document_write",
|
||||||
"fn_task_document_read",
|
"fn_task_document_read",
|
||||||
|
"fn_delegate_task",
|
||||||
"fn_research_list",
|
"fn_research_list",
|
||||||
"fn_research_get",
|
"fn_research_get",
|
||||||
"fn_insight_list",
|
"fn_insight_list",
|
||||||
@@ -83,8 +83,12 @@ const READONLY_FN_TOOLS = new Set([
|
|||||||
"fn_task_log",
|
"fn_task_log",
|
||||||
"fn_task_done",
|
"fn_task_done",
|
||||||
"fn_heartbeat_done",
|
"fn_heartbeat_done",
|
||||||
|
"fn_memory_append",
|
||||||
"fn_send_message",
|
"fn_send_message",
|
||||||
"fn_read_messages",
|
"fn_read_messages",
|
||||||
|
"fn_update_identity",
|
||||||
|
"fn_reflect_on_performance",
|
||||||
|
"fn_read_evaluations",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const MUTATING_GIT_SUBCOMMANDS = new Set([
|
const MUTATING_GIT_SUBCOMMANDS = new Set([
|
||||||
|
|||||||
Reference in New Issue
Block a user