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:
7
.changeset/fn-7728-review-gate-bypass-rbac.md
Normal file
7
.changeset/fn-7728-review-gate-bypass-rbac.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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={() => {}}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user