Files
fusion/packages/core/src/agent-permission-policy.ts
2026-06-05 00:57:49 -07:00

218 lines
7.6 KiB
TypeScript

import type {
AgentPermissionPolicy,
AgentPermissionPolicyDisposition,
AgentPermissionPolicyPresetId,
AgentPermissionPolicyRules,
} from "./types.js";
import {
AGENT_PERMISSION_POLICY_ACTION_CATEGORIES,
AGENT_PERMISSION_POLICY_CATEGORY_TOOL_EXAMPLES,
AGENT_PERMISSION_POLICY_EXEMPT_TOOL_EXAMPLES,
AGENT_PERMISSION_POLICY_PRESET_IDS,
} from "./types.js";
export const DEFAULT_AGENT_PERMISSION_POLICY_PRESET_ID: AgentPermissionPolicyPresetId = "unrestricted";
export { AGENT_PERMISSION_POLICY_CATEGORY_TOOL_EXAMPLES, AGENT_PERMISSION_POLICY_EXEMPT_TOOL_EXAMPLES };
export interface BuiltInAgentPermissionPolicyPreset {
id: AgentPermissionPolicyPresetId;
name: string;
description: string;
rules: AgentPermissionPolicyRules;
}
const VALID_DISPOSITIONS: readonly AgentPermissionPolicyDisposition[] = ["allow", "block", "require-approval"] as const;
const BUILT_IN_PRESETS: Record<AgentPermissionPolicyPresetId, BuiltInAgentPermissionPolicyPreset> = {
unrestricted: {
id: "unrestricted",
name: "Unrestricted",
description: "Allows all runtime action categories (legacy-compatible default).",
rules: buildRules("allow"),
},
"approval-required": {
id: "approval-required",
name: "Approval Required",
description: "Requires approval for all runtime action categories.",
rules: buildRules("require-approval"),
},
"locked-down": {
id: "locked-down",
name: "Locked Down",
description: "Blocks all runtime action categories.",
rules: buildRules("block"),
},
custom: {
id: "custom",
name: "Custom",
description: "Category-level custom overrides.",
rules: buildRules("allow"),
},
};
function buildRules(disposition: AgentPermissionPolicyDisposition): AgentPermissionPolicyRules {
return AGENT_PERMISSION_POLICY_ACTION_CATEGORIES.reduce((acc, category) => {
acc[category] = disposition;
return acc;
}, {} as AgentPermissionPolicyRules);
}
function isValidDisposition(value: unknown): value is AgentPermissionPolicyDisposition {
return typeof value === "string" && (VALID_DISPOSITIONS as readonly string[]).includes(value);
}
export function isAgentPermissionPolicyPresetId(value: unknown): value is AgentPermissionPolicyPresetId {
return typeof value === "string" && (AGENT_PERMISSION_POLICY_PRESET_IDS as readonly string[]).includes(value);
}
export function getBuiltInAgentPermissionPolicyPresets(): BuiltInAgentPermissionPolicyPreset[] {
return AGENT_PERMISSION_POLICY_PRESET_IDS.map((id) => resolveAgentPermissionPolicyPreset(id));
}
export function resolveAgentPermissionPolicyPreset(
presetId: AgentPermissionPolicyPresetId,
): BuiltInAgentPermissionPolicyPreset {
const preset = BUILT_IN_PRESETS[presetId];
return {
...preset,
rules: { ...preset.rules },
};
}
export function normalizeAgentPermissionPolicyFromPreset(
presetId: AgentPermissionPolicyPresetId,
): AgentPermissionPolicy {
return normalizeAgentPermissionPolicy({ presetId });
}
export function normalizeAgentPermissionPolicy(input: {
presetId: AgentPermissionPolicyPresetId;
rules?: Partial<AgentPermissionPolicyRules>;
}): AgentPermissionPolicy {
const preset = resolveAgentPermissionPolicyPreset(input.presetId);
if (input.presetId !== "custom") {
return {
presetId: input.presetId,
rules: { ...preset.rules },
};
}
const rules: AgentPermissionPolicyRules = { ...resolveAgentPermissionPolicyPreset("unrestricted").rules };
for (const category of AGENT_PERMISSION_POLICY_ACTION_CATEGORIES) {
const nextValue = input.rules?.[category];
if (nextValue === undefined) {
continue;
}
if (!isValidDisposition(nextValue)) {
throw new Error(`Invalid permission policy disposition for ${category}: ${String(nextValue)}`);
}
rules[category] = nextValue;
}
return {
presetId: "custom",
rules,
};
}
function normalizeProjectDefaultPolicy(
projectDefault: { rules?: Partial<AgentPermissionPolicyRules> } | undefined,
): AgentPermissionPolicy {
const seed = resolveAgentPermissionPolicyPreset(DEFAULT_AGENT_PERMISSION_POLICY_PRESET_ID).rules;
const merged: Partial<AgentPermissionPolicyRules> = {};
for (const category of AGENT_PERMISSION_POLICY_ACTION_CATEGORIES) {
const nextValue = projectDefault?.rules?.[category];
if (nextValue === undefined) {
continue;
}
if (!isValidDisposition(nextValue)) {
throw new Error(`Invalid project default permission policy disposition for ${category}: ${String(nextValue)}`);
}
merged[category] = nextValue;
}
if (Object.keys(merged).length === 0) {
return normalizeAgentPermissionPolicyFromPreset(DEFAULT_AGENT_PERMISSION_POLICY_PRESET_ID);
}
return normalizeAgentPermissionPolicy({
presetId: "custom",
rules: { ...seed, ...merged },
});
}
export function resolveEffectiveAgentPermissionPolicy(
policy: AgentPermissionPolicy | undefined,
projectDefault?: { rules?: Partial<AgentPermissionPolicyRules> },
): AgentPermissionPolicy {
if (!policy || !isAgentPermissionPolicyPresetId(policy.presetId)) {
return normalizeProjectDefaultPolicy(projectDefault);
}
return normalizeAgentPermissionPolicy({
presetId: policy.presetId,
rules: policy.rules,
});
}
/**
* Disposition strictness rank for column-agent policy-escalation comparison
* (R13). A LOWER rank is *broader* (more privileged): `allow` lets an action
* through unconditionally, `require-approval` gates it, `block` denies it. An
* agent whose policy is broader than the project default on ANY action category
* is an escalation that must be explicitly confirmed at save time.
*/
const DISPOSITION_BREADTH_RANK: Record<AgentPermissionPolicyDisposition, number> = {
allow: 0,
"require-approval": 1,
block: 2,
};
/**
* The broadest (most-privileged) rank — used as the fallback when a category is
* absent from a policy's rules map. Treating a missing category as the broadest
* possible disposition (`allow`) ensures an absent key can never silently
* *suppress* a genuine escalation: the comparison only flags when the agent is
* at least as broad as the default, so an unknown agent-side category errs
* toward flagging, and an unknown default-side category errs toward the most
* permissive default (the conservative direction for escalation detection).
*/
const BROADEST_RANK = DISPOSITION_BREADTH_RANK.allow;
function dispositionRank(
rules: AgentPermissionPolicyRules,
category: (typeof AGENT_PERMISSION_POLICY_ACTION_CATEGORIES)[number],
): number {
const disposition = rules[category];
if (disposition === undefined) {
// An absent category must not suppress escalation. Treat the agent side as
// broadest (most privileged) so a missing key never narrows the comparison.
return BROADEST_RANK;
}
return DISPOSITION_BREADTH_RANK[disposition];
}
/**
* True when `agentPolicy`'s effective policy is broader (more privileged) than
* the project `defaultPolicy` on at least one action category (R13).
*
* Both arguments should already be resolved via
* {@link resolveEffectiveAgentPermissionPolicy}, which fills every category. The
* defensive per-category handling here guards against a partial/custom rules
* map slipping through with a missing category key — an absent key must never
* silently suppress a genuine escalation.
*/
export function isPolicyBroaderThanDefault(
agentPolicy: AgentPermissionPolicy,
defaultPolicy: AgentPermissionPolicy,
): boolean {
for (const category of AGENT_PERMISSION_POLICY_ACTION_CATEGORIES) {
const agentRank = dispositionRank(agentPolicy.rules, category);
const defaultRank = dispositionRank(defaultPolicy.rules, category);
if (agentRank < defaultRank) return true;
}
return false;
}