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:
7
.changeset/FN-7111-tool-classification-parity.md
Normal file
7
.changeset/FN-7111-tool-classification-parity.md
Normal 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.
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user