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:
@@ -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 () => {
|
||||
|
||||
@@ -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]");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user