FN-7111: classify heartbeat tool parity gaps

Classify remaining heartbeat-agent tool gaps so permission gates enforce mutating and network behaviors consistently.

- Add shared classifications for workflow mutators, task update/promote/refine, verification/worktree acquisition, and research cancellation.
- Apply command-execution classifications in both action-gate and permanent-agent gating paths.
- Update operator docs, permission-policy examples, parity tests, and the published package changeset.

Files changed:
 .changeset/FN-7111-tool-classification-parity.md   |  7 ++
 docs/agents.md                                     |  8 +-
 packages/core/src/types.ts                         | 12 ++-
 .../engine/src/__tests__/agent-action-gate.test.ts | 11 ++-
 .../src/__tests__/gating-classifications.test.ts   | 93 +++++++++++++++++++++-
 .../src/__tests__/permanent-agent-gating.test.ts   | 44 ++++++++++
 packages/engine/src/agent-action-gate.ts           |  6 ++
 packages/engine/src/gating-classifications.ts      | 36 ++++++++-
 packages/engine/src/permanent-agent-gating.ts      |  5 ++
 9 files changed, 206 insertions(+), 16 deletions(-)

Fusion-Task-Id: FN-7111
Fusion-Task-Lineage: d2f5ee71-c742-4f10-af55-fec380f08f64
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-27 05:53:37 -07:00
parent 1a30ddd6e8
commit 2b9e383e18
9 changed files with 206 additions and 16 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Mutating agent tools now obey each agent's permission policy instead of always being allowed.
category: security
dev: Classifies fn_workflow_*, fn_task_update/promote/refine, fn_run_verification, fn_acquire_repo_worktree, and fn_research_cancel in shared gating classifications so both the action gate and permanent-agent gating govern them; closes the unrecognized-tool exempt→allow fall-through. Parity tests lock the decisions.

View File

@@ -147,10 +147,10 @@ V1 runtime action categories:
The engine classifies tool calls by behavior (not namespace alone): The engine classifies tool calls by behavior (not namespace alone):
- `file_write_delete`: built-in `write` / `edit`, plus direct filesystem attach helpers like `fn_task_attach`; low-risk coordination/registration writes such as `fn_task_document_write` and `fn_artifact_register` are handled by the coordination-exempt/read-only allow-lists below rather than this category - `file_write_delete`: built-in `write` / `edit`, plus direct filesystem attach helpers like `fn_task_attach`; low-risk coordination/registration writes such as `fn_task_document_write` and `fn_artifact_register` are handled by the coordination-exempt/read-only allow-lists below rather than this category
- `command_execution`: built-in `bash` when not classified as mutating git - `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` - `git_write`: mutating git shell commands run via `bash`
- `network_api`: external/network-facing tools (for example `fn_research_run`, `fn_research_cancel`, `fn_research_retry`, `fn_web_fetch`) - `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 mutation tools (for example `fn_update_agent_config`, `fn_task_pause`, `fn_spawn_agent`; 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_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)
- 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. - 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/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.
@@ -163,7 +163,7 @@ Unknown/unclassified tool fallback:
- In permanent-agent sessions, unknown tools default to `require-approval` (fail-safe). - 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. - Category `none` only yields `allow` when the tool is positively recognized as read-only.
- Internal Fusion runtime coordination tools (heartbeat completion, task/agent coordination, messaging, evaluations, identity reflection, memory bookkeeping) are exempt by design and always allowed so permanent-agent heartbeats can complete. - 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`.
- 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. - 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. - Canonical tool classification/exemption sets live in `packages/engine/src/gating-classifications.ts` and are shared by both action-gate paths.

View File

