FN-7728: add review_gate_bypass RBAC category for fn_task_bypass_review

Introduces a dedicated review_gate_bypass permission-policy category so operators can govern who may bypass a failed pre-merge review gate independently of ordinary task-mutation permissions.

- Add review_gate_bypass as a new sensitive-action category in packages/core/src/types.ts, distinct from task_agent_mutation, with fn_task_bypass_review as its sole example tool
- Default review_gate_bypass to require-approval even under the unrestricted preset (stricter than the preset's uniform disposition) in packages/core/src/agent-permission-policy.ts, while approval-required/locked-down already cover it uniformly
- Classify fn_task_bypass_review into the new category via a shared REVIEW_GATE_BYPASS_FN_TOOLS set in packages/engine/src/gating-classifications.ts, consumed identically by both evaluateAgentActionGate and the permanent-agent gate to prevent path drift
- Render the new category as its own row in the dashboard's project-default and per-agent AgentPermissionPolicyEditor, surfaced in AgentDetailView
- Update docs/settings-reference.md and add unit tests across core/engine/dashboard covering the new category, its stricter default, and gate-classification alignment
- Add changeset (@runfusion/fusion: minor) documenting the new operator-facing permission category

Files changed:
 .changeset/fn-7728-review-gate-bypass-rbac.md      |  7 +++
 docs/settings-reference.md                         |  8 +--
 .../src/__tests__/agent-permission-policy.test.ts  | 54 ++++++++++++++++++-
 packages/core/src/agent-permission-policy.ts       | 12 ++++-
 packages/core/src/types.ts                         |  8 +++
 .../dashboard/app/components/AgentDetailView.tsx   |  2 +
 .../app/components/AgentPermissionPolicyEditor.tsx |  8 +++
 .../__tests__/AgentPermissionPolicyEditor.test.tsx |  5 ++
 .../engine/src/__tests__/agent-action-gate.test.ts | 45 ++++++++++++++++
 .../src/__tests__/gating-classifications.test.ts   | 63 ++++++++++++++++++++++
 .../src/__tests__/permanent-agent-gating.test.ts   | 41 ++++++++++++++
 packages/engine/src/agent-action-gate.ts           |  7 +++
 packages/engine/src/gating-classifications.ts      |  8 ++-
 packages/engine/src/permanent-agent-gating.ts      |  6 +++
 14 files changed, 266 insertions(+), 8 deletions(-)

Fusion-Task-Id: FN-7728

Fusion-Task-Lineage: 100c8563-2897-4d53-9546-5c2faa6ab7d8

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-09 09:11:45 -07:00
parent 1fc615d0da
commit d44dbaab15
14 changed files with 266 additions and 8 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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");
});
});
});

View File

@@ -30,7 +30,7 @@ const BUILT_IN_PRESETS: Record<AgentPermissionPolicyPresetId, BuiltInAgentPermis
id: "unrestricted",
name: "Unrestricted",
description: "Allows all runtime action categories (legacy-compatible default).",
rules: buildRules("allow"),
rules: withReviewGateBypassOverride(buildRules("allow")),
},
"approval-required": {
id: "approval-required",
@@ -48,7 +48,7 @@ const BUILT_IN_PRESETS: Record<AgentPermissionPolicyPresetId, BuiltInAgentPermis
id: "custom",
name: "Custom",
description: "Category-level custom overrides.",
rules: buildRules("allow"),
rules: withReviewGateBypassOverride(buildRules("allow")),
},
};
@@ -59,6 +59,14 @@ function buildRules(disposition: AgentPermissionPolicyDisposition): AgentPermiss
}, {} as AgentPermissionPolicyRules);
}
/**
* FNXC:ToolPermissions 2026-07-09-00:00:
* FN-7728 — `review_gate_bypass` (governing the `fn_task_bypass_review` merge-gate override) intentionally diverges from the uniform per-preset disposition: even the `unrestricted` preset (and the `unrestricted`-seeded `custom` base) must never silently allow a review-gate bypass by default, since that is a stricter security posture than ordinary task mutation. `approval-required` (already `require-approval`) and `locked-down` (already `block`, stricter still) need no override — only the two presets whose uniform disposition would otherwise be `allow` are patched here, post-`buildRules`, so the single-disposition preset builder stays simple for every other category.
*/
function withReviewGateBypassOverride(rules: AgentPermissionPolicyRules): AgentPermissionPolicyRules {
return { ...rules, review_gate_bypass: "require-approval" };
}
export function isValidAgentPermissionPolicyDisposition(value: unknown): value is AgentPermissionPolicyDisposition {
return typeof value === "string" && (VALID_DISPOSITIONS as readonly string[]).includes(value);
}

View File

