diff --git a/.changeset/FN-7111-tool-classification-parity.md b/.changeset/FN-7111-tool-classification-parity.md new file mode 100644 index 0000000000..4097ad1c03 --- /dev/null +++ b/.changeset/FN-7111-tool-classification-parity.md @@ -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. diff --git a/docs/agents.md b/docs/agents.md index 0e2857378d..c7b65fbcac 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -147,10 +147,10 @@ V1 runtime action categories: 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 -- `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` -- `network_api`: external/network-facing tools (for example `fn_research_run`, `fn_research_cancel`, `fn_research_retry`, `fn_web_fetch`) -- `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) +- `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) - 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. @@ -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, 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. - 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 768bb7e53f..0e394111b3 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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"], file_write_delete: ["write", "edit", "fn_task_attach"], - command_execution: ["bash (non-git)", "read", "find", "grep", "ls"], - network_api: ["fn_research_run (web/research)", "fn_web_fetch", "worktrunk_install"], + 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"], task_agent_mutation: [ "fn_task_create", "fn_delegate_task", @@ -6210,6 +6210,14 @@ export const AGENT_PERMISSION_POLICY_CATEGORY_TOOL_EXAMPLES: Record< "fn_spawn_agent", "fn_update_agent_config", "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", ], }; diff --git a/packages/engine/src/__tests__/agent-action-gate.test.ts b/packages/engine/src/__tests__/agent-action-gate.test.ts index bb8e01dddf..02cf0c4cde 100644 --- a/packages/engine/src/__tests__/agent-action-gate.test.ts +++ b/packages/engine/src/__tests__/agent-action-gate.test.ts @@ -125,9 +125,14 @@ describe("agent-action-gate", () => { expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_spawn_agent", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation"); }); - it("keeps routine task bookkeeping tools exempt", () => { - expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_update", args: {}, permissionPolicy: approvalPolicy }).category).toBe("exempt"); - expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_update", args: {}, permissionPolicy: approvalPolicy }).disposition).toBe("allow"); + it("governs fn_task_update as task_agent_mutation under permission policy", () => { + const approvalDecision = evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_update", args: {}, permissionPolicy: approvalPolicy }); + 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) => { diff --git a/packages/engine/src/__tests__/gating-classifications.test.ts b/packages/engine/src/__tests__/gating-classifications.test.ts index ccf2e50797..922dd0ead6 100644 --- a/packages/engine/src/__tests__/gating-classifications.test.ts +++ b/packages/engine/src/__tests__/gating-classifications.test.ts @@ -3,6 +3,7 @@ import { evaluateAgentActionGate } from "../agent-action-gate.js"; import { ACTION_GATE_NETWORK_API_TOOLS, ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS, + COMMAND_EXECUTION_FN_TOOLS, COORDINATION_EXEMPT_TOOLS, FILE_WRITE_DELETE_FN_TOOLS, NETWORK_API_TOOLS, @@ -14,6 +15,28 @@ import { import { classifyPermanentAgentToolCall, resolvePermanentAgentToolDecision } from "../permanent-agent-gating.js"; 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 = { presetId: "locked-down", 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 = [ ["git status", false, "git status"], ["git diff", false, "git diff"], @@ -89,8 +126,8 @@ describe("gating-classifications parity", () => { "fn_task_document_write", "fn_task_done", "fn_task_log", - "fn_task_update", "fn_update_identity", + "fn_workflow_list", "grep", "ls", "read", @@ -132,6 +169,47 @@ describe("gating-classifications parity", () => { 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", () => { const fnTools = new Set(); for (const source of [ @@ -140,6 +218,7 @@ describe("gating-classifications parity", () => { ACTION_GATE_NETWORK_API_TOOLS, FILE_WRITE_DELETE_FN_TOOLS, NETWORK_API_TOOLS, + COMMAND_EXECUTION_FN_TOOLS, ]) { for (const toolName of source) { if (toolName.startsWith("fn_")) fnTools.add(toolName); @@ -161,7 +240,9 @@ describe("gating-classifications parity", () => { ? "network" : action.category === "file_write_delete" ? "file-write" - : "readonly"; + : action.category === "command_execution" + ? "command" + : "readonly"; const permanentKind = permanent.category === "task_agent_mutation" ? "mutating" @@ -169,8 +250,14 @@ describe("gating-classifications parity", () => { ? "network" : permanent.category === "file_write_delete" ? "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)) { expect({ toolName, actionKind, permanentKind }).toEqual({ toolName, actionKind: "readonly", permanentKind: "file-write" }); continue; diff --git a/packages/engine/src/__tests__/permanent-agent-gating.test.ts b/packages/engine/src/__tests__/permanent-agent-gating.test.ts index f534caac2a..4a860221fb 100644 --- a/packages/engine/src/__tests__/permanent-agent-gating.test.ts +++ b/packages/engine/src/__tests__/permanent-agent-gating.test.ts @@ -4,6 +4,20 @@ import { resolvePermanentAgentToolDecision, } 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 = [ "fn_heartbeat_done", "fn_task_create", @@ -51,7 +65,9 @@ describe("permanent-agent-gating", () => { expect(classifyPermanentAgentToolCall("fn_artifact_view").category).toBe("none"); expect(classifyPermanentAgentToolCall("fn_memory_append").category).toBe("none"); 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("fn_task_update").category).toBe("task_agent_mutation"); expect(classifyPermanentAgentToolCall("fn_task_show").category).toBe("none"); expect(classifyPermanentAgentToolCall("fn_research_get").category).toBe("none"); 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 }); }); + 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", () => { const categories = [ classifyPermanentAgentToolCall("bash", { command: "git commit -m x" }).category, diff --git a/packages/engine/src/agent-action-gate.ts b/packages/engine/src/agent-action-gate.ts index 5c30ecaf57..7c65269faf 100644 --- a/packages/engine/src/agent-action-gate.ts +++ b/packages/engine/src/agent-action-gate.ts @@ -7,6 +7,7 @@ import type { import { ACTION_GATE_NETWORK_API_TOOLS, ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS, + COMMAND_EXECUTION_FN_TOOLS, COORDINATION_EXEMPT_TOOLS, READONLY_BUILTIN_TOOLS, classifyGitCommand, @@ -85,6 +86,7 @@ export function getExemptToolNames(): string[] { const TASK_AGENT_MANAGEMENT_TOOLS = ACTION_GATE_TASK_AGENT_MANAGEMENT_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; function normalizeArgs(args: unknown): Record { @@ -159,6 +161,10 @@ export function evaluateAgentActionGate(params: { category = "task_agent_mutation"; operation = params.toolName; 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)) { category = "network_api"; operation = params.toolName; diff --git a/packages/engine/src/gating-classifications.ts b/packages/engine/src/gating-classifications.ts index c0904352fd..824566e427 100644 --- a/packages/engine/src/gating-classifications.ts +++ b/packages/engine/src/gating-classifications.ts @@ -4,9 +4,36 @@ export const READONLY_BUILTIN_TOOLS: ReadonlySet = new Set(["read", "find", "grep", "ls"]); export const FILE_WRITE_BUILTIN_TOOLS: ReadonlySet = 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; +/** + * 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 = new Set([ + "fn_run_verification", + "fn_acquire_repo_worktree", +]); + const ACTION_GATE_TASK_AGENT_ONLY_TOOLS = [ "fn_task_create", "fn_delegate_task", @@ -22,7 +49,6 @@ const PERMANENT_TASK_AGENT_ONLY_TOOLS = [ "fn_task_unpause", "fn_task_retry", "fn_task_duplicate", - "fn_task_refine", "fn_task_archive", "fn_task_unarchive", "fn_task_delete", @@ -75,6 +101,7 @@ export const NETWORK_API_TOOLS: ReadonlySet = new Set([ export const ACTION_GATE_NETWORK_API_TOOLS: ReadonlySet = new Set([ "fn_research_run", + "fn_research_cancel", "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. ]); @@ -99,6 +126,8 @@ export const READONLY_FN_TOOLS: ReadonlySet = new Set([ "fn_insight_run_show", "fn_goal_list", "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_show", "fn_list_agents", @@ -107,7 +136,6 @@ export const READONLY_FN_TOOLS: ReadonlySet = new Set([ "fn_skills_search", "fn_memory_search", "fn_memory_get", - "fn_task_update", "fn_task_log", "fn_task_done", "fn_heartbeat_done", @@ -125,7 +153,6 @@ export const COORDINATION_EXEMPT_TOOLS = [ "find", "grep", "ls", - "fn_task_update", "fn_task_log", "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. */ @@ -145,6 +172,7 @@ export const COORDINATION_EXEMPT_TOOLS = [ "fn_list_agents", "fn_agent_show", "fn_agent_org_chart", + "fn_workflow_list", "fn_send_message", "fn_post_room_message", "fn_memory_append", diff --git a/packages/engine/src/permanent-agent-gating.ts b/packages/engine/src/permanent-agent-gating.ts index 5fab88aadd..68a585bb1e 100644 --- a/packages/engine/src/permanent-agent-gating.ts +++ b/packages/engine/src/permanent-agent-gating.ts @@ -5,6 +5,7 @@ import type { PermanentAgentSensitiveActionCategory, } from "@fusion/core"; import { + COMMAND_EXECUTION_FN_TOOLS, FILE_WRITE_BUILTIN_TOOLS, FILE_WRITE_DELETE_FN_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. const TASK_AGENT_MUTATION_TOOLS = PERMANENT_AGENT_TASK_MUTATION_TOOLS; const FILE_WRITE_DELETE_TOOLS = FILE_WRITE_DELETE_FN_TOOLS; +const COMMAND_EXECUTION_TOOLS = COMMAND_EXECUTION_FN_TOOLS; function normalizeArgs(args: unknown): Record { return args && typeof args === "object" ? (args as Record) : {}; @@ -62,6 +64,9 @@ export function classifyPermanentAgentToolCall( if (FILE_WRITE_DELETE_TOOLS.has(toolName)) { 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)) { return { category: "network_api", recognized: true }; }