@@ -6200,8 +6200,8 @@ export const AGENT_PERMISSION_POLICY_CATEGORY_TOOL_EXAMPLES: Record<
> = { > = {
git_write: ["git commit", "git push", "git merge", "git branch -d", "git worktree add", "write", "edit"], git_write: ["git commit", "git push", "git merge", "git branch -d", "git worktree add", "write", "edit"],
file_write_delete: ["write", "edit", "fn_task_attach"], file_write_delete: ["write", "edit", "fn_task_attach"],
command_execution: ["bash (non-git)", "read", "find", "grep", "ls"], command_execution: ["bash (non-git)", "fn_run_verification", "fn_acquire_repo_worktree", "read", "find", "grep", "ls"],
network_api: ["fn_research_run (web/research)", "fn_web_fetch", "worktrunk_install"], network_api: ["fn_research_run (web/research)", "fn_research_cancel", "fn_web_fetch", "worktrunk_install"],
task_agent_mutation: [ task_agent_mutation: [
"fn_task_create", "fn_task_create",
"fn_delegate_task", "fn_delegate_task",
@@ -6210,6 +6210,14 @@ export const AGENT_PERMISSION_POLICY_CATEGORY_TOOL_EXAMPLES: Record<
"fn_spawn_agent", "fn_spawn_agent",
"fn_update_agent_config", "fn_update_agent_config",
"fn_update_identity", "fn_update_identity",
"fn_task_update",
"fn_workflow_create",
"fn_workflow_update",
"fn_workflow_delete",
"fn_workflow_settings",
"fn_workflow_select",
"fn_task_promote",
"fn_task_refine",
], ],
}; };

View File

