feat(FN-4366): enforce readonly tool allowlist for workflow steps

Added a readonly tool allowlist enforcement for workflow steps, blocking execution of state-mutating tools (`fn_task_update`, `fn_task_move`, etc.) during workflow step runs. The policy is wired into the executor and merger execution paths, with tests covering allowlist enforcement and a documentati

Fusion-Task-Id: FN-4366
This commit is contained in:
Fusion
2026-05-14 06:19:50 -07:00
committed by gsxdsm
parent d4e6e383be
commit af3550bb64
8 changed files with 252 additions and 23 deletions

View File

@@ -1266,7 +1266,7 @@ describe("createFnAgent", () => {
expect(createSessionArgs.tools).toBeUndefined();
});
it("passes opt-in builtin web tool allowlist to createAgentSession", async () => {
it("intersects readonly builtin allowlist with readonly policy", async () => {
const { createFnAgent } = await import("../pi.js");
await createFnAgent({
@@ -1282,9 +1282,9 @@ describe("createFnAgent", () => {
"grep",
"find",
"ls",
"WebSearch",
"WebFetch",
]));
expect(createSessionArgs.tools).not.toContain("WebSearch");
expect(createSessionArgs.tools).not.toContain("WebFetch");
});
it("keeps caller customTools in coding sessions", async () => {

View File

@@ -0,0 +1,71 @@
import { describe, it, expect } from "vitest";
import {
DENIED_IN_READONLY,
READONLY_ALLOWLIST,
ReadonlyViolationError,
filterCustomToolsForReadonly,
isReadonlyAllowed,
} from "../workflow-step-tool-policy.js";
describe("workflow-step readonly allowlist policy", () => {
it("exposes expected readonly allowlist", () => {
expect(READONLY_ALLOWLIST).toEqual([
"read",
"grep",
"find",
"ls",
"fn_web_fetch",
"fn_task_show",
"fn_task_list",
"fn_insight_list",
"fn_insight_show",
"fn_list_agents",
"fn_get_agent_config",
]);
expect(isReadonlyAllowed("read")).toBe(true);
expect(isReadonlyAllowed(" edit ")).toBe(false);
});
it("denies write/mutation tool names and keeps readonly custom tools", () => {
expect(DENIED_IN_READONLY).toEqual(expect.arrayContaining<string>([
"edit",
"write",
"bash",
"fn_task_create",
"fn_spawn_agent",
"fn_delegate_task",
"fn_update_agent_config",
"fn_agent_create",
"fn_agent_delete",
"fn_task_plan",
"fn_mission_create",
"fn_mission_delete",
"fn_milestone_add",
"fn_slice_add",
"fn_feature_add",
"fn_slice_activate",
"fn_feature_link_task",
"fn_agent_stop",
"fn_agent_start",
]));
const filtered = filterCustomToolsForReadonly([
{ name: "read" } as any,
{ name: "fn_task_list" } as any,
{ name: "edit" } as any,
{ name: "fn_task_update" } as any,
]);
expect(filtered.allowed.map((tool) => tool.name)).toEqual(["read", "fn_task_list"]);
expect(filtered.denied).toEqual(["edit"]);
});
it("captures readonly violation error shape", () => {
const err = new ReadonlyViolationError("FN-4366", "Frontend UX Design", "edit");
expect(err.code).toBe("READONLY_VIOLATION");
expect(err.taskId).toBe("FN-4366");
expect(err.stepName).toBe("Frontend UX Design");
expect(err.toolName).toBe("edit");
expect(err.message).toContain("[readonly-violation]");
});
});