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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-27 11:54:06 -07:00
parent 68282769e6
commit 45e27f8875
6 changed files with 50 additions and 11 deletions

View File

@@ -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).

View File

@@ -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.

View File

@@ -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",

View File

@@ -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");

View File

@@ -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);

View File

@@ -34,12 +34,18 @@ export const COMMAND_EXECUTION_FN_TOOLS: ReadonlySet<string> = 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",