Expose agent task assignment permissions

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-19 08:14:29 -05:00
parent bcc1d9f3d6
commit f9d685344d
10 changed files with 310 additions and 27 deletions

View File

@@ -123,6 +123,9 @@ export type {
InstanceExperimentalSettings,
InstanceSettings,
Agent,
AgentAccessState,
AgentChainOfCommandEntry,
AgentDetail,
AgentPermissions,
AgentKeyCreated,
AgentConfigRevision,

View File

@@ -4,11 +4,29 @@ import type {
AgentRole,
AgentStatus,
} from "../constants.js";
import type {
CompanyMembership,
PrincipalPermissionGrant,
} from "./access.js";
export interface AgentPermissions {
canCreateAgents: boolean;
}
export interface AgentAccessState {
canAssignTasks: boolean;
taskAssignSource: "explicit_grant" | "agent_creator" | "ceo_role" | "none";
membership: CompanyMembership | null;
grants: PrincipalPermissionGrant[];
}
export interface AgentChainOfCommandEntry {
id: string;
name: string;
role: AgentRole;
title: string | null;
}
export interface Agent {
id: string;
companyId: string;
@@ -34,6 +52,11 @@ export interface Agent {
updatedAt: Date;
}
export interface AgentDetail extends Agent {
chainOfCommand: AgentChainOfCommandEntry[];
access: AgentAccessState;
}
export interface AgentKeyCreated {
id: string;
name: string;

View File

@@ -2,6 +2,9 @@ export type { Company } from "./company.js";
export type { InstanceExperimentalSettings, InstanceSettings } from "./instance.js";
export type {
Agent,
AgentAccessState,
AgentChainOfCommandEntry,
AgentDetail,
AgentPermissions,
AgentKeyCreated,
AgentConfigRevision,

View File

@@ -100,6 +100,7 @@ export type TestAdapterEnvironment = z.infer<typeof testAdapterEnvironmentSchema
export const updateAgentPermissionsSchema = z.object({
canCreateAgents: z.boolean(),
canAssignTasks: z.boolean(),
});
export type UpdateAgentPermissions = z.infer<typeof updateAgentPermissionsSchema>;

View File

@@ -2450,6 +2450,14 @@ export function accessRoutes(
"member",
"active"
);
await access.setPrincipalPermission(
companyId,
"agent",
created.id,
"tasks:assign",
true,
req.actor.userId ?? null
);
const grants = grantsFromDefaults(
invite.defaultsPayload as Record<string, unknown> | null,
"agent"

View File

@@ -71,6 +71,80 @@ export function agentRoutes(db: Db) {
return Boolean((agent.permissions as Record<string, unknown>).canCreateAgents);
}
async function buildAgentAccessState(agent: NonNullable<Awaited<ReturnType<typeof svc.getById>>>) {
const membership = await access.getMembership(agent.companyId, "agent", agent.id);
const grants = membership
? await access.listPrincipalGrants(agent.companyId, "agent", agent.id)
: [];
const hasExplicitTaskAssignGrant = grants.some((grant) => grant.permissionKey === "tasks:assign");
if (agent.role === "ceo") {
return {
canAssignTasks: true,
taskAssignSource: "ceo_role" as const,
membership,
grants,
};
}
if (canCreateAgents(agent)) {
return {
canAssignTasks: true,
taskAssignSource: "agent_creator" as const,
membership,
grants,
};
}
if (hasExplicitTaskAssignGrant) {
return {
canAssignTasks: true,
taskAssignSource: "explicit_grant" as const,
membership,
grants,
};
}
return {
canAssignTasks: false,
taskAssignSource: "none" as const,
membership,
grants,
};
}
async function buildAgentDetail(
agent: NonNullable<Awaited<ReturnType<typeof svc.getById>>>,
options?: { restricted?: boolean },
) {
const [chainOfCommand, accessState] = await Promise.all([
svc.getChainOfCommand(agent.id),
buildAgentAccessState(agent),
]);
return {
...(options?.restricted ? redactForRestrictedAgentView(agent) : agent),
chainOfCommand,
access: accessState,
};
}
async function applyDefaultAgentTaskAssignGrant(
companyId: string,
agentId: string,
grantedByUserId: string | null,
) {
await access.ensureMembership(companyId, "agent", agentId, "member", "active");
await access.setPrincipalPermission(
companyId,
"agent",
agentId,
"tasks:assign",
true,
grantedByUserId,
);
}
async function assertCanCreateAgentsForCompany(req: Request, companyId: string) {
assertCompanyAccess(req, companyId);
if (req.actor.type === "board") {
@@ -575,8 +649,7 @@ export function agentRoutes(db: Db) {
res.status(404).json({ error: "Agent not found" });
return;
}
const chainOfCommand = await svc.getChainOfCommand(agent.id);
res.json({ ...agent, chainOfCommand });
res.json(await buildAgentDetail(agent));
});
router.get("/agents/me/inbox-lite", async (req, res) => {
@@ -618,13 +691,11 @@ export function agentRoutes(db: Db) {
if (req.actor.type === "agent" && req.actor.agentId !== id) {
const canRead = await actorCanReadConfigurationsForCompany(req, agent.companyId);
if (!canRead) {
const chainOfCommand = await svc.getChainOfCommand(agent.id);
res.json({ ...redactForRestrictedAgentView(agent), chainOfCommand });
res.json(await buildAgentDetail(agent, { restricted: true }));
return;
}
}
const chainOfCommand = await svc.getChainOfCommand(agent.id);
res.json({ ...agent, chainOfCommand });
res.json(await buildAgentDetail(agent));
});
router.get("/agents/:id/configuration", async (req, res) => {
@@ -884,6 +955,12 @@ export function agentRoutes(db: Db) {
},
});
await applyDefaultAgentTaskAssignGrant(
companyId,
agent.id,
actor.actorType === "user" ? actor.actorId : null,
);
if (approval) {
await logActivity(db, {
companyId,
@@ -945,6 +1022,12 @@ export function agentRoutes(db: Db) {
details: { name: agent.name, role: agent.role },
});
await applyDefaultAgentTaskAssignGrant(
companyId,
agent.id,
req.actor.type === "board" ? (req.actor.userId ?? null) : null,
);
if (agent.budgetMonthlyCents > 0) {
await budgets.upsertPolicy(
companyId,
@@ -988,6 +1071,18 @@ export function agentRoutes(db: Db) {
return;
}
const effectiveCanAssignTasks =
agent.role === "ceo" || Boolean(agent.permissions?.canCreateAgents) || req.body.canAssignTasks;
await access.ensureMembership(agent.companyId, "agent", agent.id, "member", "active");
await access.setPrincipalPermission(
agent.companyId,
"agent",
agent.id,
"tasks:assign",
effectiveCanAssignTasks,
req.actor.type === "board" ? (req.actor.userId ?? null) : null,
);
const actor = getActorInfo(req);
await logActivity(db, {
companyId: agent.companyId,
@@ -998,10 +1093,13 @@ export function agentRoutes(db: Db) {
action: "agent.permissions_updated",
entityType: "agent",
entityId: agent.id,
details: req.body,
details: {
canCreateAgents: agent.permissions?.canCreateAgents ?? false,
canAssignTasks: effectiveCanAssignTasks,
},
});
res.json(agent);
res.json(await buildAgentDetail(agent));
});
router.patch("/agents/:id/instructions-path", validate(updateAgentInstructionsPathSchema), async (req, res) => {

View File

@@ -251,6 +251,86 @@ export function accessService(db: Db) {
});
}
async function listPrincipalGrants(
companyId: string,
principalType: PrincipalType,
principalId: string,
) {
return db
.select()
.from(principalPermissionGrants)
.where(
and(
eq(principalPermissionGrants.companyId, companyId),
eq(principalPermissionGrants.principalType, principalType),
eq(principalPermissionGrants.principalId, principalId),
),
)
.orderBy(principalPermissionGrants.permissionKey);
}
async function setPrincipalPermission(
companyId: string,
principalType: PrincipalType,
principalId: string,
permissionKey: PermissionKey,
enabled: boolean,
grantedByUserId: string | null,
scope: Record<string, unknown> | null = null,
) {
if (!enabled) {
await db
.delete(principalPermissionGrants)
.where(
and(
eq(principalPermissionGrants.companyId, companyId),
eq(principalPermissionGrants.principalType, principalType),
eq(principalPermissionGrants.principalId, principalId),
eq(principalPermissionGrants.permissionKey, permissionKey),
),
);
return;
}
await ensureMembership(companyId, principalType, principalId, "member", "active");
const existing = await db
.select()
.from(principalPermissionGrants)
.where(
and(
eq(principalPermissionGrants.companyId, companyId),
eq(principalPermissionGrants.principalType, principalType),
eq(principalPermissionGrants.principalId, principalId),
eq(principalPermissionGrants.permissionKey, permissionKey),
),
)
.then((rows) => rows[0] ?? null);
if (existing) {
await db
.update(principalPermissionGrants)
.set({
scope,
grantedByUserId,
updatedAt: new Date(),
})
.where(eq(principalPermissionGrants.id, existing.id));
return;
}
await db.insert(principalPermissionGrants).values({
companyId,
principalType,
principalId,
permissionKey,
scope,
grantedByUserId,
createdAt: new Date(),
updatedAt: new Date(),
});
}
return {
isInstanceAdmin,
canUser,
@@ -264,5 +344,7 @@ export function accessService(db: Db) {
listUserCompanyAccess,
setUserCompanyAccess,
setPrincipalGrants,
listPrincipalGrants,
setPrincipalPermission,
};
}

View File

@@ -955,6 +955,15 @@ export function companyPortabilityService(db: Db) {
}
const created = await agents.create(targetCompany.id, patch);
await access.ensureMembership(targetCompany.id, "agent", created.id, "member", "active");
await access.setPrincipalPermission(
targetCompany.id,
"agent",
created.id,
"tasks:assign",
true,
actorUserId ?? null,
);
importedSlugToAgentId.set(planAgent.slug, created.id);
existingSlugToAgentId.set(normalizeAgentUrlKey(created.name) ?? created.id, created.id);
resultAgents.push({

View File

@@ -1,5 +1,6 @@
import type {
Agent,
AgentDetail,
AdapterEnvironmentTestResult,
AgentKeyCreated,
AgentRuntimeState,
@@ -45,6 +46,11 @@ export interface AgentHireResponse {
approval: Approval | null;
}
export interface AgentPermissionUpdate {
canCreateAgents: boolean;
canAssignTasks: boolean;
}
function withCompanyScope(path: string, companyId?: string) {
if (!companyId) return path;
const separator = path.includes("?") ? "&" : "?";
@@ -62,7 +68,7 @@ export const agentsApi = {
api.get<Record<string, unknown>[]>(`/companies/${companyId}/agent-configurations`),
get: async (id: string, companyId?: string) => {
try {
return await api.get<Agent>(agentPath(id, companyId));
return await api.get<AgentDetail>(agentPath(id, companyId));
} catch (error) {
// Backward-compat fallback: if backend shortname lookup reports ambiguity,
// resolve using company agent list while ignoring terminated agents.
@@ -83,7 +89,7 @@ export const agentsApi = {
(agent) => agent.status !== "terminated" && normalizeAgentUrlKey(agent.urlKey) === urlKey,
);
if (matches.length !== 1) throw error;
return api.get<Agent>(agentPath(matches[0]!.id, companyId));
return api.get<AgentDetail>(agentPath(matches[0]!.id, companyId));
}
},
getConfiguration: (id: string, companyId?: string) =>
@@ -100,8 +106,8 @@ export const agentsApi = {
api.post<AgentHireResponse>(`/companies/${companyId}/agent-hires`, data),
update: (id: string, data: Record<string, unknown>, companyId?: string) =>
api.patch<Agent>(agentPath(id, companyId), data),
updatePermissions: (id: string, data: { canCreateAgents: boolean }, companyId?: string) =>
api.patch<Agent>(agentPath(id, companyId, "/permissions"), data),
updatePermissions: (id: string, data: AgentPermissionUpdate, companyId?: string) =>
api.patch<AgentDetail>(agentPath(id, companyId, "/permissions"), data),
pause: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/pause"), {}),
resume: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/resume"), {}),
terminate: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/terminate"), {}),

View File

@@ -1,7 +1,13 @@
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
import { useParams, useNavigate, Link, Navigate, useBeforeUnload } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { agentsApi, type AgentKey, type ClaudeLoginResult, type AvailableSkill } from "../api/agents";
import {
agentsApi,
type AgentKey,
type ClaudeLoginResult,
type AvailableSkill,
type AgentPermissionUpdate,
} from "../api/agents";
import { budgetsApi } from "../api/budgets";
import { heartbeatsApi } from "../api/heartbeats";
import { ApiError } from "../api/client";
@@ -64,6 +70,7 @@ import { RunTranscriptView, type TranscriptMode } from "../components/transcript
import {
isUuidLike,
type Agent,
type AgentDetail as AgentDetailRecord,
type BudgetPolicySummary,
type HeartbeatRun,
type HeartbeatRunEvent,
@@ -486,7 +493,7 @@ export function AgentDetail() {
const setSaveConfigAction = useCallback((fn: (() => void) | null) => { saveConfigActionRef.current = fn; }, []);
const setCancelConfigAction = useCallback((fn: (() => void) | null) => { cancelConfigActionRef.current = fn; }, []);
const { data: agent, isLoading, error } = useQuery({
const { data: agent, isLoading, error } = useQuery<AgentDetailRecord>({
queryKey: [...queryKeys.agents.detail(routeAgentRef), lookupCompanyId ?? null],
queryFn: () => agentsApi.get(routeAgentRef, lookupCompanyId),
enabled: canFetchAgent,
@@ -672,8 +679,8 @@ export function AgentDetail() {
});
const updatePermissions = useMutation({
mutationFn: (canCreateAgents: boolean) =>
agentsApi.updatePermissions(agentLookupRef, { canCreateAgents }, resolvedCompanyId ?? undefined),
mutationFn: (permissions: AgentPermissionUpdate) =>
agentsApi.updatePermissions(agentLookupRef, permissions, resolvedCompanyId ?? undefined),
onSuccess: () => {
setActionError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) });
@@ -1076,7 +1083,7 @@ function AgentOverview({
agentId,
agentRouteId,
}: {
agent: Agent;
agent: AgentDetailRecord;
runs: HeartbeatRun[];
assignedIssues: { id: string; title: string; status: string; priority: string; identifier?: string | null; createdAt: Date }[];
runtimeState?: AgentRuntimeState;
@@ -1233,14 +1240,14 @@ function AgentConfigurePage({
onSavingChange,
updatePermissions,
}: {
agent: Agent;
agent: AgentDetailRecord;
agentId: string;
companyId?: string;
onDirtyChange: (dirty: boolean) => void;
onSaveActionChange: (save: (() => void) | null) => void;
onCancelActionChange: (cancel: (() => void) | null) => void;
onSavingChange: (saving: boolean) => void;
updatePermissions: { mutate: (canCreate: boolean) => void; isPending: boolean };
updatePermissions: { mutate: (permissions: AgentPermissionUpdate) => void; isPending: boolean };
}) {
const queryClient = useQueryClient();
const [revisionsOpen, setRevisionsOpen] = useState(false);
@@ -1340,13 +1347,13 @@ function ConfigurationTab({
onSavingChange,
updatePermissions,
}: {
agent: Agent;
agent: AgentDetailRecord;
companyId?: string;
onDirtyChange: (dirty: boolean) => void;
onSaveActionChange: (save: (() => void) | null) => void;
onCancelActionChange: (cancel: (() => void) | null) => void;
onSavingChange: (saving: boolean) => void;
updatePermissions: { mutate: (canCreate: boolean) => void; isPending: boolean };
updatePermissions: { mutate: (permissions: AgentPermissionUpdate) => void; isPending: boolean };
}) {
const queryClient = useQueryClient();
const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false);
@@ -1389,6 +1396,19 @@ function ConfigurationTab({
onSavingChange(isConfigSaving);
}, [onSavingChange, isConfigSaving]);
const canCreateAgents = Boolean(agent.permissions?.canCreateAgents);
const canAssignTasks = Boolean(agent.access?.canAssignTasks);
const taskAssignSource = agent.access?.taskAssignSource ?? "none";
const taskAssignLocked = agent.role === "ceo" || canCreateAgents;
const taskAssignHint =
taskAssignSource === "ceo_role"
? "Enabled automatically for CEO agents."
: taskAssignSource === "agent_creator"
? "Enabled automatically while this agent can create new agents."
: taskAssignSource === "explicit_grant"
? "Enabled via explicit company permission grant."
: "Disabled unless explicitly granted.";
return (
<div className="space-y-6">
<AgentConfigForm
@@ -1406,19 +1426,49 @@ function ConfigurationTab({
<div>
<h3 className="text-sm font-medium mb-3">Permissions</h3>
<div className="border border-border rounded-lg p-4">
<div className="flex items-center justify-between text-sm">
<span>Can create new agents</span>
<div className="border border-border rounded-lg p-4 space-y-4">
<div className="flex items-center justify-between gap-4 text-sm">
<div className="space-y-1">
<div>Can create new agents</div>
<p className="text-xs text-muted-foreground">
Lets this agent create or hire agents and implicitly assign tasks.
</p>
</div>
<Button
variant={agent.permissions?.canCreateAgents ? "default" : "outline"}
variant={canCreateAgents ? "default" : "outline"}
size="sm"
className="h-7 px-2.5 text-xs"
onClick={() =>
updatePermissions.mutate(!Boolean(agent.permissions?.canCreateAgents))
updatePermissions.mutate({
canCreateAgents: !canCreateAgents,
canAssignTasks: !canCreateAgents ? true : canAssignTasks,
})
}
disabled={updatePermissions.isPending}
>
{agent.permissions?.canCreateAgents ? "Enabled" : "Disabled"}
{canCreateAgents ? "Enabled" : "Disabled"}
</Button>
</div>
<div className="flex items-center justify-between gap-4 text-sm">
<div className="space-y-1">
<div>Can assign tasks</div>
<p className="text-xs text-muted-foreground">
{taskAssignHint}
</p>
</div>
<Button
variant={canAssignTasks ? "default" : "outline"}
size="sm"
className="h-7 px-2.5 text-xs"
onClick={() =>
updatePermissions.mutate({
canCreateAgents,
canAssignTasks: !canAssignTasks,
})
}
disabled={updatePermissions.isPending || taskAssignLocked}
>
{canAssignTasks ? "Enabled" : "Disabled"}
</Button>
</div>
</div>