feat(FN-1122): add agent permission access APIs
- Define canonical agent permission types and export permission helpers from @fusion/core - Add permission normalization and access-state computation with role defaults plus explicit grants - Extend AgentStore with getAccessState and add unit tests for permission logic and store behavior - Add dashboard routes for GET /api/agents/:id/access and PATCH /api/agents/:id/permissions with request validation and serialization - Add a minor changeset for @gsxdsm/fusion documenting the new agent permission endpoints
This commit is contained in:
181
packages/core/src/agent-permissions.test.ts
Normal file
181
packages/core/src/agent-permissions.test.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeAccessState,
|
||||
isValidPermission,
|
||||
normalizePermissions,
|
||||
} from "./agent-permissions.js";
|
||||
import { AGENT_PERMISSIONS } from "./types.js";
|
||||
import type { Agent, AgentCapability, AgentPermission } from "./types.js";
|
||||
|
||||
function makeAgent(role: AgentCapability, permissions?: Record<string, boolean>): Agent {
|
||||
return {
|
||||
id: "agent-001",
|
||||
name: "Test Agent",
|
||||
role,
|
||||
state: "idle",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {},
|
||||
permissions,
|
||||
};
|
||||
}
|
||||
|
||||
describe("normalizePermissions", () => {
|
||||
it("returns empty set for empty input", () => {
|
||||
expect(normalizePermissions({})).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("returns only valid permission keys", () => {
|
||||
const result = normalizePermissions({
|
||||
"tasks:execute": true,
|
||||
"foo:bar": true,
|
||||
"budget:spend": true,
|
||||
"agents:view": true,
|
||||
});
|
||||
|
||||
expect(result).toEqual(new Set<AgentPermission>(["tasks:execute", "agents:view"]));
|
||||
});
|
||||
|
||||
it("returns only granted permissions", () => {
|
||||
const result = normalizePermissions({
|
||||
"tasks:execute": true,
|
||||
"tasks:assign": false,
|
||||
"agents:view": false,
|
||||
});
|
||||
|
||||
expect(result).toEqual(new Set<AgentPermission>(["tasks:execute"]));
|
||||
});
|
||||
|
||||
it("returns all valid permissions when all are true", () => {
|
||||
const allPermissions = Object.fromEntries(
|
||||
AGENT_PERMISSIONS.map((permission) => [permission, true]),
|
||||
);
|
||||
|
||||
const result = normalizePermissions(allPermissions);
|
||||
|
||||
expect(result).toEqual(new Set<AgentPermission>(AGENT_PERMISSIONS));
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidPermission", () => {
|
||||
it("returns true for every entry in AGENT_PERMISSIONS", () => {
|
||||
for (const permission of AGENT_PERMISSIONS) {
|
||||
expect(isValidPermission(permission)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns false for invalid strings", () => {
|
||||
expect(isValidPermission("budget:spend")).toBe(false);
|
||||
expect(isValidPermission("invalid")).toBe(false);
|
||||
expect(isValidPermission("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeAccessState", () => {
|
||||
it("executor role gets execute by default and cannot assign tasks", () => {
|
||||
const state = computeAccessState(makeAgent("executor"));
|
||||
|
||||
expect(state.canExecuteTasks).toBe(true);
|
||||
expect(state.canAssignTasks).toBe(false);
|
||||
expect(state.taskAssignSource).toBe("denied");
|
||||
expect(state.resolvedPermissions.has("tasks:execute")).toBe(true);
|
||||
});
|
||||
|
||||
it("scheduler role gets assign by default", () => {
|
||||
const state = computeAccessState(makeAgent("scheduler"));
|
||||
|
||||
expect(state.canAssignTasks).toBe(true);
|
||||
expect(state.taskAssignSource).toBe("role_default");
|
||||
expect(state.roleDefaultPermissions.has("tasks:assign")).toBe(true);
|
||||
});
|
||||
|
||||
it("custom role has no defaults", () => {
|
||||
const state = computeAccessState(makeAgent("custom"));
|
||||
|
||||
expect(state.canAssignTasks).toBe(false);
|
||||
expect(state.canCreateAgents).toBe(false);
|
||||
expect(state.canExecuteTasks).toBe(false);
|
||||
expect(state.canReviewTasks).toBe(false);
|
||||
expect(state.canMergeTasks).toBe(false);
|
||||
expect(state.canDeleteAgents).toBe(false);
|
||||
expect(state.canManageMissions).toBe(false);
|
||||
expect(state.canSendMessages).toBe(false);
|
||||
expect(state.resolvedPermissions.size).toBe(0);
|
||||
});
|
||||
|
||||
it("explicit grant enables assignment and reports explicit_grant source", () => {
|
||||
const state = computeAccessState(
|
||||
makeAgent("executor", { "tasks:assign": true }),
|
||||
);
|
||||
|
||||
expect(state.canAssignTasks).toBe(true);
|
||||
expect(state.taskAssignSource).toBe("explicit_grant");
|
||||
});
|
||||
|
||||
it("explicit false does not remove role defaults", () => {
|
||||
const state = computeAccessState(
|
||||
makeAgent("executor", { "tasks:execute": false }),
|
||||
);
|
||||
|
||||
expect(state.canExecuteTasks).toBe(true);
|
||||
expect(state.resolvedPermissions.has("tasks:execute")).toBe(true);
|
||||
expect(state.explicitPermissions.has("tasks:execute")).toBe(false);
|
||||
});
|
||||
|
||||
it("separates explicit and role-default permissions in mixed cases", () => {
|
||||
const state = computeAccessState(
|
||||
makeAgent("engineer", {
|
||||
"tasks:assign": true,
|
||||
"tasks:merge": true,
|
||||
"messages:send": true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(state.explicitPermissions).toEqual(
|
||||
new Set<AgentPermission>(["tasks:assign", "tasks:merge", "messages:send"]),
|
||||
);
|
||||
expect(state.roleDefaultPermissions).toEqual(
|
||||
new Set<AgentPermission>([
|
||||
"tasks:execute",
|
||||
"tasks:review",
|
||||
"agents:view",
|
||||
"messages:read",
|
||||
"messages:send",
|
||||
]),
|
||||
);
|
||||
expect(state.resolvedPermissions.has("tasks:assign")).toBe(true);
|
||||
expect(state.resolvedPermissions.has("tasks:execute")).toBe(true);
|
||||
});
|
||||
|
||||
it("still gets role defaults when permissions field is undefined", () => {
|
||||
const state = computeAccessState(makeAgent("reviewer", undefined));
|
||||
|
||||
expect(state.canReviewTasks).toBe(true);
|
||||
expect(state.resolvedPermissions.has("tasks:review")).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores invalid permission keys", () => {
|
||||
const state = computeAccessState(
|
||||
makeAgent("custom", { "budget:spend": true, "tasks:execute": true }),
|
||||
);
|
||||
|
||||
expect(state.explicitPermissions).toEqual(new Set<AgentPermission>(["tasks:execute"]));
|
||||
expect(state.resolvedPermissions.has("tasks:execute")).toBe(true);
|
||||
expect(state.resolvedPermissions.has("budget:spend" as AgentPermission)).toBe(false);
|
||||
});
|
||||
|
||||
it("resolved permissions are the union of role defaults and explicit grants", () => {
|
||||
const state = computeAccessState(
|
||||
makeAgent("executor", {
|
||||
"tasks:assign": true,
|
||||
"agents:create": true,
|
||||
}),
|
||||
);
|
||||
|
||||
const expected = new Set<AgentPermission>([
|
||||
...state.roleDefaultPermissions,
|
||||
...state.explicitPermissions,
|
||||
]);
|
||||
expect(state.resolvedPermissions).toEqual(expected);
|
||||
});
|
||||
});
|
||||
73
packages/core/src/agent-permissions.ts
Normal file
73
packages/core/src/agent-permissions.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { Agent, AgentAccessState, AgentCapability, AgentPermission } from "./types.js";
|
||||
import { AGENT_PERMISSIONS } from "./types.js";
|
||||
|
||||
const VALID_PERMISSION_SET = new Set<AgentPermission>(AGENT_PERMISSIONS);
|
||||
|
||||
/** Default permission grants by agent role/capability. */
|
||||
export const ROLE_DEFAULT_PERMISSIONS: Record<AgentCapability, AgentPermission[]> = {
|
||||
triage: ["tasks:create", "agents:view", "messages:read"],
|
||||
executor: ["tasks:execute", "agents:view", "messages:read", "messages:send"],
|
||||
reviewer: ["tasks:review", "agents:view", "messages:read", "messages:send"],
|
||||
merger: ["tasks:merge", "agents:view", "messages:read"],
|
||||
scheduler: ["tasks:assign", "tasks:create", "tasks:archive", "agents:view", "automations:manage", "missions:manage", "messages:read"],
|
||||
engineer: ["tasks:execute", "tasks:review", "agents:view", "messages:read", "messages:send"],
|
||||
custom: [],
|
||||
};
|
||||
|
||||
/** Type guard for canonical agent permissions. */
|
||||
export function isValidPermission(key: string): key is AgentPermission {
|
||||
return VALID_PERMISSION_SET.has(key as AgentPermission);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw permission grant map into a set of explicit canonical grants.
|
||||
* Invalid keys and false values are ignored.
|
||||
*/
|
||||
export function normalizePermissions(raw: Record<string, boolean>): Set<AgentPermission> {
|
||||
const permissions = new Set<AgentPermission>();
|
||||
|
||||
for (const [key, granted] of Object.entries(raw)) {
|
||||
if (!granted) {
|
||||
continue;
|
||||
}
|
||||
if (!isValidPermission(key)) {
|
||||
continue;
|
||||
}
|
||||
permissions.add(key);
|
||||
}
|
||||
|
||||
return permissions;
|
||||
}
|
||||
|
||||
/** Compute resolved access state for an agent from role defaults + explicit grants. */
|
||||
export function computeAccessState(agent: Agent): AgentAccessState {
|
||||
const roleDefaultPermissions = new Set<AgentPermission>(ROLE_DEFAULT_PERMISSIONS[agent.role] ?? []);
|
||||
const explicitPermissions = normalizePermissions(agent.permissions ?? {});
|
||||
const resolvedPermissions = new Set<AgentPermission>(roleDefaultPermissions);
|
||||
|
||||
for (const permission of explicitPermissions) {
|
||||
resolvedPermissions.add(permission);
|
||||
}
|
||||
|
||||
const taskAssignSource = explicitPermissions.has("tasks:assign")
|
||||
? "explicit_grant"
|
||||
: roleDefaultPermissions.has("tasks:assign")
|
||||
? "role_default"
|
||||
: "denied";
|
||||
|
||||
return {
|
||||
agentId: agent.id,
|
||||
canAssignTasks: resolvedPermissions.has("tasks:assign"),
|
||||
taskAssignSource,
|
||||
canCreateAgents: resolvedPermissions.has("agents:create"),
|
||||
canExecuteTasks: resolvedPermissions.has("tasks:execute"),
|
||||
canReviewTasks: resolvedPermissions.has("tasks:review"),
|
||||
canMergeTasks: resolvedPermissions.has("tasks:merge"),
|
||||
canDeleteAgents: resolvedPermissions.has("agents:delete"),
|
||||
canManageMissions: resolvedPermissions.has("missions:manage"),
|
||||
canSendMessages: resolvedPermissions.has("messages:send"),
|
||||
resolvedPermissions,
|
||||
explicitPermissions,
|
||||
roleDefaultPermissions,
|
||||
};
|
||||
}
|
||||
@@ -136,6 +136,45 @@ describe("AgentStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── getAccessState ────────────────────────────────────────────────
|
||||
|
||||
describe("getAccessState", () => {
|
||||
it("returns computed state for an executor agent", async () => {
|
||||
const created = await store.createAgent({
|
||||
name: "Executor",
|
||||
role: "executor",
|
||||
});
|
||||
|
||||
const state = await store.getAccessState(created.id);
|
||||
|
||||
expect(state).not.toBeNull();
|
||||
expect(state?.agentId).toBe(created.id);
|
||||
expect(state?.canExecuteTasks).toBe(true);
|
||||
expect(state?.canAssignTasks).toBe(false);
|
||||
expect(state?.taskAssignSource).toBe("denied");
|
||||
});
|
||||
|
||||
it("returns null for non-existent agent", async () => {
|
||||
const state = await store.getAccessState("agent-missing");
|
||||
expect(state).toBeNull();
|
||||
});
|
||||
|
||||
it("reflects explicit permissions when set", async () => {
|
||||
const created = await store.createAgent({
|
||||
name: "Explicit",
|
||||
role: "executor",
|
||||
permissions: { "tasks:assign": true },
|
||||
});
|
||||
|
||||
const state = await store.getAccessState(created.id);
|
||||
|
||||
expect(state).not.toBeNull();
|
||||
expect(state?.canAssignTasks).toBe(true);
|
||||
expect(state?.taskAssignSource).toBe("explicit_grant");
|
||||
expect(state?.explicitPermissions.has("tasks:assign")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateAgent ───────────────────────────────────────────────────
|
||||
|
||||
describe("updateAgent", () => {
|
||||
|
||||
@@ -32,8 +32,10 @@ import type {
|
||||
AgentTaskSession,
|
||||
AgentConfigRevision,
|
||||
AgentConfigSnapshot,
|
||||
AgentAccessState,
|
||||
} from "./types.js";
|
||||
import { AGENT_VALID_TRANSITIONS, agentToConfigSnapshot, diffConfigSnapshots } from "./types.js";
|
||||
import { computeAccessState } from "./agent-permissions.js";
|
||||
|
||||
/** Events emitted by AgentStore */
|
||||
export interface AgentStoreEvents {
|
||||
@@ -175,6 +177,20 @@ export class AgentStore extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get computed access capabilities for an agent.
|
||||
* @param agentId - The agent ID
|
||||
* @returns Computed access state, or null if agent not found
|
||||
*/
|
||||
async getAccessState(agentId: string): Promise<AgentAccessState | null> {
|
||||
const agent = await this.getAgent(agentId);
|
||||
if (!agent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return computeAccessState(agent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed agent info including heartbeat history.
|
||||
* @param agentId - The agent ID
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, agentToConfigSnapshot, diffConfigSnapshots } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentHeartbeatConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, HeartbeatInvocationSource, AgentTaskSession, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox } from "./types.js";
|
||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, HeartbeatInvocationSource, AgentTaskSession, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox } from "./types.js";
|
||||
export { AGENT_VALID_TRANSITIONS } from "./types.js";
|
||||
export {
|
||||
BUILTIN_AGENT_PROMPTS,
|
||||
@@ -7,6 +7,12 @@ export {
|
||||
getAvailableTemplates,
|
||||
getTemplatesForRole,
|
||||
} from "./agent-prompts.js";
|
||||
export {
|
||||
ROLE_DEFAULT_PERMISSIONS,
|
||||
normalizePermissions,
|
||||
computeAccessState,
|
||||
isValidPermission,
|
||||
} from "./agent-permissions.js";
|
||||
export { AgentStore } from "./agent-store.js";
|
||||
export type { AgentStoreEvents } from "./agent-store.js";
|
||||
export { MessageStore } from "./message-store.js";
|
||||
|
||||
@@ -1494,6 +1494,70 @@ export interface AgentPromptsConfig {
|
||||
roleAssignments?: Partial<Record<AgentCapability, string>>;
|
||||
}
|
||||
|
||||
// ── Agent Permission Types ──────────────────────────────────────────────────
|
||||
|
||||
/** Canonical permission identifiers for agent access control.
|
||||
* Each string represents a discrete capability that can be granted or denied. */
|
||||
export const AGENT_PERMISSIONS = [
|
||||
"tasks:assign", // Assign tasks to agents
|
||||
"tasks:create", // Create new tasks
|
||||
"tasks:execute", // Execute/run tasks
|
||||
"tasks:review", // Review task output (code, specs)
|
||||
"tasks:merge", // Merge completed task branches
|
||||
"tasks:delete", // Delete tasks
|
||||
"tasks:archive", // Archive/unarchive tasks
|
||||
"agents:create", // Create new agents
|
||||
"agents:update", // Update agent configuration
|
||||
"agents:delete", // Delete agents
|
||||
"agents:view", // View agent details and logs
|
||||
"settings:read", // Read project settings
|
||||
"settings:update", // Modify project settings
|
||||
"workflows:manage", // Create/edit/delete workflow steps
|
||||
"missions:manage", // Create/edit/delete missions and slices
|
||||
"automations:manage", // Create/edit/delete scheduled automations
|
||||
"messages:send", // Send messages to agents/users
|
||||
"messages:read", // Read mailbox messages
|
||||
] as const;
|
||||
|
||||
/** A single canonical permission string. */
|
||||
export type AgentPermission = (typeof AGENT_PERMISSIONS)[number];
|
||||
|
||||
/** Describes how an agent's task assignment capability was determined. */
|
||||
export type TaskAssignSource =
|
||||
| "role_default" // Granted automatically by role (e.g., scheduler gets tasks:assign)
|
||||
| "explicit_grant" // Explicitly granted via permissions field
|
||||
| "denied"; // Not granted by any source
|
||||
|
||||
/** Computed access state for an agent, derived from its role and permissions. */
|
||||
export interface AgentAccessState {
|
||||
/** The agent ID this access state belongs to. */
|
||||
agentId: string;
|
||||
/** Whether this agent can assign tasks to other agents. */
|
||||
canAssignTasks: boolean;
|
||||
/** How the tasks:assign permission was determined. */
|
||||
taskAssignSource: TaskAssignSource;
|
||||
/** Whether this agent can create new agents. */
|
||||
canCreateAgents: boolean;
|
||||
/** Whether this agent can execute tasks. */
|
||||
canExecuteTasks: boolean;
|
||||
/** Whether this agent can review task output. */
|
||||
canReviewTasks: boolean;
|
||||
/** Whether this agent can merge task branches. */
|
||||
canMergeTasks: boolean;
|
||||
/** Whether this agent can delete agents. */
|
||||
canDeleteAgents: boolean;
|
||||
/** Whether this agent can manage missions. */
|
||||
canManageMissions: boolean;
|
||||
/** Whether this agent can send messages. */
|
||||
canSendMessages: boolean;
|
||||
/** Full set of resolved permissions (union of role defaults + explicit grants). */
|
||||
resolvedPermissions: Set<AgentPermission>;
|
||||
/** Permissions explicitly granted on this agent (from the permissions field). */
|
||||
explicitPermissions: Set<AgentPermission>;
|
||||
/** Permissions granted by role default (not explicitly set). */
|
||||
roleDefaultPermissions: Set<AgentPermission>;
|
||||
}
|
||||
|
||||
/** Agent record stored in the system */
|
||||
export interface Agent {
|
||||
/** Unique identifier (e.g., "agent-001") */
|
||||
|
||||
Reference in New Issue
Block a user