@@ -125,9 +125,14 @@ describe("agent-action-gate", () => {
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_spawn_agent", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation"); expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_spawn_agent", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation");
}); });
it("keeps routine task bookkeeping tools exempt", () => { it("governs fn_task_update as task_agent_mutation under permission policy", () => {
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_update", args: {}, permissionPolicy: approvalPolicy }).category).toBe("exempt"); const approvalDecision = evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_update", args: {}, permissionPolicy: approvalPolicy });
expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_update", args: {}, permissionPolicy: approvalPolicy }).disposition).toBe("allow"); const blockedDecision = evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_update", args: {}, permissionPolicy: lockedDownPolicy });
expect(approvalDecision.category).toBe("task_agent_mutation");
expect(approvalDecision.disposition).toBe("require-approval");
expect(blockedDecision.category).toBe("task_agent_mutation");
expect(blockedDecision.disposition).toBe("block");
}); });
it.each(FN_3548_COORDINATION_TOOLS)("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) => {

View File

@@ -3,6 +3,7 @@ import { evaluateAgentActionGate } from "../agent-action-gate.js";
import { import {
ACTION_GATE_NETWORK_API_TOOLS, ACTION_GATE_NETWORK_API_TOOLS,
ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS, ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS,
COMMAND_EXECUTION_FN_TOOLS,
COORDINATION_EXEMPT_TOOLS, COORDINATION_EXEMPT_TOOLS,
FILE_WRITE_DELETE_FN_TOOLS, FILE_WRITE_DELETE_FN_TOOLS,
NETWORK_API_TOOLS, NETWORK_API_TOOLS,
@@ -14,6 +15,28 @@ import {
import { classifyPermanentAgentToolCall, resolvePermanentAgentToolDecision } from "../permanent-agent-gating.js"; import { classifyPermanentAgentToolCall, resolvePermanentAgentToolDecision } from "../permanent-agent-gating.js";
import type { AgentPermissionPolicy } from "@fusion/core"; import type { AgentPermissionPolicy } from "@fusion/core";
const unrestrictedPolicy: AgentPermissionPolicy = {
presetId: "unrestricted",
rules: {
git_write: "allow",
file_write_delete: "allow",
command_execution: "allow",
network_api: "allow",
task_agent_mutation: "allow",
},
};
const approvalRequiredPolicy: AgentPermissionPolicy = {
presetId: "approval-required",
rules: {
git_write: "require-approval",
file_write_delete: "require-approval",
command_execution: "require-approval",
network_api: "require-approval",
task_agent_mutation: "require-approval",
},
};
const blockedPolicy: AgentPermissionPolicy = { const blockedPolicy: AgentPermissionPolicy = {
presetId: "locked-down", presetId: "locked-down",
rules: { rules: {
@@ -25,6 +48,20 @@ const blockedPolicy: AgentPermissionPolicy = {
}, },
}; };
const FN_7111_GOVERNED_TOOLS = [
["fn_workflow_select", "task_agent_mutation"],
["fn_workflow_create", "task_agent_mutation"],
["fn_workflow_update", "task_agent_mutation"],
["fn_workflow_delete", "task_agent_mutation"],
["fn_workflow_settings", "task_agent_mutation"],
["fn_task_update", "task_agent_mutation"],
["fn_task_promote", "task_agent_mutation"],
["fn_task_refine", "task_agent_mutation"],
["fn_run_verification", "command_execution"],
["fn_acquire_repo_worktree", "command_execution"],
["fn_research_cancel", "network_api"],
] as const;
const gitCases = [ const gitCases = [
["git status", false, "git status"], ["git status", false, "git status"],
["git diff", false, "git diff"], ["git diff", false, "git diff"],
@@ -89,8 +126,8 @@ describe("gating-classifications parity", () => {
"fn_task_document_write", "fn_task_document_write",
"fn_task_done", "fn_task_done",
"fn_task_log", "fn_task_log",
"fn_task_update",
"fn_update_identity", "fn_update_identity",
"fn_workflow_list",
"grep", "grep",
"ls", "ls",
"read", "read",
@@ -132,6 +169,47 @@ describe("gating-classifications parity", () => {
expect((COORDINATION_EXEMPT_TOOLS as readonly string[]).includes("worktrunk_install")).toBe(false); expect((COORDINATION_EXEMPT_TOOLS as readonly string[]).includes("worktrunk_install")).toBe(false);
}); });
it.each(FN_7111_GOVERNED_TOOLS)("governs FN-7111 tool %s as %s across action policies", (toolName, category) => {
expect(evaluateAgentActionGate({ agentId: "a1", toolName, args: {}, permissionPolicy: unrestrictedPolicy })).toMatchObject({
category,
disposition: "allow",
});
expect(evaluateAgentActionGate({ agentId: "a1", toolName, args: {}, permissionPolicy: approvalRequiredPolicy })).toMatchObject({
category,
disposition: "require-approval",
});
expect(evaluateAgentActionGate({ agentId: "a1", toolName, args: {}, permissionPolicy: blockedPolicy })).toMatchObject({
category,
disposition: "block",
});
});
it.each(FN_7111_GOVERNED_TOOLS)("blocks FN-7111 mutating tool %s under locked-down policy in both gate paths", (toolName, category) => {
const action = evaluateAgentActionGate({ agentId: "a1", toolName, args: {}, permissionPolicy: blockedPolicy });
const permanent = resolvePermanentAgentToolDecision({
toolName,
args: {},
gating: { permissionPolicy: blockedPolicy },
});
expect(action).toMatchObject({ category, disposition: "block" });
expect(permanent).toMatchObject({ category, disposition: "block", recognized: true });
});
it("recognizes fn_workflow_list as read-only coordination instead of an unknown fallback", () => {
const permanent = classifyPermanentAgentToolCall("fn_workflow_list");
const action = evaluateAgentActionGate({
agentId: "a1",
toolName: "fn_workflow_list",
args: {},
permissionPolicy: blockedPolicy,
});
expect(permanent).toEqual({ category: "none", recognized: true });
expect(action).toMatchObject({ category: "exempt", disposition: "allow" });
expect((COORDINATION_EXEMPT_TOOLS as readonly string[]).includes("fn_workflow_list")).toBe(true);
});
it("keeps fn_* category equivalence mappings across gates", () => { it("keeps fn_* category equivalence mappings across gates", () => {
const fnTools = new Set<string>(); const fnTools = new Set<string>();
for (const source of [ for (const source of [
@@ -140,6 +218,7 @@ describe("gating-classifications parity", () => {
ACTION_GATE_NETWORK_API_TOOLS, ACTION_GATE_NETWORK_API_TOOLS,
FILE_WRITE_DELETE_FN_TOOLS, FILE_WRITE_DELETE_FN_TOOLS,
NETWORK_API_TOOLS, NETWORK_API_TOOLS,
COMMAND_EXECUTION_FN_TOOLS,
]) { ]) {
for (const toolName of source) { for (const toolName of source) {
if (toolName.startsWith("fn_")) fnTools.add(toolName); if (toolName.startsWith("fn_")) fnTools.add(toolName);
@@ -161,7 +240,9 @@ describe("gating-classifications parity", () => {
? "network" ? "network"
: action.category === "file_write_delete" : action.category === "file_write_delete"
? "file-write" ? "file-write"
: "readonly"; : action.category === "command_execution"
? "command"
: "readonly";
const permanentKind = permanent.category === "task_agent_mutation" const permanentKind = permanent.category === "task_agent_mutation"
? "mutating" ? "mutating"
@@ -169,8 +250,14 @@ describe("gating-classifications parity", () => {
? "network" ? "network"
: permanent.category === "file_write_delete" : permanent.category === "file_write_delete"
? "file-write" ? "file-write"
: "readonly"; : permanent.category === "command_execution"
? "command"
: "readonly";
if (COMMAND_EXECUTION_FN_TOOLS.has(toolName)) {
expect({ toolName, actionKind, permanentKind }).toEqual({ toolName, actionKind: "command", permanentKind: "command" });
continue;
}
if (FILE_WRITE_DELETE_FN_TOOLS.has(toolName)) { if (FILE_WRITE_DELETE_FN_TOOLS.has(toolName)) {
expect({ toolName, actionKind, permanentKind }).toEqual({ toolName, actionKind: "readonly", permanentKind: "file-write" }); expect({ toolName, actionKind, permanentKind }).toEqual({ toolName, actionKind: "readonly", permanentKind: "file-write" });
continue; continue;

View File

@@ -4,6 +4,20 @@ import {
resolvePermanentAgentToolDecision, resolvePermanentAgentToolDecision,
} from "../permanent-agent-gating.js"; } from "../permanent-agent-gating.js";
const FN_7111_GOVERNED_TOOLS = [
["fn_workflow_select", "task_agent_mutation"],
["fn_workflow_create", "task_agent_mutation"],
["fn_workflow_update", "task_agent_mutation"],
["fn_workflow_delete", "task_agent_mutation"],
["fn_workflow_settings", "task_agent_mutation"],
["fn_task_update", "task_agent_mutation"],
["fn_task_promote", "task_agent_mutation"],
["fn_task_refine", "task_agent_mutation"],
["fn_run_verification", "command_execution"],
["fn_acquire_repo_worktree", "command_execution"],
["fn_research_cancel", "network_api"],
] as const;
const FN_3548_COORDINATION_TOOLS = [ const FN_3548_COORDINATION_TOOLS = [
"fn_heartbeat_done", "fn_heartbeat_done",
"fn_task_create", "fn_task_create",
@@ -51,7 +65,9 @@ describe("permanent-agent-gating", () => {
expect(classifyPermanentAgentToolCall("fn_artifact_view").category).toBe("none"); expect(classifyPermanentAgentToolCall("fn_artifact_view").category).toBe("none");
expect(classifyPermanentAgentToolCall("fn_memory_append").category).toBe("none"); 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_research_cancel").category).toBe("network_api");
expect(classifyPermanentAgentToolCall("worktrunk_install").category).toBe("network_api"); expect(classifyPermanentAgentToolCall("worktrunk_install").category).toBe("network_api");
expect(classifyPermanentAgentToolCall("fn_task_update").category).toBe("task_agent_mutation");
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");
expect(classifyPermanentAgentToolCall("fn_heartbeat_done")).toEqual({ category: "none", recognized: true }); expect(classifyPermanentAgentToolCall("fn_heartbeat_done")).toEqual({ category: "none", recognized: true });
@@ -59,6 +75,34 @@ describe("permanent-agent-gating", () => {
expect(classifyPermanentAgentToolCall("fn_read_messages")).toEqual({ category: "none", recognized: true }); expect(classifyPermanentAgentToolCall("fn_read_messages")).toEqual({ category: "none", recognized: true });
}); });
it.each(FN_7111_GOVERNED_TOOLS)("classifies FN-7111 governed tool %s as recognized %s", (toolName, category) => {
expect(classifyPermanentAgentToolCall(toolName)).toEqual({ category, recognized: true });
});
it.each(FN_7111_GOVERNED_TOOLS)("blocks FN-7111 governed tool %s under locked-down policy", (toolName, category) => {
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).toMatchObject({ category, recognized: true, disposition: "block" });
});
it("classifies fn_workflow_list as recognized readonly", () => {
expect(classifyPermanentAgentToolCall("fn_workflow_list")).toEqual({ category: "none", recognized: true });
});
it("uses only canonical action-category names", () => { it("uses only canonical action-category names", () => {
const categories = [ const categories = [
classifyPermanentAgentToolCall("bash", { command: "git commit -m x" }).category, classifyPermanentAgentToolCall("bash", { command: "git commit -m x" }).category,

View File

@@ -7,6 +7,7 @@ import type {
import { import {
ACTION_GATE_NETWORK_API_TOOLS, ACTION_GATE_NETWORK_API_TOOLS,
ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS, ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS,
COMMAND_EXECUTION_FN_TOOLS,
COORDINATION_EXEMPT_TOOLS, COORDINATION_EXEMPT_TOOLS,
READONLY_BUILTIN_TOOLS, READONLY_BUILTIN_TOOLS,
classifyGitCommand, classifyGitCommand,
@@ -85,6 +86,7 @@ export function getExemptToolNames(): string[] {
const TASK_AGENT_MANAGEMENT_TOOLS = ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS; const TASK_AGENT_MANAGEMENT_TOOLS = ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS;
const NETWORK_API_TOOLS = ACTION_GATE_NETWORK_API_TOOLS; const NETWORK_API_TOOLS = ACTION_GATE_NETWORK_API_TOOLS;
const COMMAND_EXECUTION_TOOLS = COMMAND_EXECUTION_FN_TOOLS;
const READONLY_DISCOVERY_TOOLS = READONLY_BUILTIN_TOOLS; const READONLY_DISCOVERY_TOOLS = READONLY_BUILTIN_TOOLS;
function normalizeArgs(args: unknown): Record<string, unknown> { function normalizeArgs(args: unknown): Record<string, unknown> {
@@ -159,6 +161,10 @@ export function evaluateAgentActionGate(params: {
category = "task_agent_mutation"; category = "task_agent_mutation";
operation = params.toolName; operation = params.toolName;
resourceType = params.toolName.includes("agent") || params.toolName.includes("spawn") ? "agent" : "task"; resourceType = params.toolName.includes("agent") || params.toolName.includes("spawn") ? "agent" : "task";
} else if (COMMAND_EXECUTION_TOOLS.has(params.toolName)) {
category = "command_execution";
operation = params.toolName;
resourceType = "command";
} else if (NETWORK_API_TOOLS.has(params.toolName)) { } else if (NETWORK_API_TOOLS.has(params.toolName)) {
category = "network_api"; category = "network_api";
operation = params.toolName; operation = params.toolName;

View File

@@ -4,9 +4,36 @@
export const READONLY_BUILTIN_TOOLS: ReadonlySet<string> = new Set(["read", "find", "grep", "ls"]); export const READONLY_BUILTIN_TOOLS: ReadonlySet<string> = new Set(["read", "find", "grep", "ls"]);
export const FILE_WRITE_BUILTIN_TOOLS: ReadonlySet<string> = new Set(["write", "edit"]); export const FILE_WRITE_BUILTIN_TOOLS: ReadonlySet<string> = new Set(["write", "edit"]);
const SHARED_TASK_AGENT_TOOLS = ["fn_task_add_dep", "fn_spawn_agent", "fn_update_agent_config", "fn_agent_create", "fn_agent_delete"] as const; /**
* FNXC:ToolGovernance 2026-06-27-12:05:
* Workflow edits, task status/custom-field updates, held-task promotion, and refinement creation are mutating heartbeat tools. Keep them in the shared task-agent bucket so action-gate and permanent-agent policy decisions cannot drift or fall through to silent exemption.
*/
const SHARED_TASK_AGENT_TOOLS = [
"fn_task_add_dep",
"fn_task_update",
"fn_spawn_agent",
"fn_update_agent_config",
"fn_agent_create",
"fn_agent_delete",
"fn_workflow_select",
"fn_workflow_create",
"fn_workflow_update",
"fn_workflow_delete",
"fn_workflow_settings",
"fn_task_promote",
"fn_task_refine",
] as const;
const PROVISIONING_TOOLS = ["fn_agent_create", "fn_agent_delete"] as const; const PROVISIONING_TOOLS = ["fn_agent_create", "fn_agent_delete"] as const;
/**
* FNXC:ToolGovernance 2026-06-27-12:00:
* Newly exposed mutating heartbeat tools must be positively classified before agents receive them, otherwise the action gate's unrecognized-tool fallback silently allows them. Verification and workspace acquisition execute subprocess/git-worktree work, so both gating paths use command_execution instead of a coordination exemption.
*/
export const COMMAND_EXECUTION_FN_TOOLS: ReadonlySet<string> = new Set([
"fn_run_verification",
"fn_acquire_repo_worktree",
]);
const ACTION_GATE_TASK_AGENT_ONLY_TOOLS = [ const ACTION_GATE_TASK_AGENT_ONLY_TOOLS = [
"fn_task_create", "fn_task_create",
"fn_delegate_task", "fn_delegate_task",
@@ -22,7 +49,6 @@ const PERMANENT_TASK_AGENT_ONLY_TOOLS = [
"fn_task_unpause", "fn_task_unpause",
"fn_task_retry", "fn_task_retry",
"fn_task_duplicate", "fn_task_duplicate",
"fn_task_refine",
"fn_task_archive", "fn_task_archive",
"fn_task_unarchive", "fn_task_unarchive",
"fn_task_delete", "fn_task_delete",
@@ -75,6 +101,7 @@ export const NETWORK_API_TOOLS: ReadonlySet<string> = new Set([
export const ACTION_GATE_NETWORK_API_TOOLS: ReadonlySet<string> = new Set([ export const ACTION_GATE_NETWORK_API_TOOLS: ReadonlySet<string> = new Set([
"fn_research_run", "fn_research_run",
"fn_research_cancel",
"fn_web_fetch", // FN-4603: honor network_api approval policy for web fetches. "fn_web_fetch", // FN-4603: honor network_api approval policy for web fetches.
"worktrunk_install", // FN-4624: gate binary auto-install under network_api policy. "worktrunk_install", // FN-4624: gate binary auto-install under network_api policy.
]); ]);
@@ -99,6 +126,8 @@ export const READONLY_FN_TOOLS: ReadonlySet<string> = new Set([
"fn_insight_run_show", "fn_insight_run_show",
"fn_goal_list", "fn_goal_list",
"fn_goal_show", "fn_goal_show",
// FNXC:ToolGovernance 2026-06-27-12:06: Workflow listing is read-only discovery and must stay positively recognized instead of relying on an unknown-tool fallback.
"fn_workflow_list",
"fn_mission_list", "fn_mission_list",
"fn_mission_show", "fn_mission_show",
"fn_list_agents", "fn_list_agents",
@@ -107,7 +136,6 @@ export const READONLY_FN_TOOLS: ReadonlySet<string> = new Set([
"fn_skills_search", "fn_skills_search",
"fn_memory_search", "fn_memory_search",
"fn_memory_get", "fn_memory_get",
"fn_task_update",
"fn_task_log", "fn_task_log",
"fn_task_done", "fn_task_done",
"fn_heartbeat_done", "fn_heartbeat_done",
@@ -125,7 +153,6 @@ export const COORDINATION_EXEMPT_TOOLS = [
"find", "find",
"grep", "grep",
"ls", "ls",
"fn_task_update",
"fn_task_log", "fn_task_log",
"fn_task_done", "fn_task_done",
/* FNXC:ArtifactRegistry 2026-06-21-00:00: Artifact registration mutates persisted registry state, but it is a low-risk coordination action classified like fn_task_document_write so permanent agents can publish discoverable deliverables without broad mutation approval. */ /* FNXC:ArtifactRegistry 2026-06-21-00:00: Artifact registration mutates persisted registry state, but it is a low-risk coordination action classified like fn_task_document_write so permanent agents can publish discoverable deliverables without broad mutation approval. */
@@ -145,6 +172,7 @@ export const COORDINATION_EXEMPT_TOOLS = [
"fn_list_agents", "fn_list_agents",
"fn_agent_show", "fn_agent_show",
"fn_agent_org_chart", "fn_agent_org_chart",
"fn_workflow_list",
"fn_send_message", "fn_send_message",
"fn_post_room_message", "fn_post_room_message",
"fn_memory_append", "fn_memory_append",

View File

@@ -5,6 +5,7 @@ import type {
PermanentAgentSensitiveActionCategory, PermanentAgentSensitiveActionCategory,
} from "@fusion/core"; } from "@fusion/core";
import { import {
COMMAND_EXECUTION_FN_TOOLS,
FILE_WRITE_BUILTIN_TOOLS, FILE_WRITE_BUILTIN_TOOLS,
FILE_WRITE_DELETE_FN_TOOLS, FILE_WRITE_DELETE_FN_TOOLS,
NETWORK_API_TOOLS, NETWORK_API_TOOLS,
@@ -31,6 +32,7 @@ const FILE_WRITE_TOOLS = FILE_WRITE_BUILTIN_TOOLS;
// category "none" so restrictive permanent-agent policies cannot deadlock heartbeats. // category "none" so restrictive permanent-agent policies cannot deadlock heartbeats.
const TASK_AGENT_MUTATION_TOOLS = PERMANENT_AGENT_TASK_MUTATION_TOOLS; const TASK_AGENT_MUTATION_TOOLS = PERMANENT_AGENT_TASK_MUTATION_TOOLS;
const FILE_WRITE_DELETE_TOOLS = FILE_WRITE_DELETE_FN_TOOLS; const FILE_WRITE_DELETE_TOOLS = FILE_WRITE_DELETE_FN_TOOLS;
const COMMAND_EXECUTION_TOOLS = COMMAND_EXECUTION_FN_TOOLS;
function normalizeArgs(args: unknown): Record<string, unknown> { function normalizeArgs(args: unknown): Record<string, unknown> {
return args && typeof args === "object" ? (args as Record<string, unknown>) : {}; return args && typeof args === "object" ? (args as Record<string, unknown>) : {};
@@ -62,6 +64,9 @@ export function classifyPermanentAgentToolCall(
if (FILE_WRITE_DELETE_TOOLS.has(toolName)) { if (FILE_WRITE_DELETE_TOOLS.has(toolName)) {
return { category: "file_write_delete", recognized: true }; return { category: "file_write_delete", recognized: true };
} }
if (COMMAND_EXECUTION_TOOLS.has(toolName)) {
return { category: "command_execution", recognized: true };
}
if (NETWORK_API_TOOLS.has(toolName)) { if (NETWORK_API_TOOLS.has(toolName)) {
return { category: "network_api", recognized: true }; return { category: "network_api", recognized: true };
} }