diff --git a/.changeset/fn-7728-review-gate-bypass-rbac.md b/.changeset/fn-7728-review-gate-bypass-rbac.md new file mode 100644 index 0000000000..0c74c90867 --- /dev/null +++ b/.changeset/fn-7728-review-gate-bypass-rbac.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add a dedicated permission for who may bypass a failed review gate, separate from task mutations. +category: feature +dev: Adds a new `review_gate_bypass` permission-policy category (packages/core/src/types.ts, agent-permission-policy.ts) governing `fn_task_bypass_review` (FN-7720). `fn_task_bypass_review` is classified into it in the shared `gating-classifications.ts` source and resolves identically in both `evaluateAgentActionGate` and the permanent-agent gate. Defaults to `require-approval` even under the `unrestricted` preset (stricter than the uniform preset default), while `approval-required`/`locked-down` presets already cover it uniformly. `toolRules.fn_task_bypass_review` exact overrides continue to apply on top. The dashboard permission-policy editor (project-default + per-agent override) renders the category as its own row. No DB migration required; a stored policy missing the key resolves to the preset default. The tool's CLI/pi-extension-only registration surface is unchanged. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 211fbd5c88..43482d06aa 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -1583,7 +1583,8 @@ Project-scoped default permission policy for agent runtime action gates. It appl "git_write": "require-approval", "command_execution": "require-approval", "network_api": "block", - "task_agent_mutation": "allow" + "task_agent_mutation": "allow", + "review_gate_bypass": "block" }, "toolRules": { "fn_task_create": "block" @@ -1594,9 +1595,10 @@ Project-scoped default permission policy for agent runtime action gates. It appl - `rules` is a partial map of category → disposition. - `toolRules` is an optional exact tool-name map (`fn_task_create`, `fn_web_fetch`, `bash`, etc.) → disposition. Exact tool rules apply before category rules, so the example blocks task creation while leaving other `task_agent_mutation` tools allowed. -- Categories: `git_write`, `file_write_delete`, `command_execution`, `network_api`, `task_agent_mutation`. +- Categories: `git_write`, `file_write_delete`, `command_execution`, `network_api`, `task_agent_mutation`, `review_gate_bypass`. +- `review_gate_bypass` (FN-7728) governs `fn_task_bypass_review` — the operator-only merge-gate override that force-advances a card past a failed pre-merge review step (FN-7720). It is a dedicated, more-restricted category distinct from `task_agent_mutation`: even the `unrestricted` preset defaults it to `require-approval` instead of `allow`, so a review-gate bypass is never silently allowed by default. `approval-required` (`require-approval`) and `locked-down` (`block`) already cover it uniformly. `toolRules.fn_task_bypass_review` still layers an exact override on top of the category rule, same as any other tool. The tool remains registered CLI/pi-extension-only — it is never exposed to executor/reviewer/triage agent tool lists. - Dispositions: `allow`, `require-approval`, `block`. -- Missing categories default to `allow` via the built-in `unrestricted` seed; missing or empty `toolRules` preserve legacy category-only behavior. +- Missing categories default to `allow` via the built-in `unrestricted` seed, EXCEPT `review_gate_bypass` which seeds to `require-approval` even under `unrestricted`/`custom`; missing or empty `toolRules` preserve legacy category-only behavior. A stored policy that predates the `review_gate_bypass` category (missing the key) resolves to the preset default — no migration is required. - Runtime precedence is per-agent exact tool rule → per-agent category rule → project default exact tool rule → project default category rule → unrestricted fallback. - Heartbeat-critical coordination/exempt tools remain non-configurable and allowed to prevent deadlocks. - Legacy ephemeral agents without `permissionPolicy` are not rewritten on disk; they inherit this setting when a runtime session is built. diff --git a/packages/core/src/__tests__/agent-permission-policy.test.ts b/packages/core/src/__tests__/agent-permission-policy.test.ts index db8735a60f..8542aa0883 100644 --- a/packages/core/src/__tests__/agent-permission-policy.test.ts +++ b/packages/core/src/__tests__/agent-permission-policy.test.ts @@ -26,9 +26,15 @@ describe("agent-permission-policy", () => { ]); }); - it("normalizes unrestricted preset with all categories allow", () => { + it("normalizes unrestricted preset with all categories allow, except review_gate_bypass which stays require-approval", () => { + // FN-7728: review_gate_bypass intentionally diverges from the uniform unrestricted default — + // a merge-gate bypass must never be silently allowed by default even under the permissive preset. const policy = normalizeAgentPermissionPolicyFromPreset("unrestricted"); for (const category of AGENT_PERMISSION_POLICY_ACTION_CATEGORIES) { + if (category === "review_gate_bypass") { + expect(policy.rules[category]).toBe("require-approval"); + continue; + } expect(policy.rules[category]).toBe("allow"); } }); @@ -51,6 +57,10 @@ describe("agent-permission-policy", () => { const effective = resolveEffectiveAgentPermissionPolicy(undefined); expect(effective.presetId).toBe(DEFAULT_AGENT_PERMISSION_POLICY_PRESET_ID); for (const category of AGENT_PERMISSION_POLICY_ACTION_CATEGORIES) { + if (category === "review_gate_bypass") { + expect(effective.rules[category]).toBe("require-approval"); + continue; + } expect(effective.rules[category]).toBe("allow"); } expect(effective.toolRules).toBeUndefined(); @@ -117,4 +127,46 @@ describe("agent-permission-policy", () => { expect(COORDINATION_EXEMPT_TOOLS).toContain(toolName); } }); + + // FN-7728: review_gate_bypass regression coverage. + describe("review_gate_bypass category", () => { + it("is a distinct category from task_agent_mutation with fn_task_bypass_review as its example tool", () => { + expect(AGENT_PERMISSION_POLICY_ACTION_CATEGORIES).toContain("review_gate_bypass"); + expect(AGENT_PERMISSION_POLICY_CATEGORY_TOOL_EXAMPLES.review_gate_bypass).toEqual(["fn_task_bypass_review"]); + expect(AGENT_PERMISSION_POLICY_CATEGORY_TOOL_EXAMPLES.task_agent_mutation).not.toContain("fn_task_bypass_review"); + }); + + it("defaults to require-approval under approval-required and block under locked-down", () => { + expect(normalizeAgentPermissionPolicyFromPreset("approval-required").rules.review_gate_bypass).toBe("require-approval"); + expect(normalizeAgentPermissionPolicyFromPreset("locked-down").rules.review_gate_bypass).toBe("block"); + }); + + it("can be overridden independently under a custom policy without changing task_agent_mutation", () => { + const policy = normalizeAgentPermissionPolicy({ + presetId: "custom", + rules: { review_gate_bypass: "allow" }, + }); + + expect(policy.rules.review_gate_bypass).toBe("allow"); + expect(policy.rules.task_agent_mutation).toBe("allow"); + }); + + it("resolves a stored policy missing the review_gate_bypass key to the preset default (no migration required)", () => { + const effective = resolveEffectiveAgentPermissionPolicy({ + presetId: "custom", + rules: { task_agent_mutation: "block" } as never, + }); + + expect(effective.rules.review_gate_bypass).toBe("require-approval"); + }); + + it("lets the project default override review_gate_bypass independently", () => { + const effective = resolveEffectiveAgentPermissionPolicy(undefined, { + rules: { review_gate_bypass: "block" }, + }); + + expect(effective.rules.review_gate_bypass).toBe("block"); + expect(effective.rules.task_agent_mutation).toBe("allow"); + }); + }); }); diff --git a/packages/core/src/agent-permission-policy.ts b/packages/core/src/agent-permission-policy.ts index 6e52f50896..d31d89bedd 100644 --- a/packages/core/src/agent-permission-policy.ts +++ b/packages/core/src/agent-permission-policy.ts @@ -30,7 +30,7 @@ const BUILT_IN_PRESETS: Record diff --git a/packages/dashboard/app/components/AgentPermissionPolicyEditor.tsx b/packages/dashboard/app/components/AgentPermissionPolicyEditor.tsx index 664585f192..ae05f6f039 100644 --- a/packages/dashboard/app/components/AgentPermissionPolicyEditor.tsx +++ b/packages/dashboard/app/components/AgentPermissionPolicyEditor.tsx @@ -46,6 +46,8 @@ function getCategoryLabels(t: TFunction<"app">): Record { command_execution: "allow", network_api: "allow", task_agent_mutation: "allow", + review_gate_bypass: "allow", } }} onChange={onChange} />, @@ -57,6 +58,7 @@ describe("AgentPermissionPolicyEditor", () => { command_execution: "allow", network_api: "allow", task_agent_mutation: "allow", + review_gate_bypass: "allow", } }} onChange={onChange} />, @@ -117,6 +119,7 @@ describe("AgentPermissionPolicyEditor", () => { command_execution: "allow", network_api: "allow", task_agent_mutation: "allow", + review_gate_bypass: "allow", } }} onChange={onChange} />, @@ -153,6 +156,7 @@ describe("AgentPermissionPolicyEditor", () => { command_execution: "allow", network_api: "allow", task_agent_mutation: "allow", + review_gate_bypass: "allow", }, toolRules: { fn_task_create: "block" } }} onChange={onChange} />, @@ -173,6 +177,7 @@ describe("AgentPermissionPolicyEditor", () => { command_execution: "allow", network_api: "allow", task_agent_mutation: "allow", + review_gate_bypass: "allow", }, toolRules: { fn_task_create: "allow" } }} projectDefaultToolRules={{ fn_task_create: "block" }} onChange={() => {}} diff --git a/packages/engine/src/__tests__/agent-action-gate.test.ts b/packages/engine/src/__tests__/agent-action-gate.test.ts index db8c44e5c7..297a8ddd97 100644 --- a/packages/engine/src/__tests__/agent-action-gate.test.ts +++ b/packages/engine/src/__tests__/agent-action-gate.test.ts @@ -39,6 +39,9 @@ const unrestrictedPolicy: AgentPermissionPolicy = { "command_execution": "allow", "network_api": "allow", "task_agent_mutation": "allow", + // FN-7728: review_gate_bypass is intentionally allow here so tests targeting this fixture's other + // categories are unaffected; dedicated review_gate_bypass disposition coverage lives below. + "review_gate_bypass": "allow", }, }; @@ -50,6 +53,7 @@ const lockedDownPolicy: AgentPermissionPolicy = { "command_execution": "block", "network_api": "block", "task_agent_mutation": "block", + "review_gate_bypass": "block", }, }; @@ -62,6 +66,7 @@ const approvalPolicy: AgentPermissionPolicy = { "command_execution": "require-approval", "network_api": "require-approval", "task_agent_mutation": "require-approval", + "review_gate_bypass": "require-approval", }, }; @@ -136,6 +141,46 @@ describe("agent-action-gate", () => { expect(blockedDecision.disposition).toBe("block"); }); + // FN-7728: fn_task_bypass_review must classify as its own review_gate_bypass category, + // not task_agent_mutation, and must never fall through to the unrecognized-tool exempt fallback. + it("classifies fn_task_bypass_review as review_gate_bypass, distinct from task_agent_mutation and exempt", () => { + const decision = evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_bypass_review", args: {}, permissionPolicy: unrestrictedPolicy }); + expect(decision.category).toBe("review_gate_bypass"); + expect(decision.category).not.toBe("task_agent_mutation"); + expect(decision.category).not.toBe("exempt"); + expect(decision.resourceType).toBe("task"); + }); + + it.each([ + ["allow" as const, "allow" as const], + ["require-approval" as const, "require-approval" as const], + ["block" as const, "block" as const], + ])("honors review_gate_bypass disposition %s for fn_task_bypass_review", (ruleDisposition, expectedDisposition) => { + const policy: AgentPermissionPolicy = { + ...unrestrictedPolicy, + presetId: "custom", + rules: { ...unrestrictedPolicy.rules, review_gate_bypass: ruleDisposition }, + }; + const decision = evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_bypass_review", args: {}, permissionPolicy: policy }); + expect(decision.category).toBe("review_gate_bypass"); + expect(decision.disposition).toBe(expectedDisposition); + }); + + it("lets an exact toolRules.fn_task_bypass_review override win over the review_gate_bypass category rule", () => { + const policy: AgentPermissionPolicy = { + ...unrestrictedPolicy, + presetId: "custom", + rules: { ...unrestrictedPolicy.rules, review_gate_bypass: "block" }, + toolRules: { fn_task_bypass_review: "allow" }, + }; + const decision = evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_bypass_review", args: {}, permissionPolicy: policy }); + expect(decision.category).toBe("review_gate_bypass"); + expect(decision.disposition).toBe("allow"); + expect(decision.metadata).toMatchObject({ + permissionPolicyMatch: { type: "toolRule", toolName: "fn_task_bypass_review", disposition: "allow" }, + }); + }); + it("uses exact tool overrides before task-agent category rules", () => { const policy: AgentPermissionPolicy = { ...unrestrictedPolicy, diff --git a/packages/engine/src/__tests__/gating-classifications.test.ts b/packages/engine/src/__tests__/gating-classifications.test.ts index f15257568d..58b979aa23 100644 --- a/packages/engine/src/__tests__/gating-classifications.test.ts +++ b/packages/engine/src/__tests__/gating-classifications.test.ts @@ -24,6 +24,7 @@ const unrestrictedPolicy: AgentPermissionPolicy = { command_execution: "allow", network_api: "allow", task_agent_mutation: "allow", + review_gate_bypass: "allow", }, }; @@ -35,6 +36,7 @@ const approvalRequiredPolicy: AgentPermissionPolicy = { command_execution: "require-approval", network_api: "require-approval", task_agent_mutation: "require-approval", + review_gate_bypass: "require-approval", }, }; @@ -46,6 +48,7 @@ const blockedPolicy: AgentPermissionPolicy = { command_execution: "block", network_api: "block", task_agent_mutation: "block", + review_gate_bypass: "block", }, }; @@ -222,6 +225,66 @@ describe("gating-classifications parity", () => { } }); + // FN-7728 (Surface Enumeration: gate paths): fn_task_bypass_review must classify as review_gate_bypass + // identically in BOTH evaluateAgentActionGate and the permanent-agent gate, never task_agent_mutation, + // never the unrecognized-tool exempt fallback, and honor allow/require-approval/block plus toolRules override. + it("governs fn_task_bypass_review as review_gate_bypass in both gate paths, distinct from task_agent_mutation", () => { + expect(TASK_AGENT_MUTATION_TOOLS.has("fn_task_bypass_review")).toBe(false); + expect(ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS.has("fn_task_bypass_review")).toBe(false); + expect(PERMANENT_AGENT_TASK_MUTATION_TOOLS.has("fn_task_bypass_review")).toBe(false); + expect((COORDINATION_EXEMPT_TOOLS as readonly string[]).includes("fn_task_bypass_review")).toBe(false); + expect(READONLY_FN_TOOLS.has("fn_task_bypass_review")).toBe(false); + expect(classifyPermanentAgentToolCall("fn_task_bypass_review")).toEqual({ + category: "review_gate_bypass", + recognized: true, + }); + + for (const [permissionPolicy, disposition] of policyMatrix) { + expect(resolvePermanentAgentToolDecision({ + toolName: "fn_task_bypass_review", + args: {}, + gating: { permissionPolicy }, + })).toMatchObject({ + category: "review_gate_bypass", + disposition, + recognized: true, + }); + expect(evaluateAgentActionGate({ + agentId: "a1", + toolName: "fn_task_bypass_review", + args: {}, + permissionPolicy, + })).toMatchObject({ + category: "review_gate_bypass", + disposition, + }); + } + }); + + it("applies exact tool overrides consistently for governed review-gate bypass", () => { + const permissionPolicy: AgentPermissionPolicy = { + ...unrestrictedPolicy, + presetId: "custom", + rules: { + ...unrestrictedPolicy.rules, + review_gate_bypass: "allow", + }, + toolRules: { fn_task_bypass_review: "block" }, + }; + + expect(resolvePermanentAgentToolDecision({ + toolName: "fn_task_bypass_review", + args: {}, + gating: { permissionPolicy }, + })).toMatchObject({ category: "review_gate_bypass", disposition: "block", recognized: true }); + expect(evaluateAgentActionGate({ + agentId: "a1", + toolName: "fn_task_bypass_review", + args: {}, + permissionPolicy, + })).toMatchObject({ category: "review_gate_bypass", disposition: "block" }); + }); + it("applies exact tool overrides consistently for governed task creation", () => { const permissionPolicy: AgentPermissionPolicy = { ...unrestrictedPolicy, diff --git a/packages/engine/src/__tests__/permanent-agent-gating.test.ts b/packages/engine/src/__tests__/permanent-agent-gating.test.ts index 1cb314fe83..25c4d901b6 100644 --- a/packages/engine/src/__tests__/permanent-agent-gating.test.ts +++ b/packages/engine/src/__tests__/permanent-agent-gating.test.ts @@ -14,6 +14,7 @@ const unrestrictedPolicy: AgentPermissionPolicy = { command_execution: "allow", network_api: "allow", task_agent_mutation: "allow", + review_gate_bypass: "allow", }, }; @@ -25,6 +26,7 @@ const approvalRequiredPolicy: AgentPermissionPolicy = { command_execution: "require-approval", network_api: "require-approval", task_agent_mutation: "require-approval", + review_gate_bypass: "require-approval", }, }; @@ -36,6 +38,7 @@ const blockedPolicy: AgentPermissionPolicy = { command_execution: "block", network_api: "block", task_agent_mutation: "block", + review_gate_bypass: "block", }, }; @@ -224,6 +227,44 @@ describe("permanent-agent-gating", () => { expect(decision.recognized).toBe(true); }); + // FN-7728: fn_task_bypass_review must classify identically here (review_gate_bypass) as in + // agent-action-gate.ts's evaluateAgentActionGate, and must never fall through to task_agent_mutation + // or the unrecognized-tool "none"/require-approval fallback. + it("classifies fn_task_bypass_review as review_gate_bypass, distinct from task_agent_mutation", () => { + expect(classifyPermanentAgentToolCall("fn_task_bypass_review")).toEqual({ category: "review_gate_bypass", recognized: true }); + }); + + it.each([ + ["allow" as const, "allow" as const], + ["require-approval" as const, "require-approval" as const], + ["block" as const, "block" as const], + ])("honors review_gate_bypass disposition %s for fn_task_bypass_review", (ruleDisposition, expectedDisposition) => { + const decision = resolvePermanentAgentToolDecision({ + toolName: "fn_task_bypass_review", + gating: { + permissionPolicy: { + presetId: "custom", + rules: { review_gate_bypass: ruleDisposition }, + }, + }, + }); + expect(decision).toMatchObject({ category: "review_gate_bypass", recognized: true, disposition: expectedDisposition }); + }); + + it("lets an exact toolRules.fn_task_bypass_review override win over the review_gate_bypass category rule", () => { + const decision = resolvePermanentAgentToolDecision({ + toolName: "fn_task_bypass_review", + gating: { + permissionPolicy: { + presetId: "custom", + rules: { review_gate_bypass: "block" }, + toolRules: { fn_task_bypass_review: "allow" }, + }, + }, + }); + expect(decision).toMatchObject({ category: "review_gate_bypass", recognized: true, disposition: "allow" }); + }); + it("resolves disposition from policy for sensitive categories", () => { const blockDecision = resolvePermanentAgentToolDecision({ toolName: "write", diff --git a/packages/engine/src/agent-action-gate.ts b/packages/engine/src/agent-action-gate.ts index 6000545926..4333397c1b 100644 --- a/packages/engine/src/agent-action-gate.ts +++ b/packages/engine/src/agent-action-gate.ts @@ -10,6 +10,7 @@ import { COMMAND_EXECUTION_FN_TOOLS, COORDINATION_EXEMPT_TOOLS, READONLY_BUILTIN_TOOLS, + REVIEW_GATE_BYPASS_FN_TOOLS, classifyGitCommand, } from "./gating-classifications.js"; import { runtimeLog } from "./logger.js"; @@ -88,6 +89,7 @@ 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; +const REVIEW_GATE_BYPASS_TOOLS = REVIEW_GATE_BYPASS_FN_TOOLS; function normalizeArgs(args: unknown): Record { return args && typeof args === "object" ? (args as Record) : {}; @@ -157,6 +159,11 @@ export function evaluateAgentActionGate(params: { category = "command_execution"; operation = params.toolName; resourceType = "file"; + } else if (REVIEW_GATE_BYPASS_TOOLS.has(params.toolName)) { + // FNXC:ToolGovernance 2026-07-09-00:00: FN-7728 — fn_task_bypass_review is a merge-gate override, governed by its own review_gate_bypass category rather than task_agent_mutation so operators can dial bypass approval independently of ordinary task mutations. + category = "review_gate_bypass"; + operation = params.toolName; + resourceType = "task"; } else if (TASK_AGENT_MANAGEMENT_TOOLS.has(params.toolName)) { category = "task_agent_mutation"; operation = params.toolName; diff --git a/packages/engine/src/gating-classifications.ts b/packages/engine/src/gating-classifications.ts index c8dab3dc3d..118e9f62d9 100644 --- a/packages/engine/src/gating-classifications.ts +++ b/packages/engine/src/gating-classifications.ts @@ -61,8 +61,6 @@ const PERMANENT_TASK_AGENT_ONLY_TOOLS = [ "fn_task_pause", "fn_task_unpause", "fn_task_retry", - // FNXC:ReviewLaneBypass 2026-07-09-00:00: fn_task_bypass_review (FN-7720) is registered only on the CLI/pi-extension operator tool surface (packages/cli/src/extension.ts), the same registration path as fn_task_retry/fn_task_pause above — classify it here so the action gate never falls through to the unrecognized-tool exemption for this mutating, policy-gated escape hatch. - "fn_task_bypass_review", "fn_task_duplicate", "fn_task_archive", "fn_task_unarchive", @@ -105,6 +103,12 @@ export const PERMANENT_AGENT_TASK_MUTATION_TOOLS: ReadonlySet = new Set( ...PERMANENT_TASK_AGENT_ONLY_TOOLS, ]); +/** + * FNXC:ToolGovernance 2026-07-09-00:00: + * FN-7728 gives `fn_task_bypass_review` (FN-7720's merge-gate override, CLI/pi-extension operator-tool-only — never on executor/reviewer/triage agent tool lists) its own `review_gate_bypass` classification, distinct from `task_agent_mutation`, so operators can govern "who may bypass a failed review gate" independently of ordinary task mutations and it can never fall through to the unrecognized-tool exempt fallback. Both evaluateAgentActionGate (agent-action-gate.ts) and the permanent-agent gate (permanent-agent-gating.ts) must consume this same set so the two gate paths cannot drift. + */ +export const REVIEW_GATE_BYPASS_FN_TOOLS: ReadonlySet = new Set(["fn_task_bypass_review"]); + export const FILE_WRITE_DELETE_FN_TOOLS: ReadonlySet = new Set(["fn_task_attach"]); export const NETWORK_API_TOOLS: ReadonlySet = new Set([ diff --git a/packages/engine/src/permanent-agent-gating.ts b/packages/engine/src/permanent-agent-gating.ts index 3991b6a2e6..63287386f3 100644 --- a/packages/engine/src/permanent-agent-gating.ts +++ b/packages/engine/src/permanent-agent-gating.ts @@ -12,6 +12,7 @@ import { PERMANENT_AGENT_TASK_MUTATION_TOOLS, READONLY_BUILTIN_TOOLS, READONLY_FN_TOOLS, + REVIEW_GATE_BYPASS_FN_TOOLS, isGitWriteCommand, } from "./gating-classifications.js"; @@ -33,6 +34,8 @@ const FILE_WRITE_TOOLS = FILE_WRITE_BUILTIN_TOOLS; 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; +// FNXC:ToolGovernance 2026-07-09-00:00: FN-7728 — mirror agent-action-gate.ts's review_gate_bypass classification here so the permanent-agent gate resolves fn_task_bypass_review identically (no two-path drift). +const REVIEW_GATE_BYPASS_TOOLS = REVIEW_GATE_BYPASS_FN_TOOLS; function normalizeArgs(args: unknown): Record { return args && typeof args === "object" ? (args as Record) : {}; @@ -111,6 +114,9 @@ export function classifyPermanentAgentToolCall( if (READONLY_BUILTIN_TOOLS.has(toolName)) { return { category: "none", recognized: true }; } + if (REVIEW_GATE_BYPASS_TOOLS.has(toolName)) { + return { category: "review_gate_bypass", recognized: true }; + } if (TASK_AGENT_MUTATION_TOOLS.has(toolName)) { return { category: "task_agent_mutation", recognized: true }; }