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

@@ -0,0 +1,65 @@
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
import { TASK_AGENT_MUTATION_TOOLS } from "./gating-classifications.js";
export const READONLY_ALLOWLIST = [
"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",
] as const;
const WRITE_BUILTIN_TOOLS = ["edit", "write", "bash"] as const;
export const DENIED_IN_READONLY = [
...WRITE_BUILTIN_TOOLS,
...Array.from(TASK_AGENT_MUTATION_TOOLS).sort(),
] as const;
const READONLY_ALLOWLIST_SET = new Set<string>(READONLY_ALLOWLIST);
const DENIED_IN_READONLY_SET = new Set<string>(DENIED_IN_READONLY);
// Note: fn_task_browse_github_issues is read-only by behavior, but readonly sessions
// intentionally exclude host extensions in pi.ts, so it remains absent by default.
export class ReadonlyViolationError extends Error {
readonly code = "READONLY_VIOLATION" as const;
constructor(
public readonly taskId: string,
public readonly stepName: string,
public readonly toolName: string,
) {
super(`[readonly-violation] ${stepName} attempted to use denied tool "${toolName}" for task ${taskId}`);
this.name = "ReadonlyViolationError";
}
}
export function isReadonlyAllowed(toolName: string): boolean {
return READONLY_ALLOWLIST_SET.has(toolName.trim());
}
export function filterCustomToolsForReadonly(tools: ToolDefinition[]): { allowed: ToolDefinition[]; denied: string[] } {
const allowed: ToolDefinition[] = [];
const denied: string[] = [];
for (const tool of tools) {
const name = tool.name?.trim() ?? "";
if (!name) continue;
if (isReadonlyAllowed(name)) {
allowed.push(tool);
continue;
}
if (DENIED_IN_READONLY_SET.has(name)) {
denied.push(name);
}
}
return { allowed, denied };
}