feat(FN-4488): complete Step 1 — core permission policy model

Fusion-Task-Id: FN-4488
Fusion-Task-Lineage: 3fc1ac43-490f-43f2-a27e-4fdceb64d9c4
This commit is contained in:
Fusion
2026-05-14 12:28:30 -07:00
committed by gsxdsm
parent baadd0254b
commit 2aa5805850
7 changed files with 170 additions and 9 deletions

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import {
normalizeAgentPermissionPolicy,
resolveEffectiveAgentPermissionPolicy,
resolveAgentPermissionPolicyPreset,
} from "../agent-permission-policy.js";
describe("agent permission policy resolution", () => {
it("returns built-in preset rules unchanged", () => {
const policy = resolveEffectiveAgentPermissionPolicy({
presetId: "locked-down",
rules: resolveAgentPermissionPolicyPreset("unrestricted").rules,
});
expect(policy.presetId).toBe("locked-down");
expect(policy.rules).toEqual(resolveAgentPermissionPolicyPreset("locked-down").rules);
});
it("merges custom preset overrides over unrestricted seed", () => {
const policy = normalizeAgentPermissionPolicy({
presetId: "custom",
rules: { command_execution: "require-approval" },
});
expect(policy.rules.command_execution).toBe("require-approval");
expect(policy.rules.git_write).toBe("allow");
});
it("uses project default when agent policy is undefined", () => {
const policy = resolveEffectiveAgentPermissionPolicy(undefined, {
rules: { network_api: "block" },
});
expect(policy.presetId).toBe("custom");
expect(policy.rules.network_api).toBe("block");
expect(policy.rules.git_write).toBe("allow");
});
it("keeps per-agent custom rule over project default", () => {
const policy = resolveEffectiveAgentPermissionPolicy(
{
presetId: "custom",
rules: { command_execution: "allow" },
},
{ rules: { command_execution: "require-approval" } },
);
expect(policy.rules.command_execution).toBe("allow");
});
it("rejects invalid disposition values", () => {
expect(() =>
normalizeAgentPermissionPolicy({
presetId: "custom",
rules: { git_write: "nope" as never },
}),
).toThrow(/Invalid permission policy disposition/);
});
});

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_PROJECT_SETTINGS, PROJECT_SETTINGS_KEYS } from "../settings-schema.js";
describe("defaultAgentPermissionPolicy settings schema contract", () => {
it("includes defaultAgentPermissionPolicy key", () => {
expect(PROJECT_SETTINGS_KEYS).toContain("defaultAgentPermissionPolicy");
});
it("defaults to undefined", () => {
expect(DEFAULT_PROJECT_SETTINGS.defaultAgentPermissionPolicy).toBeUndefined();
});
it("supports partial category rules", () => {
const setting = {
rules: {
command_execution: "require-approval",
},
};
expect(setting.rules.command_execution).toBe("require-approval");
});
});

View File

@@ -1,5 +1,6 @@
import type {
AgentPermissionPolicy,
AgentPermissionPolicyDisposition,
AgentPermissionPolicyPresetId,
AgentPermissionPolicyRules,
} from "./types.js";
@@ -14,6 +15,8 @@ export interface BuiltInAgentPermissionPolicyPreset {
rules: AgentPermissionPolicyRules;
}
const VALID_DISPOSITIONS: readonly AgentPermissionPolicyDisposition[] = ["allow", "block", "require-approval"] as const;
const BUILT_IN_PRESETS: Record<AgentPermissionPolicyPresetId, BuiltInAgentPermissionPolicyPreset> = {
unrestricted: {
id: "unrestricted",
@@ -33,15 +36,25 @@ const BUILT_IN_PRESETS: Record<AgentPermissionPolicyPresetId, BuiltInAgentPermis
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: AgentPermissionPolicyRules[(typeof AGENT_PERMISSION_POLICY_ACTION_CATEGORIES)[number]]): AgentPermissionPolicyRules {
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);
}
@@ -63,18 +76,72 @@ export function resolveAgentPermissionPolicyPreset(
export function normalizeAgentPermissionPolicyFromPreset(
presetId: AgentPermissionPolicyPresetId,
): AgentPermissionPolicy {
const preset = resolveAgentPermissionPolicyPreset(presetId);
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: preset.id,
rules: { ...preset.rules },
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;
}
return normalizeAgentPermissionPolicy({
presetId: "custom",
rules: { ...seed, ...merged },
});
}
export function resolveEffectiveAgentPermissionPolicy(
policy: AgentPermissionPolicy | undefined,
projectDefault?: { rules?: Partial<AgentPermissionPolicyRules> },
): AgentPermissionPolicy {
if (!policy || !isAgentPermissionPolicyPresetId(policy.presetId)) {
return normalizeAgentPermissionPolicyFromPreset(DEFAULT_AGENT_PERMISSION_POLICY_PRESET_ID);
return normalizeProjectDefaultPolicy(projectDefault);
}
return normalizeAgentPermissionPolicyFromPreset(policy.presetId);
return normalizeAgentPermissionPolicy({
presetId: policy.presetId,
rules: policy.rules,
});
}

