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:
5
.changeset/fn-1122-agent-permissions.md
Normal file
5
.changeset/fn-1122-agent-permissions.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@gsxdsm/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add agent permissions and access control: structured permission model with role-based defaults, `AgentAccessState` type, `GET /api/agents/:id/access` and `PATCH /api/agents/:id/permissions` endpoints.
|
||||||
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 ───────────────────────────────────────────────────
|
// ── updateAgent ───────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("updateAgent", () => {
|
describe("updateAgent", () => {
|
||||||
|
|||||||
@@ -32,8 +32,10 @@ import type {
|
|||||||
AgentTaskSession,
|
AgentTaskSession,
|
||||||
AgentConfigRevision,
|
AgentConfigRevision,
|
||||||
AgentConfigSnapshot,
|
AgentConfigSnapshot,
|
||||||
|
AgentAccessState,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
import { AGENT_VALID_TRANSITIONS, agentToConfigSnapshot, diffConfigSnapshots } from "./types.js";
|
import { AGENT_VALID_TRANSITIONS, agentToConfigSnapshot, diffConfigSnapshots } from "./types.js";
|
||||||
|
import { computeAccessState } from "./agent-permissions.js";
|
||||||
|
|
||||||
/** Events emitted by AgentStore */
|
/** Events emitted by AgentStore */
|
||||||
export interface AgentStoreEvents {
|
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.
|
* Get detailed agent info including heartbeat history.
|
||||||
* @param agentId - The agent ID
|
* @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 { 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, AgentHeartbeatConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, HeartbeatInvocationSource, AgentTaskSession, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox } 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 { AGENT_VALID_TRANSITIONS } from "./types.js";
|
||||||
export {
|
export {
|
||||||
BUILTIN_AGENT_PROMPTS,
|
BUILTIN_AGENT_PROMPTS,
|
||||||
@@ -7,6 +7,12 @@ export {
|
|||||||
getAvailableTemplates,
|
getAvailableTemplates,
|
||||||
getTemplatesForRole,
|
getTemplatesForRole,
|
||||||
} from "./agent-prompts.js";
|
} from "./agent-prompts.js";
|
||||||
|
export {
|
||||||
|
ROLE_DEFAULT_PERMISSIONS,
|
||||||
|
normalizePermissions,
|
||||||
|
computeAccessState,
|
||||||
|
isValidPermission,
|
||||||
|
} from "./agent-permissions.js";
|
||||||
export { AgentStore } from "./agent-store.js";
|
export { AgentStore } from "./agent-store.js";
|
||||||
export type { AgentStoreEvents } from "./agent-store.js";
|
export type { AgentStoreEvents } from "./agent-store.js";
|
||||||
export { MessageStore } from "./message-store.js";
|
export { MessageStore } from "./message-store.js";
|
||||||
|
|||||||
@@ -1494,6 +1494,70 @@ export interface AgentPromptsConfig {
|
|||||||
roleAssignments?: Partial<Record<AgentCapability, string>>;
|
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 */
|
/** Agent record stored in the system */
|
||||||
export interface Agent {
|
export interface Agent {
|
||||||
/** Unique identifier (e.g., "agent-001") */
|
/** Unique identifier (e.g., "agent-001") */
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import { request } from "../test-request.js";
|
||||||
|
|
||||||
|
const AGENT_PERMISSIONS = [
|
||||||
|
"tasks:assign",
|
||||||
|
"tasks:create",
|
||||||
|
"tasks:execute",
|
||||||
|
"tasks:review",
|
||||||
|
"tasks:merge",
|
||||||
|
"tasks:delete",
|
||||||
|
"tasks:archive",
|
||||||
|
"agents:create",
|
||||||
|
"agents:update",
|
||||||
|
"agents:delete",
|
||||||
|
"agents:view",
|
||||||
|
"settings:read",
|
||||||
|
"settings:update",
|
||||||
|
"workflows:manage",
|
||||||
|
"missions:manage",
|
||||||
|
"automations:manage",
|
||||||
|
"messages:send",
|
||||||
|
"messages:read",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type AgentCapability = "triage" | "executor" | "reviewer" | "merger" | "scheduler" | "engineer" | "custom";
|
||||||
|
|
||||||
|
type AgentRecord = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
role: AgentCapability;
|
||||||
|
state: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
permissions?: Record<string, boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROLE_DEFAULT_PERMISSIONS: Record<AgentCapability, string[]> = {
|
||||||
|
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: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizePermissions(raw: Record<string, boolean>): Set<string> {
|
||||||
|
const result = new Set<string>();
|
||||||
|
for (const [key, granted] of Object.entries(raw)) {
|
||||||
|
if (granted && AGENT_PERMISSIONS.includes(key as (typeof AGENT_PERMISSIONS)[number])) {
|
||||||
|
result.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeMockAccessState(agent: AgentRecord) {
|
||||||
|
const roleDefaultPermissions = new Set(ROLE_DEFAULT_PERMISSIONS[agent.role] ?? []);
|
||||||
|
const explicitPermissions = normalizePermissions(agent.permissions ?? {});
|
||||||
|
const resolvedPermissions = new Set(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockInit = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const mockGetAgent = vi.fn();
|
||||||
|
const mockUpdateAgent = vi.fn();
|
||||||
|
const mockListAgents = vi.fn().mockResolvedValue([]);
|
||||||
|
const mockComputeAccessState = vi.fn((agent: AgentRecord) => computeMockAccessState(agent));
|
||||||
|
const mockIsValidPermission = vi.fn(
|
||||||
|
(key: string) => AGENT_PERMISSIONS.includes(key as (typeof AGENT_PERMISSIONS)[number]),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mock("@fusion/core", () => {
|
||||||
|
return {
|
||||||
|
AgentStore: class MockAgentStore {
|
||||||
|
init = mockInit;
|
||||||
|
getAgent = mockGetAgent;
|
||||||
|
updateAgent = mockUpdateAgent;
|
||||||
|
listAgents = mockListAgents;
|
||||||
|
},
|
||||||
|
computeAccessState: mockComputeAccessState,
|
||||||
|
isValidPermission: mockIsValidPermission,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
class MockStore extends EventEmitter {
|
||||||
|
getRootDir(): string {
|
||||||
|
return "/tmp/fn-1122-test";
|
||||||
|
}
|
||||||
|
|
||||||
|
getFusionDir(): string {
|
||||||
|
return "/tmp/fn-1122-test/.fusion";
|
||||||
|
}
|
||||||
|
|
||||||
|
getDatabase() {
|
||||||
|
return {
|
||||||
|
exec: vi.fn(),
|
||||||
|
prepare: vi.fn().mockReturnValue({
|
||||||
|
run: vi.fn().mockReturnValue({ changes: 0 }),
|
||||||
|
get: vi.fn(),
|
||||||
|
all: vi.fn().mockReturnValue([]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeAgent(overrides: Partial<AgentRecord> = {}): AgentRecord {
|
||||||
|
return {
|
||||||
|
id: "agent-001",
|
||||||
|
name: "Agent",
|
||||||
|
role: "executor",
|
||||||
|
state: "idle",
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
metadata: {},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Agent permission routes", () => {
|
||||||
|
let store: MockStore;
|
||||||
|
let app: ReturnType<typeof import("../server.js").createServer>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockInit.mockResolvedValue(undefined);
|
||||||
|
mockListAgents.mockResolvedValue([]);
|
||||||
|
|
||||||
|
store = new MockStore();
|
||||||
|
const { createServer } = await import("../server.js");
|
||||||
|
app = createServer(store as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/agents/:id/access", () => {
|
||||||
|
it("returns executor access state", async () => {
|
||||||
|
mockGetAgent.mockResolvedValue(makeAgent({ role: "executor" }));
|
||||||
|
|
||||||
|
const response = await request(app, "GET", "/api/agents/agent-001/access");
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect((response.body as any).canExecuteTasks).toBe(true);
|
||||||
|
expect((response.body as any).canAssignTasks).toBe(false);
|
||||||
|
expect((response.body as any).taskAssignSource).toBe("denied");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns scheduler access state with role_default task assignment", async () => {
|
||||||
|
mockGetAgent.mockResolvedValue(makeAgent({ role: "scheduler" }));
|
||||||
|
|
||||||
|
const response = await request(app, "GET", "/api/agents/agent-001/access");
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect((response.body as any).canAssignTasks).toBe(true);
|
||||||
|
expect((response.body as any).taskAssignSource).toBe("role_default");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 for non-existent agent", async () => {
|
||||||
|
mockGetAgent.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const response = await request(app, "GET", "/api/agents/agent-missing/access");
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect((response.body as any).error).toBe("Agent not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serializes set fields as arrays", async () => {
|
||||||
|
mockGetAgent.mockResolvedValue(makeAgent({ role: "executor" }));
|
||||||
|
|
||||||
|
const response = await request(app, "GET", "/api/agents/agent-001/access");
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(Array.isArray((response.body as any).resolvedPermissions)).toBe(true);
|
||||||
|
expect(Array.isArray((response.body as any).explicitPermissions)).toBe(true);
|
||||||
|
expect(Array.isArray((response.body as any).roleDefaultPermissions)).toBe(true);
|
||||||
|
expect((response.body as any).resolvedPermissions).toContain("tasks:execute");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects explicit permissions on the agent", async () => {
|
||||||
|
mockGetAgent.mockResolvedValue(
|
||||||
|
makeAgent({ role: "executor", permissions: { "tasks:assign": true } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await request(app, "GET", "/api/agents/agent-001/access");
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect((response.body as any).canAssignTasks).toBe(true);
|
||||||
|
expect((response.body as any).taskAssignSource).toBe("explicit_grant");
|
||||||
|
expect((response.body as any).explicitPermissions).toContain("tasks:assign");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PATCH /api/agents/:id/permissions", () => {
|
||||||
|
it("updates permissions with valid keys", async () => {
|
||||||
|
mockUpdateAgent.mockResolvedValue(
|
||||||
|
makeAgent({ permissions: { "tasks:assign": true, "messages:send": false } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await request(
|
||||||
|
app,
|
||||||
|
"PATCH",
|
||||||
|
"/api/agents/agent-001/permissions",
|
||||||
|
JSON.stringify({ permissions: { "tasks:assign": true, "messages:send": false } }),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect((response.body as any).permissions).toEqual({ "tasks:assign": true, "messages:send": false });
|
||||||
|
expect(mockUpdateAgent).toHaveBeenCalledWith("agent-001", {
|
||||||
|
permissions: { "tasks:assign": true, "messages:send": false },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 for invalid permission key", async () => {
|
||||||
|
const response = await request(
|
||||||
|
app,
|
||||||
|
"PATCH",
|
||||||
|
"/api/agents/agent-001/permissions",
|
||||||
|
JSON.stringify({ permissions: { "invalid:key": true } }),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect((response.body as any).error).toBe("Invalid permission: invalid:key");
|
||||||
|
expect(mockUpdateAgent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 for budget-related permission key", async () => {
|
||||||
|
const response = await request(
|
||||||
|
app,
|
||||||
|
"PATCH",
|
||||||
|
"/api/agents/agent-001/permissions",
|
||||||
|
JSON.stringify({ permissions: { "budget:spend": true } }),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect((response.body as any).error).toBe("Budget permissions are not supported");
|
||||||
|
expect(mockUpdateAgent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 for non-existent agent", async () => {
|
||||||
|
mockUpdateAgent.mockRejectedValue(new Error("Agent agent-missing not found"));
|
||||||
|
|
||||||
|
const response = await request(
|
||||||
|
app,
|
||||||
|
"PATCH",
|
||||||
|
"/api/agents/agent-missing/permissions",
|
||||||
|
JSON.stringify({ permissions: { "tasks:assign": true } }),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect((response.body as any).error).toContain("not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts empty permissions object", async () => {
|
||||||
|
mockUpdateAgent.mockResolvedValue(makeAgent({ permissions: {} }));
|
||||||
|
|
||||||
|
const response = await request(
|
||||||
|
app,
|
||||||
|
"PATCH",
|
||||||
|
"/api/agents/agent-001/permissions",
|
||||||
|
JSON.stringify({ permissions: {} }),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect((response.body as any).permissions).toEqual({});
|
||||||
|
expect(mockUpdateAgent).toHaveBeenCalledWith("agent-001", { permissions: {} });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6937,6 +6937,15 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serializeAccessState(state: import("@fusion/core").AgentAccessState) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
resolvedPermissions: Array.from(state.resolvedPermissions),
|
||||||
|
explicitPermissions: Array.from(state.explicitPermissions),
|
||||||
|
roleDefaultPermissions: Array.from(state.roleDefaultPermissions),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/agents
|
* POST /api/agents
|
||||||
* Create a new agent.
|
* Create a new agent.
|
||||||
@@ -7295,6 +7304,77 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/agents/:id/access
|
||||||
|
* Get computed access state for an agent.
|
||||||
|
*/
|
||||||
|
router.get("/agents/:id/access", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const scopedStore = await getScopedStore(req);
|
||||||
|
const { AgentStore, computeAccessState } = await import("@fusion/core");
|
||||||
|
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||||
|
await agentStore.init();
|
||||||
|
|
||||||
|
const agent = await agentStore.getAgent(req.params.id);
|
||||||
|
if (!agent) {
|
||||||
|
res.status(404).json({ error: "Agent not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = computeAccessState(agent);
|
||||||
|
res.json(serializeAccessState(state));
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/agents/:id/permissions
|
||||||
|
* Update agent permission grants.
|
||||||
|
*/
|
||||||
|
router.patch("/agents/:id/permissions", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { permissions } = req.body ?? {};
|
||||||
|
|
||||||
|
if (permissions === undefined || permissions === null || typeof permissions !== "object" || Array.isArray(permissions)) {
|
||||||
|
res.status(400).json({ error: "permissions must be an object" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { AgentStore, isValidPermission } = await import("@fusion/core");
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(permissions as Record<string, unknown>)) {
|
||||||
|
if (key.startsWith("budget:")) {
|
||||||
|
res.status(400).json({ error: "Budget permissions are not supported" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isValidPermission(key)) {
|
||||||
|
res.status(400).json({ error: `Invalid permission: ${key}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof value !== "boolean") {
|
||||||
|
res.status(400).json({ error: `Permission value for ${key} must be boolean` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopedStore = await getScopedStore(req);
|
||||||
|
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||||
|
await agentStore.init();
|
||||||
|
|
||||||
|
const agent = await agentStore.updateAgent(req.params.id, {
|
||||||
|
permissions: permissions as Record<string, boolean>,
|
||||||
|
});
|
||||||
|
res.json(agent);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.message?.includes("not found")) {
|
||||||
|
res.status(404).json({ error: err.message });
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/agents/:id/instructions
|
* PATCH /api/agents/:id/instructions
|
||||||
* Update agent custom instructions.
|
* Update agent custom instructions.
|
||||||
|
|||||||
Reference in New Issue
Block a user