feat(FN-4488): complete Step 3 — API custom permission rules
Fusion-Task-Id: FN-4488 Fusion-Task-Lineage: 3fc1ac43-490f-43f2-a27e-4fdceb64d9c4
This commit is contained in:
@@ -1983,6 +1983,97 @@ describe("Agent create/update routes", () => {
|
||||
expect(res.body.error).toContain("permissionPolicy must be an object");
|
||||
});
|
||||
|
||||
it("POST /api/agents accepts custom permissionPolicy rules", async () => {
|
||||
const res = await REQUEST(
|
||||
buildAgentApp(),
|
||||
"POST",
|
||||
"/api/agents",
|
||||
JSON.stringify({
|
||||
name: "Custom Policy Agent",
|
||||
role: "executor",
|
||||
permissionPolicy: {
|
||||
presetId: "custom",
|
||||
rules: {
|
||||
command_execution: "require-approval",
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.permissionPolicy).toMatchObject({
|
||||
presetId: "custom",
|
||||
rules: {
|
||||
command_execution: "require-approval",
|
||||
git_write: "allow",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("POST /api/agents rejects unknown custom permissionPolicy category", async () => {
|
||||
const res = await REQUEST(
|
||||
buildAgentApp(),
|
||||
"POST",
|
||||
"/api/agents",
|
||||
JSON.stringify({
|
||||
name: "Bad Category Agent",
|
||||
role: "executor",
|
||||
permissionPolicy: {
|
||||
presetId: "custom",
|
||||
rules: { nope: "allow" },
|
||||
},
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("unknown category");
|
||||
});
|
||||
|
||||
it("POST /api/agents rejects invalid custom permissionPolicy disposition", async () => {
|
||||
const res = await REQUEST(
|
||||
buildAgentApp(),
|
||||
"POST",
|
||||
"/api/agents",
|
||||
JSON.stringify({
|
||||
name: "Bad Disposition Agent",
|
||||
role: "executor",
|
||||
permissionPolicy: {
|
||||
presetId: "custom",
|
||||
rules: { git_write: "sometimes" },
|
||||
},
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("invalid disposition");
|
||||
});
|
||||
|
||||
it("PATCH /api/agents/:id updates permissionPolicy without clobbering other fields", async () => {
|
||||
const res = await REQUEST(
|
||||
buildAgentApp(),
|
||||
"PATCH",
|
||||
`/api/agents/${agentId}`,
|
||||
JSON.stringify({
|
||||
permissionPolicy: {
|
||||
presetId: "custom",
|
||||
rules: { command_execution: "require-approval" },
|
||||
},
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.id).toBe(agentId);
|
||||
expect(res.body.name).toBe("Initial Agent");
|
||||
expect(res.body.permissionPolicy).toMatchObject({
|
||||
presetId: "custom",
|
||||
rules: { command_execution: "require-approval" },
|
||||
});
|
||||
});
|
||||
|
||||
it("POST /api/agents normalizes policy rules from preset and ignores caller-supplied rules", async () => {
|
||||
const res = await REQUEST(
|
||||
buildAgentApp(),
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Request, Response } from "express";
|
||||
import type { Agent, AgentCapability, AgentUpdateInput, TaskStore } from "@fusion/core";
|
||||
import type { Agent, AgentCapability, AgentUpdateInput, TaskStore, AgentPermissionPolicyRules, AgentPermissionPolicyDisposition } from "@fusion/core";
|
||||
import {
|
||||
ApprovalRequestStore,
|
||||
AGENT_PERMISSION_POLICY_ACTION_CATEGORIES,
|
||||
getDefaultHeartbeatProcedurePath,
|
||||
isAgentPermissionPolicyPresetId,
|
||||
isEphemeralAgent,
|
||||
normalizeAgentPermissionPolicy,
|
||||
normalizeAgentPermissionPolicyFromPreset,
|
||||
} from "@fusion/core";
|
||||
import { ApiError, badRequest, notFound } from "../api-error.js";
|
||||
@@ -26,6 +28,41 @@ const AVATAR_MIME_TO_EXT: Record<string, string> = {
|
||||
"image/webp": "webp",
|
||||
};
|
||||
const MAX_AVATAR_BYTES = 2 * 1024 * 1024;
|
||||
const VALID_POLICY_DISPOSITIONS: readonly AgentPermissionPolicyDisposition[] = ["allow", "block", "require-approval"] as const;
|
||||
|
||||
function parsePermissionPolicyInput(input: unknown) {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
||||
throw badRequest("permissionPolicy must be an object");
|
||||
}
|
||||
const policy = input as { presetId?: unknown; rules?: unknown };
|
||||
if (typeof policy.presetId !== "string" || !isAgentPermissionPolicyPresetId(policy.presetId)) {
|
||||
throw badRequest("permissionPolicy.presetId must be one of: unrestricted, approval-required, locked-down, custom");
|
||||
}
|
||||
|
||||
if (policy.presetId !== "custom") {
|
||||
return normalizeAgentPermissionPolicyFromPreset(policy.presetId);
|
||||
}
|
||||
|
||||
if (policy.rules !== undefined && (typeof policy.rules !== "object" || policy.rules === null || Array.isArray(policy.rules))) {
|
||||
throw badRequest("permissionPolicy.rules must be an object");
|
||||
}
|
||||
|
||||
const customRules = (policy.rules ?? {}) as Record<string, unknown>;
|
||||
for (const category of Object.keys(customRules)) {
|
||||
if (!(AGENT_PERMISSION_POLICY_ACTION_CATEGORIES as readonly string[]).includes(category)) {
|
||||
throw badRequest(`permissionPolicy.rules contains unknown category: ${category}`);
|
||||
}
|
||||
const disposition = customRules[category];
|
||||
if (typeof disposition !== "string" || !(VALID_POLICY_DISPOSITIONS as readonly string[]).includes(disposition)) {
|
||||
throw badRequest(`permissionPolicy.rules.${category} has invalid disposition: ${String(disposition)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return normalizeAgentPermissionPolicy({
|
||||
presetId: "custom",
|
||||
rules: customRules as Partial<AgentPermissionPolicyRules>,
|
||||
});
|
||||
}
|
||||
|
||||
function isCompatibleDefaultHeartbeatPath(path: string | undefined, agent: Agent): boolean {
|
||||
const trimmed = path?.trim();
|
||||
@@ -153,13 +190,7 @@ export function registerAgentCoreListCreateRoutes(ctx: ApiRoutesContext, deps: A
|
||||
}
|
||||
let normalizedPermissionPolicy;
|
||||
if (permissionPolicy !== undefined && permissionPolicy !== null) {
|
||||
if (typeof permissionPolicy !== "object" || Array.isArray(permissionPolicy)) {
|
||||
throw badRequest("permissionPolicy must be an object");
|
||||
}
|
||||
if (typeof permissionPolicy.presetId !== "string" || !isAgentPermissionPolicyPresetId(permissionPolicy.presetId)) {
|
||||
throw badRequest("permissionPolicy.presetId must be one of: unrestricted, approval-required, locked-down");
|
||||
}
|
||||
normalizedPermissionPolicy = normalizeAgentPermissionPolicyFromPreset(permissionPolicy.presetId);
|
||||
normalizedPermissionPolicy = parsePermissionPolicyInput(permissionPolicy);
|
||||
}
|
||||
if (!validateAgentInstructionsPayload(instructionsPath, instructionsText)) {
|
||||
return;
|
||||
@@ -609,13 +640,7 @@ export function registerAgentCoreRoutes(ctx: ApiRoutesContext, deps: AgentCoreRo
|
||||
|
||||
if ("permissionPolicy" in body) {
|
||||
if (body.permissionPolicy !== null) {
|
||||
if (typeof body.permissionPolicy !== "object" || Array.isArray(body.permissionPolicy)) {
|
||||
throw badRequest("permissionPolicy must be an object");
|
||||
}
|
||||
if (typeof body.permissionPolicy.presetId !== "string" || !isAgentPermissionPolicyPresetId(body.permissionPolicy.presetId)) {
|
||||
throw badRequest("permissionPolicy.presetId must be one of: unrestricted, approval-required, locked-down");
|
||||
}
|
||||
updates.permissionPolicy = normalizeAgentPermissionPolicyFromPreset(body.permissionPolicy.presetId);
|
||||
updates.permissionPolicy = parsePermissionPolicyInput(body.permissionPolicy);
|
||||
} else {
|
||||
updates.permissionPolicy = undefined;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user