@@ -6678,12 +6678,17 @@ export type AgentPermission = (typeof AGENT_PERMISSIONS)[number];
* `none` is a classifier-only result for positively-recognized read-only actions.
* It is never stored as a policy rule key.
*/
/**
* FNXC:ToolPermissions 2026-07-09-00:00:
* FN-7728 adds `review_gate_bypass` as a first-class sensitive action category distinct from `task_agent_mutation`. It governs merge-gate override tools (e.g. `fn_task_bypass_review`, delivered by FN-7720) so operators can independently allow/require-approval/block "who may bypass a failed review gate" without touching ordinary task-mutation policy. It defaults to a stricter disposition than the uniform preset default (see agent-permission-policy.ts) and is resolved identically by both evaluateAgentActionGate and the permanent-agent gate via the shared gating-classifications.ts source.
*/
export const PERMANENT_AGENT_ACTION_CATEGORIES = [
"git_write",
"file_write_delete",
"command_execution",
"network_api",
"task_agent_mutation",
"review_gate_bypass",
"none",
] as const;
@@ -6700,6 +6705,7 @@ export const AGENT_PERMISSION_POLICY_ACTION_CATEGORIES: readonly PermanentAgentS
"command_execution",
"network_api",
"task_agent_mutation",
"review_gate_bypass",
] as const;
export const AGENT_PERMISSION_POLICY_CATEGORY_TOOL_EXAMPLES: Record<
@@ -6734,6 +6740,8 @@ export const AGENT_PERMISSION_POLICY_CATEGORY_TOOL_EXAMPLES: Record<
"fn_task_promote",
"fn_task_refine",
],
/* FNXC:ToolPermissions 2026-07-09-00:00: FN-7728 — review_gate_bypass governs merge-gate override tools as a distinct, more-restricted permission from ordinary task mutation. fn_task_bypass_review (FN-7720) is CLI/pi-extension operator-tool-only; it is never exposed to executor/reviewer/triage agent tool lists. */
review_gate_bypass: ["fn_task_bypass_review"],
};
export const AGENT_PERMISSION_POLICY_EXEMPT_TOOL_EXAMPLES: readonly string[] = [

View File

@@ -4806,6 +4806,8 @@ function ConfigTab({
command_execution: projectDefaultPermissionPolicy?.rules?.command_execution ?? "allow",
network_api: projectDefaultPermissionPolicy?.rules?.network_api ?? "allow",
task_agent_mutation: projectDefaultPermissionPolicy?.rules?.task_agent_mutation ?? "allow",
// FNXC:ToolPermissions 2026-07-09-00:00: FN-7728 — review_gate_bypass defaults stricter than the other categories (require-approval, not allow) to mirror the unrestricted preset's targeted override for this merge-gate bypass category.
review_gate_bypass: projectDefaultPermissionPolicy?.rules?.review_gate_bypass ?? "require-approval",
},
})}
>

View File

@@ -46,6 +46,8 @@ function getCategoryLabels(t: TFunction<"app">): Record<string, { label: string;
command_execution: { label: t("agentPolicy.category.commandExecution.label", "Command execution"), description: t("agentPolicy.category.commandExecution.description", "Runs shell commands and scripts.") },
network_api: { label: t("agentPolicy.category.networkApi.label", "Network/API"), description: t("agentPolicy.category.networkApi.description", "Outbound network or API access.") },
task_agent_mutation: { label: t("agentPolicy.category.taskAgentMutation.label", "Task/agent mutation"), description: t("agentPolicy.category.taskAgentMutation.description", "Task state changes, delegation, or agent lifecycle actions.") },
// FNXC:ToolPermissions 2026-07-09-00:00: FN-7728 — review_gate_bypass is a dedicated, more-restricted category for the merge-gate override tool, distinct from ordinary task_agent_mutation.
review_gate_bypass: { label: t("agentPolicy.category.reviewGateBypass.label", "Review gate bypass"), description: t("agentPolicy.category.reviewGateBypass.description", "Bypassing a failed pre-merge review step to force a card past the review lane.") },
};
}
@@ -65,6 +67,8 @@ function buildAllowRules(): AgentPermissionPolicyRules {
command_execution: "allow",
network_api: "allow",
task_agent_mutation: "allow",
// FNXC:ToolPermissions 2026-07-09-00:00: FN-7728 — review_gate_bypass mirrors the core unrestricted preset's targeted override (require-approval, not allow) so the editor's derived preset detection stays in sync with agent-permission-policy.ts.
review_gate_bypass: "require-approval",
};
}
@@ -75,6 +79,8 @@ const PRESET_RULES: Record<"unrestricted" | "approval-required" | "locked-down",
command_execution: "allow",
network_api: "allow",
task_agent_mutation: "allow",
// FNXC:ToolPermissions 2026-07-09-00:00: FN-7728 — review_gate_bypass stays require-approval even under the unrestricted preset (see agent-permission-policy.ts withReviewGateBypassOverride).
review_gate_bypass: "require-approval",
},
"approval-required": {
git_write: "require-approval",
@@ -82,6 +88,7 @@ const PRESET_RULES: Record<"unrestricted" | "approval-required" | "locked-down",
command_execution: "require-approval",
network_api: "require-approval",
task_agent_mutation: "require-approval",
review_gate_bypass: "require-approval",
},
"locked-down": {
git_write: "block",
@@ -89,6 +96,7 @@ const PRESET_RULES: Record<"unrestricted" | "approval-required" | "locked-down",
command_execution: "block",
network_api: "block",
task_agent_mutation: "block",
review_gate_bypass: "block",
},
};

View File

@@ -19,6 +19,7 @@ describe("AgentPermissionPolicyEditor", () => {
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={() => {}}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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",

View File

@@ -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<string, unknown> {
return args && typeof args === "object" ? (args as Record<string, unknown>) : {};
@@ -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;

View File

@@ -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<string> = 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<string> = new Set(["fn_task_bypass_review"]);
export const FILE_WRITE_DELETE_FN_TOOLS: ReadonlySet<string> = new Set(["fn_task_attach"]);
export const NETWORK_API_TOOLS: ReadonlySet<string> = new Set([

View File

@@ -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<string, unknown> {
return args && typeof args === "object" ? (args as Record<string, unknown>) : {};
@@ -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 };
}