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:
gsxdsm
2026-04-08 01:39:15 -07:00
parent 343a575b8e
commit 025b60164d
9 changed files with 767 additions and 2 deletions

View 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);
});
});

View 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,
};
}

View File

@@ -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", () => {

View File

@@ -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

View File

@@ -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";

View File

@@ -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") */