View File

@@ -63,7 +63,7 @@ interface CheckoutLeaseContext {
}
import { computeAccessState } from "./agent-permissions.js";
import { canAgentTakeImplementationTask, canAgentTakeImplementationTaskForExplicitRouting, formatRoleMismatchReason } from "./agent-role-policy.js";
import { resolveEffectiveAgentPermissionPolicy } from "./agent-permission-policy.js";
import { normalizeAgentPermissionPolicy, resolveEffectiveAgentPermissionPolicy } from "./agent-permission-policy.js";
import { Database } from "./db.js";
import { createAgentRunSnapshot, createAgentSnapshot, validateSnapshotEnvelope, type AgentRunSnapshot, type AgentSnapshot } from "./shared-mesh-state.js";
@@ -1078,6 +1078,11 @@ export class AgentStore extends EventEmitter {
const beforeSnapshot = agentToConfigSnapshot(agent);
const updatedAt = new Date().toISOString();
const normalizedUpdatedPermissionPolicy =
"permissionPolicy" in updates && updates.permissionPolicy !== undefined
? normalizeAgentPermissionPolicy(updates.permissionPolicy)
: updates.permissionPolicy;
const updated: Agent = {
...agent,
name: nextName ?? agent.name,
@@ -1091,7 +1096,7 @@ export class AgentStore extends EventEmitter {
...("runtimeConfig" in updates && { runtimeConfig: updates.runtimeConfig }),
...("pauseReason" in updates && { pauseReason: updates.pauseReason }),
...("permissions" in updates && { permissions: updates.permissions }),
...("permissionPolicy" in updates && { permissionPolicy: updates.permissionPolicy }),
...("permissionPolicy" in updates && { permissionPolicy: normalizedUpdatedPermissionPolicy }),
...("lastError" in updates && { lastError: updates.lastError }),
...("totalInputTokens" in updates && { totalInputTokens: updates.totalInputTokens }),
...("totalOutputTokens" in updates && { totalOutputTokens: updates.totalOutputTokens }),

View File

@@ -64,6 +64,7 @@ export {
getBuiltInAgentPermissionPolicyPresets,
resolveAgentPermissionPolicyPreset,
normalizeAgentPermissionPolicyFromPreset,
normalizeAgentPermissionPolicy,
resolveEffectiveAgentPermissionPolicy,
isAgentPermissionPolicyPresetId,
} from "./agent-permission-policy.js";

View File

@@ -237,6 +237,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
requirePlanApproval: false,
ephemeralAgentsEnabled: true,
agentProvisioning: {},
defaultAgentPermissionPolicy: undefined,
specStalenessEnabled: false,
specStalenessMaxAgeMs: 6 * 60 * 60 * 1000,
taskStuckTimeoutMs: 600_000,

View File

@@ -2470,6 +2470,12 @@ export interface ProjectSettings {
trustedAgentIds?: string[];
alwaysApproveDelete?: boolean;
};
/** Project default runtime permission-policy overrides for permanent agents.
* Rules are a partial map of category -> disposition (`allow` | `block` | `require-approval`).
* Missing categories inherit the built-in `unrestricted` seed (`allow`). */
defaultAgentPermissionPolicy?: {
rules: Partial<AgentPermissionPolicyRules>;
};
/** When true, enforces that task specifications (PROMPT.md) are refreshed if they
* become stale. Stale specs are detected based on specStalenessMaxAgeMs.
* Default: false. */
@@ -4511,7 +4517,7 @@ export interface PermanentAgentGatingContext {
}
/** Built-in permission policy preset identifiers for permanent agents. */
export const AGENT_PERMISSION_POLICY_PRESET_IDS = ["unrestricted", "approval-required", "locked-down"] as const;
export const AGENT_PERMISSION_POLICY_PRESET_IDS = ["unrestricted", "approval-required", "locked-down", "custom"] as const;
/** A single built-in permission policy preset identifier. */
export type AgentPermissionPolicyPresetId = (typeof AGENT_PERMISSION_POLICY_PRESET_IDS)[number];