From c3c4216f7618fdb47b22e0897c212b7ebf5e24d7 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 27 Jun 2026 12:44:50 -0700 Subject: [PATCH] FN-7132: classify task creation as a gated mutation Classify fn_task_create as a task-agent mutation so permanent-agent policies can require approval or block task creation. - Move fn_task_create out of the permanent-agent read-only tool set and into the shared task mutation classification. - Add parity tests covering permanent-agent and action-gate decisions for fn_task_create. - Preserve delegate and GitHub import tools as permanent-agent read-only coordination tools while documenting the distinction. - Add a patch changeset for the published Fusion package. Files changed: .../fn-7132-task-create-mutation-classification.md | 7 +++ docs/agents.md | 6 +-- .../src/__tests__/gating-classifications.test.ts | 58 +++++++++++++++++++++- .../src/__tests__/permanent-agent-gating.test.ts | 3 +- packages/engine/src/gating-classifications.ts | 10 ++-- 5 files changed, 75 insertions(+), 9 deletions(-) Fusion-Task-Id: FN-7132 Fusion-Task-Lineage: 6e32ed9d-f0fa-46f2-a749-c5dbe863fb1f Co-authored-by: Fusion (runfusion.ai) --- ...132-task-create-mutation-classification.md | 7 +++ docs/agents.md | 6 +- .../__tests__/gating-classifications.test.ts | 58 ++++++++++++++++++- .../__tests__/permanent-agent-gating.test.ts | 3 +- packages/engine/src/gating-classifications.ts | 10 +++- 5 files changed, 75 insertions(+), 9 deletions(-) create mode 100644 .changeset/fn-7132-task-create-mutation-classification.md diff --git a/.changeset/fn-7132-task-create-mutation-classification.md b/.changeset/fn-7132-task-create-mutation-classification.md new file mode 100644 index 0000000000..779c21b9f0 --- /dev/null +++ b/.changeset/fn-7132-task-create-mutation-classification.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Permanent agents now obey approval/block policy when creating tasks. +category: fix +dev: Removed fn_task_create from READONLY_FN_TOOLS and classified it as task_agent_mutation in the permanent-agent gate (packages/engine/src/gating-classifications.ts); action-gate classification unchanged. fn_delegate_task and GitHub import tools intentionally left permanent-readonly. diff --git a/docs/agents.md b/docs/agents.md index e4b00f1614..b736fc62a3 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -150,9 +150,9 @@ The engine classifies tool calls by behavior (not namespace alone): - `command_execution`: built-in `bash` when not classified as mutating git, plus fn tools that run bounded subprocess/worktree acquisition flows such as `fn_run_verification` and `fn_acquire_repo_worktree` - `git_write`: mutating git shell commands run via `bash` - `network_api`: external/network-facing tools (for example `fn_research_run`, `fn_research_cancel`, `fn_web_fetch`, `worktrunk_install`; `fn_research_retry` is permanent-agent network-classified and remains action-gate read-only/exception behavior) -- `task_agent_mutation`: task/agent/workflow mutation tools (for example `fn_update_agent_config`, `fn_task_pause`, `fn_spawn_agent`, `fn_task_update`, `fn_task_promote`, `fn_task_refine`, and workflow mutators such as `fn_workflow_create`, `fn_workflow_update`, `fn_workflow_delete`, `fn_workflow_settings`, `fn_workflow_select`; action-gate task-import/create tools like `fn_task_create`, `fn_delegate_task`, `fn_task_import_github`, and `fn_task_import_github_issue` use this category in action-gate evaluation) +- `task_agent_mutation`: task/agent/workflow mutation tools (for example `fn_update_agent_config`, `fn_task_pause`, `fn_spawn_agent`, `fn_task_create`, `fn_task_update`, `fn_task_promote`, `fn_task_refine`, and workflow mutators such as `fn_workflow_create`, `fn_workflow_update`, `fn_workflow_delete`, `fn_workflow_settings`, `fn_workflow_select`; action-gate-only task coordination tools like `fn_delegate_task`, `fn_task_import_github`, and `fn_task_import_github_issue` use this category in action-gate evaluation) - Dashboard permission editors now show per-category example tools sourced from `AGENT_PERMISSION_POLICY_CATEGORY_TOOL_EXAMPLES` in `@fusion/core`, plus a read-only exempt-tools panel for coordination/messaging bypass tools. -- `none`: positively recognized read-only tools (`read`, `grep`, `find`, `ls`, list/show/get-style `fn_*` tools, plus permanent-agent coordination/task-creation helpers like `fn_task_create`, `fn_delegate_task`, `fn_task_import_github`, and `fn_task_import_github_issue`). Artifact tools mirror `fn_task_document_write` in the shipped allow-lists: `fn_artifact_register`, `fn_artifact_list`, and `fn_artifact_view` are present in `READONLY_FN_TOOLS` and `COORDINATION_EXEMPT_TOOLS`, so registration is treated as coordination/registry publication instead of a broad mutation approval. +- `none`: positively recognized read-only tools (`read`, `grep`, `find`, `ls`, list/show/get-style `fn_*` tools, plus permanent-agent coordination helpers like `fn_delegate_task`, `fn_task_import_github`, and `fn_task_import_github_issue`). Artifact tools mirror `fn_task_document_write` in the shipped allow-lists: `fn_artifact_register`, `fn_artifact_list`, and `fn_artifact_view` are present in `READONLY_FN_TOOLS` and `COORDINATION_EXEMPT_TOOLS`, so registration is treated as coordination/registry publication instead of a broad mutation approval. `bash` git-write heuristic in v1: @@ -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, 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`. +- 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. `fn_task_create` is governed as `task_agent_mutation` in both action-gate and permanent-agent evaluation because it creates task rows; delegation/import tools remain governed in action-gate evaluation 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/engine/src/__tests__/gating-classifications.test.ts b/packages/engine/src/__tests__/gating-classifications.test.ts index 82a8ee4290..f759ca3e84 100644 --- a/packages/engine/src/__tests__/gating-classifications.test.ts +++ b/packages/engine/src/__tests__/gating-classifications.test.ts @@ -7,6 +7,7 @@ import { COORDINATION_EXEMPT_TOOLS, FILE_WRITE_DELETE_FN_TOOLS, NETWORK_API_TOOLS, + PERMANENT_AGENT_TASK_MUTATION_TOOLS, READONLY_FN_TOOLS, TASK_AGENT_MUTATION_TOOLS, classifyGitCommand, @@ -94,12 +95,23 @@ 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", ]); +const policyMatrix = [ + [unrestrictedPolicy, "allow"], + [approvalRequiredPolicy, "require-approval"], + [blockedPolicy, "block"], +] as const; + +const permanentReadonlySiblingTaskCreationTools = [ + "fn_delegate_task", + "fn_task_import_github", + "fn_task_import_github_issue", +] as const; + describe("gating-classifications parity", () => { it("locks coordination exempt membership", () => { expect([...COORDINATION_EXEMPT_TOOLS].sort()).toMatchInlineSnapshot(` @@ -164,6 +176,50 @@ describe("gating-classifications parity", () => { }); }); + it("governs fn_task_create as task_agent_mutation in both gate paths", () => { + expect(READONLY_FN_TOOLS.has("fn_task_create")).toBe(false); + expect(TASK_AGENT_MUTATION_TOOLS.has("fn_task_create")).toBe(true); + expect(ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS.has("fn_task_create")).toBe(true); + expect(PERMANENT_AGENT_TASK_MUTATION_TOOLS.has("fn_task_create")).toBe(true); + expect(classifyPermanentAgentToolCall("fn_task_create")).toEqual({ + category: "task_agent_mutation", + recognized: true, + }); + + for (const [permissionPolicy, disposition] of policyMatrix) { + expect(resolvePermanentAgentToolDecision({ + toolName: "fn_task_create", + args: {}, + gating: { permissionPolicy }, + })).toMatchObject({ + category: "task_agent_mutation", + disposition, + recognized: true, + }); + expect(evaluateAgentActionGate({ + agentId: "a1", + toolName: "fn_task_create", + args: {}, + permissionPolicy, + })).toMatchObject({ + category: "task_agent_mutation", + disposition, + }); + } + }); + + it.each(permanentReadonlySiblingTaskCreationTools)("keeps sibling task creation tool %s permanent-readonly", (toolName) => { + expect(READONLY_FN_TOOLS.has(toolName)).toBe(true); + expect(ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS.has(toolName)).toBe(true); + expect(PERMANENT_AGENT_TASK_MUTATION_TOOLS.has(toolName)).toBe(false); + expect(classifyPermanentAgentToolCall(toolName)).toEqual({ category: "none", recognized: true }); + 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/__tests__/permanent-agent-gating.test.ts b/packages/engine/src/__tests__/permanent-agent-gating.test.ts index 4a860221fb..c1ad8fff24 100644 --- a/packages/engine/src/__tests__/permanent-agent-gating.test.ts +++ b/packages/engine/src/__tests__/permanent-agent-gating.test.ts @@ -20,7 +20,6 @@ const FN_7111_GOVERNED_TOOLS = [ const FN_3548_COORDINATION_TOOLS = [ "fn_heartbeat_done", - "fn_task_create", "fn_task_log", "fn_task_document_write", "fn_task_document_read", @@ -53,7 +52,7 @@ describe("permanent-agent-gating", () => { }); it("classifies shared fn tools by behavior", () => { - expect(classifyPermanentAgentToolCall("fn_task_create").category).toBe("none"); + expect(classifyPermanentAgentToolCall("fn_task_create").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_task_import_github").category).toBe("none"); diff --git a/packages/engine/src/gating-classifications.ts b/packages/engine/src/gating-classifications.ts index 7e2c496200..b75192c21b 100644 --- a/packages/engine/src/gating-classifications.ts +++ b/packages/engine/src/gating-classifications.ts @@ -36,13 +36,17 @@ export const COMMAND_EXECUTION_FN_TOOLS: ReadonlySet = new Set([ /** * 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. + * 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. In the permanent-agent gate, delegate/import tools remain recognized `none` coordination primitives while fn_task_create is governed separately as a board mutation. + * + * FNXC:ToolGovernance 2026-06-27-12:31: + * FN-7132 requires every live board-creation tool to avoid read-only classification in all gate paths. Classify fn_task_create as task_agent_mutation for both action and permanent agents so locked-down policies can block task-row creation; keep delegate and GitHub import tools in the action-only bucket until their permanent-agent coordination semantics are intentionally revisited. * * 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 PERMANENT_AND_ACTION_TASK_AGENT_TOOLS = ["fn_task_create"] as const; const ACTION_GATE_TASK_AGENT_ONLY_TOOLS = [ - "fn_task_create", + ...PERMANENT_AND_ACTION_TASK_AGENT_TOOLS, "fn_delegate_task", "fn_task_import_github", "fn_task_import_github_issue", @@ -92,6 +96,7 @@ export const ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS: ReadonlySet = new export const PERMANENT_AGENT_TASK_MUTATION_TOOLS: ReadonlySet = new Set([ ...SHARED_TASK_AGENT_TOOLS, + ...PERMANENT_AND_ACTION_TASK_AGENT_TOOLS, ...PERMANENT_TASK_AGENT_ONLY_TOOLS, ]); @@ -122,7 +127,6 @@ export const READONLY_FN_TOOLS: ReadonlySet = new Set([ "fn_task_search", // FNXC:ToolGovernance 2026-06-27-00:00: `fn_task_get` is a deprecated recognition-only alias. It is no longer registered as a live tool, but historical/in-flight calls must still classify as read-only instead of falling through to unknown-tool handling. "fn_task_get", - "fn_task_create", "fn_task_document_write", "fn_task_document_read", "fn_delegate_task",