From 45e27f8875181afa9c95b472df7abd7d85e68e6b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 27 Jun 2026 11:54:06 -0700 Subject: [PATCH] FN-7126: govern task creation with action gate Task creation and delegation now honor task-agent mutation policies in action-gate evaluation. - Classify task creation, delegation, and import tools as task_agent_mutation for action-gate policy decisions. - Preserve permanent-agent recognition for coordination paths while documenting the split semantics. - Update permission policy examples, regression tests, and release note metadata. Files changed: .changeset/fn-7126-task-create-action-gate.md | 7 +++++++ docs/agents.md | 2 +- packages/core/src/types.ts | 2 +- .../engine/src/__tests__/agent-action-gate.test.ts | 24 ++++++++++++++++++---- .../src/__tests__/gating-classifications.test.ts | 16 +++++++++++++-- packages/engine/src/gating-classifications.ts | 10 ++++++--- 6 files changed, 50 insertions(+), 11 deletions(-) Fusion-Task-Id: FN-7126 Fusion-Task-Lineage: 0e5d2aea-6670-4f9c-9ee8-5a135774c784 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7126-task-create-action-gate.md | 7 ++++++ docs/agents.md | 2 +- packages/core/src/types.ts | 2 +- .../src/__tests__/agent-action-gate.test.ts | 24 +++++++++++++++---- .../__tests__/gating-classifications.test.ts | 16 +++++++++++-- packages/engine/src/gating-classifications.ts | 10 +++++--- 6 files changed, 50 insertions(+), 11 deletions(-) create mode 100644 .changeset/fn-7126-task-create-action-gate.md diff --git a/.changeset/fn-7126-task-create-action-gate.md b/.changeset/fn-7126-task-create-action-gate.md new file mode 100644 index 0000000000..f2e9243d8f --- /dev/null +++ b/.changeset/fn-7126-task-create-action-gate.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Govern task creation and delegation with the task_agent_mutation permission policy. +category: fix +dev: fn_task_create and fn_delegate_task were action-gate exempt despite being task-board mutations; now classified task_agent_mutation in the action gate (permanent-agent gate none classification preserved). diff --git a/docs/agents.md b/docs/agents.md index ab8633dca2..e4b00f1614 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -163,7 +163,7 @@ Unknown/unclassified tool fallback: - 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. -- Internal Fusion runtime coordination tools (heartbeat completion, logs, documents, task creation/delegation, messaging, evaluations, identity reflection, memory bookkeeping) are exempt by design and always allowed so permanent-agent heartbeats can complete. Task field/status mutation via `fn_task_update` is not exempt; it is governed as `task_agent_mutation`. +- Internal Fusion runtime coordination tools (heartbeat completion, logs, documents, messaging, evaluations, identity reflection, memory bookkeeping, and read-only discovery) are exempt by design and always allowed so permanent-agent heartbeats can complete. Task creation/delegation/import are governed as `task_agent_mutation` in action-gate evaluation because they mutate the task board, while the permanent-agent classifier still treats them as positively recognized `none` coordination primitives. Task field/status mutation via `fn_task_update` is also governed as `task_agent_mutation`. - Operators can reload the in-memory exempt-tool registry at runtime via `POST /api/action-gate/reload` (optional body `{ "tools": string[] }`) to apply exemption-list updates without restarting the engine process. - Canonical tool classification/exemption sets live in `packages/engine/src/gating-classifications.ts` and are shared by both action-gate paths. diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index ec349df8f1..36cf7db6cd 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -6204,6 +6204,7 @@ export const AGENT_PERMISSION_POLICY_CATEGORY_TOOL_EXAMPLES: Record< file_write_delete: ["write", "edit", "fn_task_attach"], command_execution: ["bash (non-git)", "fn_run_verification", "fn_acquire_repo_worktree", "read", "find", "grep", "ls"], network_api: ["fn_research_run (web/research)", "fn_research_cancel", "fn_web_fetch", "worktrunk_install"], + /* FNXC:ToolGovernance 2026-06-27-16:51: Dashboard policy examples must mirror action-gate mutation exports. Identity reflection is exempt heartbeat coordination, so it is intentionally not advertised as task_agent_mutation. */ task_agent_mutation: [ "fn_task_create", "fn_delegate_task", @@ -6211,7 +6212,6 @@ export const AGENT_PERMISSION_POLICY_CATEGORY_TOOL_EXAMPLES: Record< "fn_task_import_github_issue", "fn_spawn_agent", "fn_update_agent_config", - "fn_update_identity", "fn_task_update", "fn_workflow_create", "fn_workflow_update", diff --git a/packages/engine/src/__tests__/agent-action-gate.test.ts b/packages/engine/src/__tests__/agent-action-gate.test.ts index 8cbb315661..a1a1669499 100644 --- a/packages/engine/src/__tests__/agent-action-gate.test.ts +++ b/packages/engine/src/__tests__/agent-action-gate.test.ts @@ -11,14 +11,12 @@ 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_artifact_register", "fn_artifact_list", "fn_artifact_view", - "fn_delegate_task", "fn_list_agents", "fn_agent_show", "fn_agent_org_chart", @@ -115,9 +113,9 @@ describe("agent-action-gate", () => { 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_task_create", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("exempt"); + expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_create", 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("exempt"); + expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_delegate_task", 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_task_import_github", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation"); expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_import_github_issue", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation"); @@ -219,6 +217,24 @@ describe("agent-action-gate", () => { expect(execute).toHaveBeenCalledTimes(1); }); + it.each([ + "fn_task_create", + "fn_delegate_task", + "fn_task_import_github", + "fn_task_import_github_issue", + ] as const)("governs task creation/import tool %s as task_agent_mutation", (toolName) => { + for (const args of [{}, undefined]) { + expect(evaluateAgentActionGate({ agentId: "a1", toolName, args, permissionPolicy: approvalPolicy })).toMatchObject({ + category: "task_agent_mutation", + disposition: "require-approval", + }); + expect(evaluateAgentActionGate({ agentId: "a1", toolName, args, permissionPolicy: lockedDownPolicy })).toMatchObject({ + category: "task_agent_mutation", + disposition: "block", + }); + } + }); + 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"); diff --git a/packages/engine/src/__tests__/gating-classifications.test.ts b/packages/engine/src/__tests__/gating-classifications.test.ts index 5241b6d183..82a8ee4290 100644 --- a/packages/engine/src/__tests__/gating-classifications.test.ts +++ b/packages/engine/src/__tests__/gating-classifications.test.ts @@ -94,6 +94,8 @@ const gitCases = [ ] as const; const ACTION_MUTATION_PERMANENT_READONLY_TOOLS = new Set([ + "fn_task_create", + "fn_delegate_task", "fn_task_import_github", "fn_task_import_github_issue", ]); @@ -108,7 +110,6 @@ describe("gating-classifications parity", () => { "fn_artifact_list", "fn_artifact_register", "fn_artifact_view", - "fn_delegate_task", "fn_goal_list", "fn_goal_show", "fn_heartbeat_done", @@ -121,7 +122,6 @@ describe("gating-classifications parity", () => { "fn_read_messages", "fn_reflect_on_performance", "fn_send_message", - "fn_task_create", "fn_task_document_read", "fn_task_document_write", "fn_task_done", @@ -152,6 +152,18 @@ describe("gating-classifications parity", () => { } }); + it.each([...ACTION_MUTATION_PERMANENT_READONLY_TOOLS])("keeps action-mutating tool %s readonly in permanent gating", (toolName) => { + expect(evaluateAgentActionGate({ agentId: "a1", toolName, args: {}, permissionPolicy: blockedPolicy })).toMatchObject({ + category: "task_agent_mutation", + disposition: "block", + }); + expect(resolvePermanentAgentToolDecision({ toolName, args: {}, gating: { permissionPolicy: blockedPolicy } })).toMatchObject({ + category: "none", + disposition: "allow", + recognized: true, + }); + }); + it("includes goal retrieval tools on readonly path only", () => { expect(READONLY_FN_TOOLS.has("fn_goal_list")).toBe(true); expect(READONLY_FN_TOOLS.has("fn_goal_show")).toBe(true); diff --git a/packages/engine/src/gating-classifications.ts b/packages/engine/src/gating-classifications.ts index c678128706..3e779d6a1e 100644 --- a/packages/engine/src/gating-classifications.ts +++ b/packages/engine/src/gating-classifications.ts @@ -34,12 +34,18 @@ export const COMMAND_EXECUTION_FN_TOOLS: ReadonlySet = new Set([ "fn_acquire_repo_worktree", ]); +/** + * FNXC:ToolGovernance 2026-06-27-11:24: + * FN-7126 classifies task creation/delegation/import as task_agent_mutation in the action gate because they mutate the board and must honor operator approval/block policy. Keep the same tools in READONLY_FN_TOOLS for the permanent-agent gate, where they remain recognized `none` coordination primitives. + * + * FNXC:ToolGovernance 2026-06-27-16:51: + * Identity reflection stays out of this action-gate mutation-only list because it is heartbeat-critical coordination, not a task-board mutation. Keep it in COORDINATION_EXEMPT_TOOLS and READONLY_FN_TOOLS so exported mutation sets do not contradict action-gate exemption semantics. + */ const ACTION_GATE_TASK_AGENT_ONLY_TOOLS = [ "fn_task_create", "fn_delegate_task", "fn_task_import_github", "fn_task_import_github_issue", - "fn_update_identity", ] as const; const ACTION_GATE_SHARED_TASK_AGENT_TOOLS = SHARED_TASK_AGENT_TOOLS.filter( (tool) => !(PROVISIONING_TOOLS as readonly string[]).includes(tool), @@ -176,8 +182,6 @@ export const COORDINATION_EXEMPT_TOOLS = [ "fn_memory_get", "fn_read_messages", "fn_heartbeat_done", - "fn_task_create", - "fn_delegate_task", "fn_goal_list", "fn_goal_show", "fn_list_agents",