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:
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -64,6 +64,7 @@ export {
|
||||
getBuiltInAgentPermissionPolicyPresets,
|
||||
resolveAgentPermissionPolicyPreset,
|
||||
normalizeAgentPermissionPolicyFromPreset,
|
||||
normalizeAgentPermissionPolicy,
|
||||
resolveEffectiveAgentPermissionPolicy,
|
||||
isAgentPermissionPolicyPresetId,
|
||||
} from "./agent-permission-policy.js";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user