mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
Implement two-tier agent and skill change grants
Add suggest/change permission keys for agent and skill configuration, route protected mutations through authorization.decide, repair default root and reflection-coach grants, and carry agent permission grants through company portability. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
78774af89e
commit
eb4a49a6b6
21 files changed
+935
-275
No files matched your search
@@ -849,7 +849,10 @@ export type JoinRequestStatus = (typeof JOIN_REQUEST_STATUSES)[number];
|
||||
|
||||
export const PERMISSION_KEYS = [
|
||||
"agents:create",
|
||||
"agents:configure",
|
||||
"agents:suggest-changes",
|
||||
"skills:create",
|
||||
"skills:suggest-changes",
|
||||
"environments:manage",
|
||||
"users:invite",
|
||||
"users:manage_permissions",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AgentEnvConfig } from "./secrets.js";
|
||||
import type { RoutineVariable } from "./routine.js";
|
||||
import type { IssueCommentAuthorType } from "../constants.js";
|
||||
import type { IssueCommentAuthorType, PermissionKey } from "../constants.js";
|
||||
import type { IssueCommentMetadata, IssueCommentPresentation } from "./issue.js";
|
||||
|
||||
export interface CompanyPortabilityInclude {
|
||||
@@ -145,6 +145,10 @@ export interface CompanyPortabilityAgentManifestEntry {
|
||||
adapterConfig: Record<string, unknown>;
|
||||
runtimeConfig: Record<string, unknown>;
|
||||
permissions: Record<string, unknown>;
|
||||
permissionGrants: Array<{
|
||||
permissionKey: PermissionKey;
|
||||
scope: Record<string, unknown> | null;
|
||||
}>;
|
||||
budgetMonthlyCents: number;
|
||||
metadata: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { PERMISSION_KEYS } from "../constants.js";
|
||||
import { MAX_COMPANY_ATTACHMENT_MAX_BYTES } from "../constants.js";
|
||||
import {
|
||||
issueCommentAuthorTypeSchema,
|
||||
@@ -70,6 +71,10 @@ export const portabilityAgentManifestEntrySchema = z.object({
|
||||
adapterConfig: z.record(z.string(), z.unknown()),
|
||||
runtimeConfig: z.record(z.string(), z.unknown()),
|
||||
permissions: z.record(z.string(), z.unknown()),
|
||||
permissionGrants: z.array(z.object({
|
||||
permissionKey: z.enum(PERMISSION_KEYS),
|
||||
scope: z.record(z.string(), z.unknown()).nullable().default(null),
|
||||
})).default([]),
|
||||
budgetMonthlyCents: z.number().int().nonnegative(),
|
||||
metadata: z.record(z.string(), z.unknown()).nullable(),
|
||||
});
|
||||
|
||||
@@ -80,6 +80,7 @@ function registerModuleMocks() {
|
||||
agentInstructionsService: () => mockAgentInstructionsService,
|
||||
accessService: () => mockAccessService,
|
||||
approvalService: () => mockApprovalService,
|
||||
builtInAgentService: () => ({ ensureCompanyDefaultAgentGrants: vi.fn() }),
|
||||
companySkillService: () => mockCompanySkillService,
|
||||
budgetService: () => mockBudgetService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
|
||||
@@ -69,6 +69,7 @@ vi.mock("../services/index.js", () => ({
|
||||
agentInstructionsService: () => mockAgentInstructionsService,
|
||||
accessService: () => mockAccessService,
|
||||
approvalService: () => mockApprovalService,
|
||||
builtInAgentService: () => ({ ensureCompanyDefaultAgentGrants: vi.fn() }),
|
||||
companySkillService: () => mockCompanySkillService,
|
||||
budgetService: () => mockBudgetService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
@@ -94,6 +95,7 @@ function registerModuleMocks() {
|
||||
agentInstructionsService: () => mockAgentInstructionsService,
|
||||
accessService: () => mockAccessService,
|
||||
approvalService: () => mockApprovalService,
|
||||
builtInAgentService: () => ({ ensureCompanyDefaultAgentGrants: vi.fn() }),
|
||||
companySkillService: () => mockCompanySkillService,
|
||||
budgetService: () => mockBudgetService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
|
||||
@@ -182,6 +182,7 @@ vi.mock("../services/index.js", () => ({
|
||||
agentInstructionsService: () => mockAgentInstructionsService,
|
||||
accessService: () => mockAccessService,
|
||||
approvalService: () => mockApprovalService,
|
||||
builtInAgentService: () => ({ ensureCompanyDefaultAgentGrants: vi.fn() }),
|
||||
companySkillService: () => mockCompanySkillService,
|
||||
budgetService: () => mockBudgetService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
|
||||
@@ -8,6 +8,10 @@ const mockAgentService = vi.hoisted(() => ({
|
||||
resolveByReference: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockBuiltInAgentService = vi.hoisted(() => ({
|
||||
ensureCompanyDefaultAgentGrants: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAgentInstructionsService = vi.hoisted(() => ({
|
||||
getBundle: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
@@ -42,6 +46,7 @@ vi.mock("../services/index.js", () => ({
|
||||
agentInstructionsService: () => mockAgentInstructionsService,
|
||||
accessService: () => mockAccessService,
|
||||
approvalService: () => ({}),
|
||||
builtInAgentService: () => mockBuiltInAgentService,
|
||||
companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }),
|
||||
budgetService: () => ({}),
|
||||
environmentService: () => mockEnvironmentService,
|
||||
@@ -73,6 +78,7 @@ function registerModuleMocks() {
|
||||
agentInstructionsService: () => mockAgentInstructionsService,
|
||||
accessService: () => mockAccessService,
|
||||
approvalService: () => ({}),
|
||||
builtInAgentService: () => mockBuiltInAgentService,
|
||||
companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }),
|
||||
budgetService: () => ({}),
|
||||
heartbeatService: () => ({}),
|
||||
@@ -193,6 +199,7 @@ describe("agent instructions bundle routes", () => {
|
||||
vi.doUnmock("../middleware/index.js");
|
||||
registerModuleMocks();
|
||||
vi.clearAllMocks();
|
||||
mockBuiltInAgentService.ensureCompanyDefaultAgentGrants.mockResolvedValue(0);
|
||||
mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config);
|
||||
mockFindServerAdapter.mockImplementation((_type: string) => ({ type: _type }));
|
||||
mockAccessService.decide.mockResolvedValue({
|
||||
@@ -290,7 +297,11 @@ describe("agent instructions bundle routes", () => {
|
||||
}
|
||||
return makeAgent();
|
||||
});
|
||||
mockAccessService.hasPermission.mockResolvedValue(false);
|
||||
mockAccessService.decide.mockResolvedValue({
|
||||
allowed: false,
|
||||
reason: "deny_no_grant",
|
||||
explanation: "Missing permission: agents:configure or agents:suggest-changes.",
|
||||
});
|
||||
|
||||
const res = await requestApp(
|
||||
await createApp({
|
||||
@@ -305,6 +316,14 @@ describe("agent instructions bundle routes", () => {
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
expect(res.body.error).toContain("Missing permission");
|
||||
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: "agent_config:read",
|
||||
resource: {
|
||||
type: "agent",
|
||||
companyId: "company-1",
|
||||
agentId: "11111111-1111-4111-8111-111111111111",
|
||||
},
|
||||
}));
|
||||
expect(mockAgentInstructionsService.getBundle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -324,7 +343,18 @@ describe("agent instructions bundle routes", () => {
|
||||
expect(mockAgentInstructionsService.getBundle).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows the built-in Reflection Coach to read peer instructions bundles", async () => {
|
||||
it("allows agents with suggest grants to read peer instructions bundles", async () => {
|
||||
mockAccessService.decide.mockResolvedValue({
|
||||
allowed: true,
|
||||
reason: "allow_explicit_grant",
|
||||
explanation: "Allowed by explicit grant agents:suggest-changes.",
|
||||
grant: {
|
||||
principalType: "agent",
|
||||
principalId: "coach-agent",
|
||||
permissionKey: "agents:suggest-changes",
|
||||
scope: null,
|
||||
},
|
||||
});
|
||||
mockAgentService.getById.mockImplementation(async (id: string) => {
|
||||
if (id === "coach-agent") {
|
||||
return makeReflectionCoachAgent({ id: "coach-agent" });
|
||||
@@ -345,6 +375,14 @@ describe("agent instructions bundle routes", () => {
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: "agent_config:read",
|
||||
resource: {
|
||||
type: "agent",
|
||||
companyId: "company-1",
|
||||
agentId: "11111111-1111-4111-8111-111111111111",
|
||||
},
|
||||
}));
|
||||
expect(mockAgentInstructionsService.readFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "11111111-1111-4111-8111-111111111111" }),
|
||||
"AGENTS.md",
|
||||
|
||||
@@ -63,6 +63,7 @@ function registerModuleMocks() {
|
||||
hasPermission: vi.fn(async () => true),
|
||||
}),
|
||||
approvalService: () => ({}),
|
||||
builtInAgentService: () => ({ ensureCompanyDefaultAgentGrants: vi.fn() }),
|
||||
companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }),
|
||||
budgetService: () => ({}),
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
|
||||
@@ -51,6 +51,10 @@ const mockAgentService = vi.hoisted(() => ({
|
||||
resolveByReference: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockBuiltInAgentService = vi.hoisted(() => ({
|
||||
ensureCompanyDefaultAgentGrants: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
canUser: vi.fn(),
|
||||
decide: vi.fn(),
|
||||
@@ -195,6 +199,7 @@ function registerModuleMocks() {
|
||||
agentInstructionsService: () => mockAgentInstructionsService,
|
||||
accessService: () => mockAccessService,
|
||||
approvalService: () => mockApprovalService,
|
||||
builtInAgentService: () => mockBuiltInAgentService,
|
||||
companySkillService: () => mockCompanySkillService,
|
||||
budgetService: () => mockBudgetService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
@@ -309,6 +314,7 @@ describe.sequential("agent permission routes", () => {
|
||||
mockAgentService.updatePermissions.mockReset();
|
||||
mockAgentService.getChainOfCommand.mockReset();
|
||||
mockAgentService.resolveByReference.mockReset();
|
||||
mockBuiltInAgentService.ensureCompanyDefaultAgentGrants.mockReset();
|
||||
mockAccessService.canUser.mockReset();
|
||||
mockAccessService.decide.mockReset();
|
||||
mockAccessService.hasPermission.mockReset();
|
||||
@@ -354,6 +360,7 @@ describe.sequential("agent permission routes", () => {
|
||||
});
|
||||
mockAgentService.update.mockResolvedValue(baseAgent);
|
||||
mockAgentService.updatePermissions.mockResolvedValue(baseAgent);
|
||||
mockBuiltInAgentService.ensureCompanyDefaultAgentGrants.mockResolvedValue(0);
|
||||
mockAccessService.canUser.mockResolvedValue(true);
|
||||
mockAccessService.decide.mockImplementation(async (input: { action?: string }) => {
|
||||
const allowed = Boolean(await mockAccessService.canUser());
|
||||
@@ -866,6 +873,7 @@ describe.sequential("agent permission routes", () => {
|
||||
true,
|
||||
"agent-admin-user",
|
||||
);
|
||||
expect(mockBuiltInAgentService.ensureCompanyDefaultAgentGrants).toHaveBeenCalledWith(companyId);
|
||||
});
|
||||
|
||||
it("rejects direct agent creation when new agents require board approval", async () => {
|
||||
@@ -1699,11 +1707,10 @@ describe.sequential("agent permission routes", () => {
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("denies an agent actor without agents:create when reading peer config", async () => {
|
||||
// Agent actors must still pass the agents:create gate (explicit
|
||||
// grant OR canCreateAgents permission on the agent record). A peer
|
||||
// agent in the same company without that permission must not be
|
||||
// able to read another agent's configuration.
|
||||
it("denies an agent actor without configure or suggest grants when reading peer config", async () => {
|
||||
// Agent actors must pass the agent configuration read ladder. A peer
|
||||
// agent in the same company without agents:configure or
|
||||
// agents:suggest-changes must not read another agent's configuration.
|
||||
const peerAgentId = "33333333-3333-4333-8333-333333333333";
|
||||
const peerAgent = { ...baseAgent, id: peerAgentId };
|
||||
mockAgentService.getById.mockImplementation(async (id: string) => {
|
||||
@@ -1713,7 +1720,11 @@ describe.sequential("agent permission routes", () => {
|
||||
}
|
||||
return null;
|
||||
});
|
||||
mockAccessService.hasPermission.mockResolvedValue(false);
|
||||
mockAccessService.decide.mockResolvedValue({
|
||||
allowed: false,
|
||||
reason: "deny_no_grant",
|
||||
explanation: "Missing permission: agents:configure or agents:suggest-changes.",
|
||||
});
|
||||
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
@@ -1726,11 +1737,15 @@ describe.sequential("agent permission routes", () => {
|
||||
const res = await request(app).get(`/api/agents/${peerAgentId}/configuration`);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: "agent_config:read",
|
||||
resource: { type: "company", companyId },
|
||||
}));
|
||||
});
|
||||
|
||||
it("allows an agent actor with agents:create grant to read peer config", async () => {
|
||||
// When an agent actor has an explicit agents:create grant in the
|
||||
// access service, the read gate must let them through.
|
||||
it("allows an agent actor with agents:suggest-changes grant to read peer config", async () => {
|
||||
// Suggest-tier authority implies read access so the agent can prepare a
|
||||
// consented diff without receiving direct change authority.
|
||||
const peerAgentId = "44444444-4444-4444-8444-444444444444";
|
||||
const peerAgent = { ...baseAgent, id: peerAgentId };
|
||||
mockAgentService.getById.mockImplementation(async (id: string) => {
|
||||
@@ -1740,11 +1755,17 @@ describe.sequential("agent permission routes", () => {
|
||||
}
|
||||
return null;
|
||||
});
|
||||
mockAccessService.hasPermission.mockImplementation(
|
||||
async (_companyId: string, _principalType: string, principalId: string, key: string) => {
|
||||
return principalId === agentId && key === "agents:create";
|
||||
mockAccessService.decide.mockResolvedValue({
|
||||
allowed: true,
|
||||
reason: "allow_explicit_grant",
|
||||
explanation: "Allowed by explicit grant agents:suggest-changes.",
|
||||
grant: {
|
||||
principalType: "agent",
|
||||
principalId: agentId,
|
||||
permissionKey: "agents:suggest-changes",
|
||||
scope: null,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const app = await createApp({
|
||||
type: "agent",
|
||||
@@ -1757,6 +1778,10 @@ describe.sequential("agent permission routes", () => {
|
||||
const res = await request(app).get(`/api/agents/${peerAgentId}/configuration`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: "agent_config:read",
|
||||
resource: { type: "company", companyId },
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ vi.mock("../services/index.js", () => ({
|
||||
agentInstructionsService: () => mockAgentInstructionsService,
|
||||
accessService: () => mockAccessService,
|
||||
approvalService: () => mockApprovalService,
|
||||
builtInAgentService: () => ({ ensureCompanyDefaultAgentGrants: vi.fn() }),
|
||||
companySkillService: () => mockCompanySkillService,
|
||||
budgetService: () => mockBudgetService,
|
||||
environmentService: () => mockEnvironmentService,
|
||||
@@ -123,6 +124,7 @@ function registerModuleMocks() {
|
||||
agentInstructionsService: () => mockAgentInstructionsService,
|
||||
accessService: () => mockAccessService,
|
||||
approvalService: () => mockApprovalService,
|
||||
builtInAgentService: () => ({ ensureCompanyDefaultAgentGrants: vi.fn() }),
|
||||
companySkillService: () => mockCompanySkillService,
|
||||
budgetService: () => mockBudgetService,
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
|
||||
@@ -45,6 +45,7 @@ vi.mock("../services/index.js", () => ({
|
||||
agentInstructionsService: () => ({}),
|
||||
accessService: () => mockAccessService,
|
||||
approvalService: () => ({}),
|
||||
builtInAgentService: () => ({ ensureCompanyDefaultAgentGrants: vi.fn() }),
|
||||
companySkillService: () => ({
|
||||
listRuntimeSkillEntries: vi.fn(async () => []),
|
||||
resolveRequestedSkillKeys: vi.fn(async () => []),
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
principalPermissionGrants,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import { LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared";
|
||||
import { LOW_TRUST_REVIEW_PRESET, type PermissionKey } from "@paperclipai/shared";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
@@ -100,7 +100,7 @@ async function grantAgentPermission(
|
||||
db: ReturnType<typeof createDb>,
|
||||
companyId: string,
|
||||
agentId: string,
|
||||
permissionKey: "tasks:assign" | "tasks:assign_scope",
|
||||
permissionKey: PermissionKey,
|
||||
scope: Record<string, unknown> | null = null,
|
||||
) {
|
||||
await db.insert(companyMemberships).values({
|
||||
@@ -141,7 +141,7 @@ async function grantUserPermission(
|
||||
db: ReturnType<typeof createDb>,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
permissionKey: "tasks:assign" | "tasks:assign_scope",
|
||||
permissionKey: PermissionKey,
|
||||
scope: Record<string, unknown> | null = null,
|
||||
) {
|
||||
await db.insert(companyMemberships).values({
|
||||
@@ -224,24 +224,11 @@ describeEmbeddedPostgres("authorization service", () => {
|
||||
expect(decision.explanation).toContain("Allowed by explicit grant tasks:assign");
|
||||
});
|
||||
|
||||
it("allows agent grants for agent configuration decisions", async () => {
|
||||
const company = await createCompany(db, "AgentGrant");
|
||||
it("allows suggest grants to read peer agent configuration", async () => {
|
||||
const company = await createCompany(db, "AgentReadGrant");
|
||||
const actorAgent = await createAgent(db, company.id);
|
||||
const targetAgent = await createAgent(db, company.id);
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
principalId: actorAgent.id,
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
});
|
||||
await db.insert(principalPermissionGrants).values({
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
principalId: actorAgent.id,
|
||||
permissionKey: "agents:create",
|
||||
grantedByUserId: null,
|
||||
});
|
||||
await grantAgentPermission(db, company.id, actorAgent.id, "agents:suggest-changes");
|
||||
|
||||
const decision = await authorizationService(db).decide({
|
||||
actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_key" },
|
||||
@@ -249,8 +236,133 @@ describeEmbeddedPostgres("authorization service", () => {
|
||||
resource: { type: "agent", companyId: company.id, agentId: targetAgent.id },
|
||||
});
|
||||
|
||||
expect(decision.allowed).toBe(true);
|
||||
expect(decision.grant?.permissionKey).toBe("agents:create");
|
||||
expect(decision).toMatchObject({
|
||||
allowed: true,
|
||||
reason: "allow_explicit_grant",
|
||||
grant: {
|
||||
principalType: "agent",
|
||||
principalId: actorAgent.id,
|
||||
permissionKey: "agents:suggest-changes",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces direct or consented suggest grants for agent configuration changes", async () => {
|
||||
const company = await createCompany(db, "AgentChangeGrant");
|
||||
const directAgent = await createAgent(db, company.id);
|
||||
const suggestAgent = await createAgent(db, company.id);
|
||||
const noGrantAgent = await createAgent(db, company.id);
|
||||
const targetAgent = await createAgent(db, company.id);
|
||||
await grantAgentPermission(db, company.id, directAgent.id, "agents:configure");
|
||||
await grantAgentPermission(db, company.id, suggestAgent.id, "agents:suggest-changes");
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
principalId: noGrantAgent.id,
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
});
|
||||
|
||||
const authz = authorizationService(db);
|
||||
await expect(authz.decide({
|
||||
actor: { type: "agent", agentId: directAgent.id, companyId: company.id, source: "agent_key" },
|
||||
action: "agent_config:update",
|
||||
resource: { type: "agent", companyId: company.id, agentId: targetAgent.id },
|
||||
scope: { requiresChangeGrant: true },
|
||||
})).resolves.toMatchObject({
|
||||
allowed: true,
|
||||
reason: "allow_direct_change",
|
||||
grant: { permissionKey: "agents:configure" },
|
||||
});
|
||||
|
||||
await expect(authz.decide({
|
||||
actor: { type: "agent", agentId: suggestAgent.id, companyId: company.id, source: "agent_key" },
|
||||
action: "agent_config:update",
|
||||
resource: { type: "agent", companyId: company.id, agentId: targetAgent.id },
|
||||
scope: { requiresChangeGrant: true },
|
||||
})).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_missing_consent",
|
||||
grant: { permissionKey: "agents:suggest-changes" },
|
||||
});
|
||||
|
||||
await expect(authz.decide({
|
||||
actor: { type: "agent", agentId: suggestAgent.id, companyId: company.id, source: "agent_key" },
|
||||
action: "agent_config:update",
|
||||
resource: { type: "agent", companyId: company.id, agentId: targetAgent.id },
|
||||
scope: { requiresChangeGrant: true, consentedChange: true },
|
||||
})).resolves.toMatchObject({
|
||||
allowed: true,
|
||||
reason: "allow_consented_change",
|
||||
grant: { permissionKey: "agents:suggest-changes" },
|
||||
});
|
||||
|
||||
await expect(authz.decide({
|
||||
actor: { type: "agent", agentId: noGrantAgent.id, companyId: company.id, source: "agent_key" },
|
||||
action: "agent_config:update",
|
||||
resource: { type: "agent", companyId: company.id, agentId: targetAgent.id },
|
||||
scope: { requiresChangeGrant: true },
|
||||
})).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_no_grant",
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces direct or consented suggest grants for skill configuration changes", async () => {
|
||||
const company = await createCompany(db, "SkillChangeGrant");
|
||||
const directAgent = await createAgent(db, company.id);
|
||||
const suggestAgent = await createAgent(db, company.id);
|
||||
const noGrantAgent = await createAgent(db, company.id);
|
||||
await grantAgentPermission(db, company.id, directAgent.id, "skills:create");
|
||||
await grantAgentPermission(db, company.id, suggestAgent.id, "skills:suggest-changes");
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId: company.id,
|
||||
principalType: "agent",
|
||||
principalId: noGrantAgent.id,
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
});
|
||||
|
||||
const authz = authorizationService(db);
|
||||
await expect(authz.decide({
|
||||
actor: { type: "agent", agentId: directAgent.id, companyId: company.id, source: "agent_key" },
|
||||
action: "skill_config:update",
|
||||
resource: { type: "company", companyId: company.id },
|
||||
})).resolves.toMatchObject({
|
||||
allowed: true,
|
||||
reason: "allow_direct_change",
|
||||
grant: { permissionKey: "skills:create" },
|
||||
});
|
||||
|
||||
await expect(authz.decide({
|
||||
actor: { type: "agent", agentId: suggestAgent.id, companyId: company.id, source: "agent_key" },
|
||||
action: "skill_config:update",
|
||||
resource: { type: "company", companyId: company.id },
|
||||
})).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_missing_consent",
|
||||
grant: { permissionKey: "skills:suggest-changes" },
|
||||
});
|
||||
|
||||
await expect(authz.decide({
|
||||
actor: { type: "agent", agentId: suggestAgent.id, companyId: company.id, source: "agent_key" },
|
||||
action: "skill_config:update",
|
||||
resource: { type: "company", companyId: company.id },
|
||||
scope: { consentedChange: true },
|
||||
})).resolves.toMatchObject({
|
||||
allowed: true,
|
||||
reason: "allow_consented_change",
|
||||
grant: { permissionKey: "skills:suggest-changes" },
|
||||
});
|
||||
|
||||
await expect(authz.decide({
|
||||
actor: { type: "agent", agentId: noGrantAgent.id, companyId: company.id, source: "agent_key" },
|
||||
action: "skill_config:update",
|
||||
resource: { type: "company", companyId: company.id },
|
||||
})).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_no_grant",
|
||||
});
|
||||
});
|
||||
|
||||
it("denies cross-company agent decisions before grant evaluation", async () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
budgetPolicies,
|
||||
builtInManagedResources,
|
||||
companies,
|
||||
companyMemberships,
|
||||
companySkillVersions,
|
||||
companySkills,
|
||||
createDb,
|
||||
@@ -68,6 +69,7 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
await db.delete(companySkillVersions);
|
||||
await db.delete(companySkills);
|
||||
await db.delete(principalPermissionGrants);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(agentConfigRevisions);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(approvals);
|
||||
@@ -80,6 +82,14 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function permissionKeysForAgent(agentId: string) {
|
||||
const grants = await db
|
||||
.select()
|
||||
.from(principalPermissionGrants)
|
||||
.where(eq(principalPermissionGrants.principalId, agentId));
|
||||
return grants.map((grant) => grant.permissionKey).sort();
|
||||
}
|
||||
|
||||
async function seedCompany(options: { requireApproval?: boolean } = {}) {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
@@ -415,7 +425,7 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
|
||||
it("auto-provisions a paused Reflection Coach bundle with skill sync and a disabled routine", async () => {
|
||||
const companyId = await seedCompany({ requireApproval: false });
|
||||
await agentService(db).create(companyId, {
|
||||
const root = await agentService(db).create(companyId, {
|
||||
name: "CEO",
|
||||
role: "ceo",
|
||||
status: "idle",
|
||||
@@ -427,6 +437,12 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
|
||||
const result = await reconcileBuiltInAgentsOnStartup(db);
|
||||
expect(result.autoEnsured).toBeGreaterThanOrEqual(1);
|
||||
expect(result.defaultGrantsEnsured).toBeGreaterThanOrEqual(4);
|
||||
|
||||
const rootGrantKeys = await permissionKeysForAgent(root.id);
|
||||
expect(rootGrantKeys).toEqual(expect.arrayContaining(["agents:configure", "skills:create"]));
|
||||
expect(rootGrantKeys).not.toContain("agents:suggest-changes");
|
||||
expect(rootGrantKeys).not.toContain("skills:suggest-changes");
|
||||
|
||||
const state = await builtInAgentService(db).get(companyId, "reflection-coach");
|
||||
expect(state).toMatchObject({
|
||||
@@ -487,6 +503,10 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
cronExpression: "0 9 * * 1",
|
||||
timezone: "UTC",
|
||||
});
|
||||
const coachGrantKeys = await permissionKeysForAgent(state.agentId!);
|
||||
expect(coachGrantKeys).toEqual(expect.arrayContaining(["agents:suggest-changes", "skills:suggest-changes"]));
|
||||
expect(coachGrantKeys).not.toContain("agents:configure");
|
||||
expect(coachGrantKeys).not.toContain("skills:create");
|
||||
});
|
||||
|
||||
it("preserves new-agent approval gates during automatic Reflection Coach provisioning", async () => {
|
||||
@@ -757,11 +777,11 @@ describeEmbeddedPostgres("built-in agents", () => {
|
||||
timezone: "UTC",
|
||||
});
|
||||
|
||||
const grants = await db
|
||||
.select()
|
||||
.from(principalPermissionGrants)
|
||||
.where(eq(principalPermissionGrants.principalId, state.agentId!));
|
||||
expect(grants.map((grant) => grant.permissionKey)).not.toContain("tasks:assign");
|
||||
const grantKeys = await permissionKeysForAgent(state.agentId!);
|
||||
expect(grantKeys).toEqual(expect.arrayContaining(["agents:suggest-changes", "skills:suggest-changes"]));
|
||||
expect(grantKeys).not.toContain("tasks:assign");
|
||||
expect(grantKeys).not.toContain("agents:configure");
|
||||
expect(grantKeys).not.toContain("skills:create");
|
||||
});
|
||||
|
||||
it("controls the Reflection Coach routine schedule without enabling it by default", async () => {
|
||||
|
||||
@@ -480,6 +480,55 @@ describe("company portability", () => {
|
||||
expect(exported.warnings).toContain("Agent claudecoder PATH override was omitted from export because it is system-dependent.");
|
||||
});
|
||||
|
||||
it("exports agent permission grants through the Paperclip extension and manifest", async () => {
|
||||
const db = {
|
||||
select: vi.fn((selection: Record<string, unknown>) => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(async () => {
|
||||
if (!selection.permissionKey) return [];
|
||||
return [
|
||||
{
|
||||
principalId: "agent-1",
|
||||
permissionKey: "agents:suggest-changes",
|
||||
scope: null,
|
||||
},
|
||||
{
|
||||
principalId: "agent-1",
|
||||
permissionKey: "skills:create",
|
||||
scope: { targetAgentIds: ["agent-1"] },
|
||||
},
|
||||
];
|
||||
}),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
const portability = companyPortabilityService(db as any);
|
||||
|
||||
const exported = await portability.exportBundle("company-1", {
|
||||
include: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: false,
|
||||
issues: false,
|
||||
},
|
||||
});
|
||||
|
||||
const extension = asTextFile(exported.files[".paperclip.yaml"]);
|
||||
expect(extension).toContain("permissionGrants:");
|
||||
expect(extension).toContain('permissionKey: "agents:suggest-changes"');
|
||||
expect(extension).toContain('permissionKey: "skills:create"');
|
||||
expect(exported.manifest.agents.find((agent) => agent.slug === "claudecoder")?.permissionGrants).toEqual([
|
||||
{
|
||||
permissionKey: "agents:suggest-changes",
|
||||
scope: null,
|
||||
},
|
||||
{
|
||||
permissionKey: "skills:create",
|
||||
scope: { targetAgentIds: ["agent-1"] },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("exports hire approval policy only when approval is required", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
@@ -1559,6 +1608,90 @@ describe("company portability", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("imports agent permission grants from package metadata", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
agentSvc.list.mockResolvedValue([]);
|
||||
agentSvc.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
|
||||
id: "agent-imported",
|
||||
name: input.name,
|
||||
adapterType: input.adapterType,
|
||||
adapterConfig: input.adapterConfig,
|
||||
runtimeConfig: input.runtimeConfig,
|
||||
status: input.status,
|
||||
}));
|
||||
|
||||
await portability.importBundle({
|
||||
source: {
|
||||
type: "inline",
|
||||
files: {
|
||||
"COMPANY.md": [
|
||||
"---",
|
||||
"name: Import",
|
||||
"includes:",
|
||||
" - agents/coder/AGENTS.md",
|
||||
"---",
|
||||
"",
|
||||
].join("\n"),
|
||||
"agents/coder/AGENTS.md": [
|
||||
"---",
|
||||
"name: Coder",
|
||||
"slug: coder",
|
||||
"kind: agent",
|
||||
"---",
|
||||
"",
|
||||
"# Coder",
|
||||
"",
|
||||
].join("\n"),
|
||||
".paperclip.yaml": [
|
||||
"schema: paperclip/v1",
|
||||
"agents:",
|
||||
" coder:",
|
||||
" adapter:",
|
||||
" type: process",
|
||||
" config: {}",
|
||||
" permissionGrants:",
|
||||
" - permissionKey: agents:suggest-changes",
|
||||
" - permissionKey: skills:create",
|
||||
" scope:",
|
||||
" targetAgentIds:",
|
||||
" - agent-imported",
|
||||
"",
|
||||
].join("\n"),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
company: false,
|
||||
agents: true,
|
||||
projects: false,
|
||||
issues: false,
|
||||
},
|
||||
target: {
|
||||
mode: "existing_company",
|
||||
companyId: "company-1",
|
||||
},
|
||||
collisionStrategy: "rename",
|
||||
}, "user-1");
|
||||
|
||||
expect(accessSvc.setPrincipalPermission).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
"agent",
|
||||
"agent-imported",
|
||||
"agents:suggest-changes",
|
||||
true,
|
||||
"user-1",
|
||||
null,
|
||||
);
|
||||
expect(accessSvc.setPrincipalPermission).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
"agent",
|
||||
"agent-imported",
|
||||
"skills:create",
|
||||
true,
|
||||
"user-1",
|
||||
{ targetAgentIds: ["agent-imported"] },
|
||||
);
|
||||
});
|
||||
|
||||
it("removes import secrets created before a later import failure", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
agentSvc.list.mockResolvedValue([]);
|
||||
|
||||
@@ -8,6 +8,7 @@ const mockAgentService = vi.hoisted(() => ({
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
canUser: vi.fn(),
|
||||
decide: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -78,6 +79,30 @@ const mockReflectionCoachMutationGate = vi.hoisted(() => ({
|
||||
assertConsented: vi.fn(),
|
||||
}));
|
||||
|
||||
function allowSkillChangeDecision(reason = "allow_direct_change") {
|
||||
return {
|
||||
allowed: true,
|
||||
action: "skill_config:update",
|
||||
reason,
|
||||
explanation: "Allowed.",
|
||||
grant: {
|
||||
principalType: "agent",
|
||||
principalId: "agent-1",
|
||||
permissionKey: reason === "allow_consented_change" ? "skills:suggest-changes" : "skills:create",
|
||||
scope: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function denySkillChangeDecision(reason = "deny_no_grant", explanation = "Missing permission: skills:create or skills:suggest-changes.") {
|
||||
return {
|
||||
allowed: false,
|
||||
action: "skill_config:update",
|
||||
reason,
|
||||
explanation,
|
||||
};
|
||||
}
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
|
||||
|
||||
@@ -603,8 +628,9 @@ describe("company skill mutation permissions", () => {
|
||||
});
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
mockAccessService.canUser.mockResolvedValue(true);
|
||||
mockAccessService.decide.mockResolvedValue(allowSkillChangeDecision());
|
||||
mockAccessService.hasPermission.mockResolvedValue(false);
|
||||
mockReflectionCoachMutationGate.assertConsented.mockResolvedValue(false);
|
||||
mockReflectionCoachMutationGate.assertConsented.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("allows local board operators to mutate company skills", async () => {
|
||||
@@ -662,7 +688,10 @@ describe("company skill mutation permissions", () => {
|
||||
.send({})
|
||||
.expect(200);
|
||||
|
||||
expect(mockAccessService.canUser).toHaveBeenCalledWith("company-1", "board-user", "skills:create");
|
||||
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: "skill_config:update",
|
||||
resource: { type: "company", companyId: "company-1" },
|
||||
}));
|
||||
expect(mockAccessService.canUser).not.toHaveBeenCalledWith("company-1", "board-user", "agents:create");
|
||||
expect(mockCompanySkillService.createLocalSkill).toHaveBeenCalled();
|
||||
expect(mockCompanySkillService.importFromSource).toHaveBeenCalled();
|
||||
@@ -674,7 +703,7 @@ describe("company skill mutation permissions", () => {
|
||||
});
|
||||
|
||||
it("blocks board users without skills:create from mutating company skills", async () => {
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
mockAccessService.decide.mockResolvedValue(denySkillChangeDecision());
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "board",
|
||||
@@ -687,8 +716,11 @@ describe("company skill mutation permissions", () => {
|
||||
.send({ source: "https://github.com/vercel-labs/agent-browser" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
expect(res.body.error).toBe("Missing permission: skills:create");
|
||||
expect(mockAccessService.canUser).toHaveBeenCalledWith("company-1", "board-user", "skills:create");
|
||||
expect(res.body.error).toBe("Missing permission: skills:create or skills:suggest-changes.");
|
||||
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: "skill_config:update",
|
||||
resource: { type: "company", companyId: "company-1" },
|
||||
}));
|
||||
expect(mockAccessService.canUser).not.toHaveBeenCalledWith("company-1", "board-user", "agents:create");
|
||||
expect(mockCompanySkillService.importFromSource).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -934,7 +966,8 @@ describe("company skill mutation permissions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks same-company agents with skill creation disabled from mutating company skills", async () => {
|
||||
it("blocks same-company agents without skill change grants from mutating company skills", async () => {
|
||||
mockAccessService.decide.mockResolvedValue(denySkillChangeDecision());
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "55555555-5555-4555-8555-555555555555",
|
||||
companyId: "company-1",
|
||||
@@ -951,8 +984,11 @@ describe("company skill mutation permissions", () => {
|
||||
.send({ source: "https://github.com/vercel-labs/agent-browser" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
expect(res.body.error).toBe("Missing permission: skills:create");
|
||||
expect(mockAccessService.hasPermission).toHaveBeenCalledWith("company-1", "agent", "55555555-5555-4555-8555-555555555555", "skills:create");
|
||||
expect(res.body.error).toBe("Missing permission: skills:create or skills:suggest-changes.");
|
||||
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: "skill_config:update",
|
||||
resource: { type: "company", companyId: "company-1" },
|
||||
}));
|
||||
expect(mockAccessService.hasPermission).not.toHaveBeenCalledWith("company-1", "agent", "55555555-5555-4555-8555-555555555555", "agents:create");
|
||||
expect(mockCompanySkillService.importFromSource).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1139,11 +1175,12 @@ describe("company skill mutation permissions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("allows agents with canCreateSkills to mutate company skills", async () => {
|
||||
it("allows agents with direct skills:create grants to mutate company skills", async () => {
|
||||
mockAccessService.decide.mockResolvedValue(allowSkillChangeDecision("allow_direct_change"));
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "55555555-5555-4555-8555-555555555555",
|
||||
companyId: "company-1",
|
||||
permissions: { canCreateSkills: true },
|
||||
permissions: { canCreateSkills: false },
|
||||
});
|
||||
|
||||
const res = await request(await createApp({
|
||||
@@ -1156,25 +1193,23 @@ describe("company skill mutation permissions", () => {
|
||||
.send({ source: "https://github.com/vercel-labs/agent-browser" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: "skill_config:update",
|
||||
resource: { type: "company", companyId: "company-1" },
|
||||
}));
|
||||
expect(mockReflectionCoachMutationGate.assertConsented).not.toHaveBeenCalled();
|
||||
expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
"https://github.com/vercel-labs/agent-browser",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects Reflection Coach skill mutations when the server-side gate is not satisfied", async () => {
|
||||
it("rejects suggest-tier skill mutations when the consent gate is not satisfied", async () => {
|
||||
const { forbidden } = await import("../errors.js");
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "reflection-coach",
|
||||
companyId: "company-1",
|
||||
permissions: { canCreateSkills: true },
|
||||
metadata: {
|
||||
paperclipBuiltInAgent: {
|
||||
key: "reflection-coach",
|
||||
featureKeys: ["reflection-coach"],
|
||||
},
|
||||
},
|
||||
});
|
||||
mockAccessService.decide.mockResolvedValue(denySkillChangeDecision(
|
||||
"deny_missing_consent",
|
||||
"Permission skills:suggest-changes requires accepted change consent before applying this mutation.",
|
||||
));
|
||||
mockReflectionCoachMutationGate.assertConsented.mockRejectedValue(forbidden("gate required", {
|
||||
code: "reflection_coach_mutation_gate_required",
|
||||
}));
|
||||
@@ -1189,6 +1224,7 @@ describe("company skill mutation permissions", () => {
|
||||
.send({ name: "Reflection Draft", slug: "reflection-draft", markdown: "# Draft" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
expect(res.body.error).toBe("Permission skills:suggest-changes requires accepted change consent before applying this mutation.");
|
||||
expect(mockReflectionCoachMutationGate.assertConsented).toHaveBeenCalledWith({
|
||||
companyId: "company-1",
|
||||
actorAgentId: "reflection-coach",
|
||||
@@ -1198,7 +1234,13 @@ describe("company skill mutation permissions", () => {
|
||||
expect(mockCompanySkillService.createLocalSkill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows same-company agents with missing skill creation permission to mutate company skills", async () => {
|
||||
it("allows suggest-tier skill mutations after accepted change consent", async () => {
|
||||
mockAccessService.decide
|
||||
.mockResolvedValueOnce(denySkillChangeDecision(
|
||||
"deny_missing_consent",
|
||||
"Permission skills:suggest-changes requires accepted change consent before applying this mutation.",
|
||||
))
|
||||
.mockResolvedValueOnce(allowSkillChangeDecision("allow_consented_change"));
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "55555555-5555-4555-8555-555555555555",
|
||||
companyId: "company-1",
|
||||
@@ -1215,57 +1257,29 @@ describe("company skill mutation permissions", () => {
|
||||
.send({ source: "https://github.com/vercel-labs/agent-browser" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(mockReflectionCoachMutationGate.assertConsented).toHaveBeenCalledWith({
|
||||
companyId: "company-1",
|
||||
actorAgentId: "agent-1",
|
||||
actorRunId: "run-1",
|
||||
targetKeys: ["skill-import:https://github.com/vercel-labs/agent-browser"],
|
||||
});
|
||||
expect(mockAccessService.decide).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
action: "skill_config:update",
|
||||
resource: { type: "company", companyId: "company-1" },
|
||||
scope: { consentedChange: true },
|
||||
}));
|
||||
expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
"https://github.com/vercel-labs/agent-browser",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows agents with explicit skills:create grants to mutate company skills", async () => {
|
||||
it("blocks same-company agents without skill change or suggest grants", async () => {
|
||||
mockAccessService.decide.mockResolvedValue(denySkillChangeDecision());
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "55555555-5555-4555-8555-555555555555",
|
||||
companyId: "company-1",
|
||||
permissions: { canCreateSkills: false },
|
||||
});
|
||||
mockAccessService.hasPermission.mockImplementation(async (
|
||||
_companyId: string,
|
||||
_principalType: string,
|
||||
_principalId: string,
|
||||
key: string,
|
||||
) => {
|
||||
return key === "skills:create";
|
||||
});
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "agent",
|
||||
agentId: "55555555-5555-4555-8555-555555555555",
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
}))
|
||||
.post("/api/companies/company-1/skills/import")
|
||||
.send({ source: "https://github.com/vercel-labs/agent-browser" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(mockAccessService.hasPermission).toHaveBeenCalledWith("company-1", "agent", "55555555-5555-4555-8555-555555555555", "skills:create");
|
||||
expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
"https://github.com/vercel-labs/agent-browser",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not allow explicit agents:create grants to mutate company skills", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "55555555-5555-4555-8555-555555555555",
|
||||
companyId: "company-1",
|
||||
permissions: { canCreateSkills: false },
|
||||
});
|
||||
mockAccessService.hasPermission.mockImplementation(async (
|
||||
_companyId: string,
|
||||
_principalType: string,
|
||||
_principalId: string,
|
||||
key: string,
|
||||
) => {
|
||||
return key === "agents:create";
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
const res = await request(await createApp({
|
||||
@@ -1278,8 +1292,44 @@ describe("company skill mutation permissions", () => {
|
||||
.send({ source: "https://github.com/vercel-labs/agent-browser" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
expect(res.body.error).toBe("Missing permission: skills:create");
|
||||
expect(mockAccessService.hasPermission).toHaveBeenCalledWith("company-1", "agent", "55555555-5555-4555-8555-555555555555", "skills:create");
|
||||
expect(res.body.error).toBe("Missing permission: skills:create or skills:suggest-changes.");
|
||||
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: "skill_config:update",
|
||||
resource: { type: "company", companyId: "company-1" },
|
||||
}));
|
||||
expect(mockCompanySkillService.importFromSource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not allow explicit agents:create grants to mutate company skills", async () => {
|
||||
mockAccessService.decide.mockResolvedValue(denySkillChangeDecision());
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
permissions: { canCreateSkills: false },
|
||||
});
|
||||
mockAccessService.hasPermission.mockImplementation(async (
|
||||
_companyId: string,
|
||||
_principalType: string,
|
||||
_principalId: string,
|
||||
key: string,
|
||||
) => key === "agents:create");
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
}))
|
||||
.post("/api/companies/company-1/skills/import")
|
||||
.send({ source: "https://github.com/vercel-labs/agent-browser" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
expect(res.body.error).toBe("Missing permission: skills:create or skills:suggest-changes.");
|
||||
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
|
||||
action: "skill_config:update",
|
||||
resource: { type: "company", companyId: "company-1" },
|
||||
}));
|
||||
expect(mockAccessService.hasPermission).not.toHaveBeenCalledWith("company-1", "agent", "agent-1", "agents:create");
|
||||
expect(mockCompanySkillService.importFromSource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
+92
-81
@@ -41,6 +41,7 @@ import {
|
||||
agentInstructionsService,
|
||||
accessService,
|
||||
approvalService,
|
||||
builtInAgentService,
|
||||
companySkillService,
|
||||
budgetService,
|
||||
heartbeatService,
|
||||
@@ -52,7 +53,7 @@ import {
|
||||
syncInstructionsBundleConfigFromFilePath,
|
||||
workspaceOperationService,
|
||||
} from "../services/index.js";
|
||||
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
||||
import { conflict, forbidden, HttpError, notFound, unprocessable } from "../errors.js";
|
||||
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
|
||||
import {
|
||||
assertNoAgentHostWorkspaceCommandMutation,
|
||||
@@ -99,8 +100,8 @@ import { recoveryService } from "../services/recovery/service.js";
|
||||
import { resolveCoreTrustPreset } from "../services/trust-preset-resolver.js";
|
||||
import { readObject } from "../lib/objects.js";
|
||||
import { listInvalidOrgChainDescendantIds } from "../services/agent-invokability.js";
|
||||
import { readBuiltInAgentMarker } from "../services/built-in-agent-metadata.js";
|
||||
import {
|
||||
AGENT_PROFILE_CHANGE_CONSENT_FIELDS,
|
||||
agentInstructionsChangeTargetKey,
|
||||
agentProfileChangeTargetKey,
|
||||
changeConsentGateService,
|
||||
@@ -696,30 +697,22 @@ export function agentRoutes(
|
||||
// read-only operation available to any board (human) member of the
|
||||
// company. Responses go through `redactAgentConfiguration` so secrets
|
||||
// are never exposed. Mutations and environment probes still gate on
|
||||
// agents:create via assertCanCreateAgentsForCompany / assertCanUpdateAgent.
|
||||
// agents:create or agents:configure via the mutating route helpers.
|
||||
//
|
||||
// For AGENT actors we keep the previous, stricter gate: an agent must
|
||||
// either have an explicit `agents:create` grant or the legacy
|
||||
// `canCreateAgents` permission on its own record. Agents are
|
||||
// non-human principals — they should not be able to introspect peer
|
||||
// agents' configurations just by virtue of being in the same company.
|
||||
// For AGENT actors we keep a stricter gate: an agent must have either
|
||||
// agents:configure or agents:suggest-changes before it can inspect peer
|
||||
// agent configuration for a proposed diff.
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (req.actor.type === "agent") {
|
||||
if (!req.actor.agentId) throw forbidden("Agent authentication required");
|
||||
const actorAgent = await svc.getById(req.actor.agentId);
|
||||
if (!actorAgent || actorAgent.companyId !== companyId) {
|
||||
throw forbidden("Agent key cannot access another company");
|
||||
const decision = await access.decide({
|
||||
actor: req.actor,
|
||||
action: "agent_config:read",
|
||||
resource: { type: "company", companyId },
|
||||
});
|
||||
if (!decision.allowed) {
|
||||
throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
|
||||
}
|
||||
const allowedByGrant = await access.hasPermission(
|
||||
companyId,
|
||||
"agent",
|
||||
actorAgent.id,
|
||||
"agents:create",
|
||||
);
|
||||
if (!allowedByGrant && !canCreateAgents(actorAgent)) {
|
||||
throw forbidden("Missing permission: can create agents");
|
||||
}
|
||||
return actorAgent;
|
||||
return req.actor.agentId ? await svc.getById(req.actor.agentId) : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -739,26 +732,21 @@ export function agentRoutes(
|
||||
|
||||
async function actorCanReadConfigurationsForCompany(req: Request, companyId: string) {
|
||||
// Mirrors assertCanReadConfigurations but returns a boolean instead of
|
||||
// throwing. Board actors only need company access; agent actors must
|
||||
// still pass the agents:create gate (explicit grant or canCreateAgents
|
||||
// on their own record) so peer agents cannot snoop each others'
|
||||
// configurations.
|
||||
// throwing. Board actors only need company access; agent actors must pass
|
||||
// the agent configuration read grant ladder so peer agents cannot snoop
|
||||
// each others' configurations.
|
||||
try {
|
||||
assertCompanyAccess(req, companyId);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (req.actor.type === "board") return true;
|
||||
if (!req.actor.agentId) return false;
|
||||
const actorAgent = await svc.getById(req.actor.agentId);
|
||||
if (!actorAgent || actorAgent.companyId !== companyId) return false;
|
||||
const allowedByGrant = await access.hasPermission(
|
||||
companyId,
|
||||
"agent",
|
||||
actorAgent.id,
|
||||
"agents:create",
|
||||
);
|
||||
return allowedByGrant || canCreateAgents(actorAgent);
|
||||
const decision = await access.decide({
|
||||
actor: req.actor,
|
||||
action: "agent_config:read",
|
||||
resource: { type: "company", companyId },
|
||||
});
|
||||
return decision.allowed;
|
||||
}
|
||||
|
||||
async function buildSkippedWakeupResponse(
|
||||
@@ -853,20 +841,14 @@ export function agentRoutes(
|
||||
}
|
||||
if (actorAgent.id === targetAgent.id) return;
|
||||
|
||||
const builtInMarker = readBuiltInAgentMarker(actorAgent.metadata);
|
||||
if (builtInMarker?.key === "reflection-coach") {
|
||||
return;
|
||||
}
|
||||
const decision = await access.decide({
|
||||
actor: req.actor,
|
||||
action: "agent_config:read",
|
||||
resource: { type: "agent", companyId: targetAgent.companyId, agentId: targetAgent.id },
|
||||
});
|
||||
if (decision.allowed) return;
|
||||
|
||||
const allowedByGrant = await access.hasPermission(
|
||||
targetAgent.companyId,
|
||||
"agent",
|
||||
actorAgent.id,
|
||||
"agents:create",
|
||||
);
|
||||
if (allowedByGrant || canCreateAgents(actorAgent)) return;
|
||||
|
||||
throw forbidden("Missing permission: can create agents");
|
||||
throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
|
||||
}
|
||||
|
||||
function assertKnownAdapterType(type: string | null | undefined): string {
|
||||
@@ -1329,48 +1311,70 @@ export function agentRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
async function assertCanManageInstructionsPath(req: Request, targetAgent: { id: string; companyId: string }) {
|
||||
async function assertCanApplyProtectedAgentChange(
|
||||
req: Request,
|
||||
targetAgent: { id: string; companyId: string },
|
||||
targetKeys: string[],
|
||||
) {
|
||||
assertCompanyAccess(req, targetAgent.companyId);
|
||||
if (req.actor.type === "board") {
|
||||
await assertBoardCanManageAgentsForCompany(req, targetAgent.companyId);
|
||||
const changeScope = { requiresChangeGrant: true };
|
||||
const decision = await access.decide({
|
||||
actor: req.actor,
|
||||
action: "agent_config:update",
|
||||
resource: { type: "agent", companyId: targetAgent.companyId, agentId: targetAgent.id },
|
||||
scope: changeScope,
|
||||
});
|
||||
if (decision.allowed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await isReflectionCoachActor(req, targetAgent.companyId)) {
|
||||
const allowedByReflectionCoachGate = await changeConsentGateService(db).assertConsented({
|
||||
companyId: targetAgent.companyId,
|
||||
actorAgentId: req.actor.agentId,
|
||||
actorRunId: req.actor.runId ?? null,
|
||||
targetKeys: [agentInstructionsChangeTargetKey(targetAgent.id)],
|
||||
if (decision.reason === "deny_missing_consent" && req.actor.type === "agent" && targetKeys.length > 0) {
|
||||
try {
|
||||
await changeConsentGateService(db).assertConsented({
|
||||
companyId: targetAgent.companyId,
|
||||
actorAgentId: req.actor.agentId,
|
||||
actorRunId: req.actor.runId ?? null,
|
||||
targetKeys,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError && err.status === 403) {
|
||||
throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const consentedDecision = await access.decide({
|
||||
actor: req.actor,
|
||||
action: "agent_config:update",
|
||||
resource: { type: "agent", companyId: targetAgent.companyId, agentId: targetAgent.id },
|
||||
scope: { ...changeScope, consentedChange: true },
|
||||
});
|
||||
if (allowedByReflectionCoachGate) return;
|
||||
if (consentedDecision.allowed) {
|
||||
return;
|
||||
}
|
||||
throw forbidden(consentedDecision.explanation, authorizationDeniedDetails(consentedDecision));
|
||||
}
|
||||
|
||||
throw forbidden(
|
||||
"Only board-authenticated callers can manage instructions path or bundle configuration",
|
||||
throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
|
||||
}
|
||||
|
||||
async function assertCanManageInstructionsPath(req: Request, targetAgent: { id: string; companyId: string }) {
|
||||
await assertCanApplyProtectedAgentChange(
|
||||
req,
|
||||
targetAgent,
|
||||
[agentInstructionsChangeTargetKey(targetAgent.id)],
|
||||
);
|
||||
}
|
||||
|
||||
async function isReflectionCoachActor(req: Request, companyId: string) {
|
||||
if (req.actor.type !== "agent" || !req.actor.agentId) return false;
|
||||
const actorAgent = await svc.getById(req.actor.agentId);
|
||||
if (!actorAgent || actorAgent.companyId !== companyId) {
|
||||
throw forbidden("Agent key cannot access another company");
|
||||
}
|
||||
return readBuiltInAgentMarker(actorAgent.metadata)?.key === "reflection-coach";
|
||||
}
|
||||
|
||||
async function assertReflectionCoachAgentDescriptionMutationGate(
|
||||
async function assertCanApplyAgentProfileChange(
|
||||
req: Request,
|
||||
targetAgent: { id: string; companyId: string },
|
||||
) {
|
||||
if (!(await isReflectionCoachActor(req, targetAgent.companyId))) return;
|
||||
await changeConsentGateService(db).assertConsented({
|
||||
companyId: targetAgent.companyId,
|
||||
actorAgentId: req.actor.type === "agent" ? req.actor.agentId : null,
|
||||
actorRunId: req.actor.runId ?? null,
|
||||
targetKeys: [agentProfileChangeTargetKey(targetAgent.id)],
|
||||
});
|
||||
await assertCanApplyProtectedAgentChange(
|
||||
req,
|
||||
targetAgent,
|
||||
[agentProfileChangeTargetKey(targetAgent.id)],
|
||||
);
|
||||
}
|
||||
|
||||
function assertNoAgentInstructionsConfigMutation(
|
||||
@@ -2533,6 +2537,7 @@ export function agentRoutes(
|
||||
agent.id,
|
||||
req.actor.type === "board" ? (req.actor.userId ?? null) : null,
|
||||
);
|
||||
await builtInAgentService(db).ensureCompanyDefaultAgentGrants(companyId);
|
||||
|
||||
if (agent.budgetMonthlyCents > 0) {
|
||||
await budgets.upsertPolicy(
|
||||
@@ -2861,7 +2866,7 @@ export function agentRoutes(
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
await assertCanUpdateAgent(req, existing);
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
|
||||
if (hasOwn(req.body as object, "permissions")) {
|
||||
res.status(422).json({ error: "Use /api/agents/:id/permissions for permission changes" });
|
||||
@@ -2973,8 +2978,14 @@ export function agentRoutes(
|
||||
},
|
||||
);
|
||||
}
|
||||
if (touchesAgentProfileChangeConsentFields(patchData)) {
|
||||
await assertReflectionCoachAgentDescriptionMutationGate(req, existing);
|
||||
const touchesProfileFields = touchesAgentProfileChangeConsentFields(patchData);
|
||||
const profileOnlyChange = touchesProfileFields && Object.keys(patchData).every((key) =>
|
||||
(AGENT_PROFILE_CHANGE_CONSENT_FIELDS as readonly string[]).includes(key),
|
||||
);
|
||||
if (profileOnlyChange) {
|
||||
await assertCanApplyAgentProfileChange(req, existing);
|
||||
} else {
|
||||
await assertCanUpdateAgent(req, existing);
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
import { forbidden } from "../errors.js";
|
||||
import { assertAuthenticated, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { getTelemetryClient } from "../telemetry.js";
|
||||
import { readBuiltInAgentMarker } from "../services/built-in-agent-metadata.js";
|
||||
import {
|
||||
changeConsentGateService,
|
||||
skillChangeTargetKey,
|
||||
@@ -59,11 +58,6 @@ export function companySkillRoutes(db: Db) {
|
||||
const issues = issueService(db);
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
function canCreateSkills(agent: { permissions: Record<string, unknown> | null | undefined }) {
|
||||
if (!agent.permissions || typeof agent.permissions !== "object") return true;
|
||||
return (agent.permissions as Record<string, unknown>).canCreateSkills !== false;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
@@ -126,56 +120,42 @@ export function companySkillRoutes(db: Db) {
|
||||
return targetKeys;
|
||||
}
|
||||
|
||||
async function isReflectionCoachActor(req: Request, companyId: string) {
|
||||
if (req.actor.type !== "agent" || !req.actor.agentId) return false;
|
||||
const actorAgent = await agents.getById(req.actor.agentId);
|
||||
if (!actorAgent || actorAgent.companyId !== companyId) {
|
||||
throw forbidden("Agent key cannot access another company");
|
||||
}
|
||||
return readBuiltInAgentMarker(actorAgent.metadata)?.key === "reflection-coach";
|
||||
}
|
||||
|
||||
async function assertReflectionCoachSkillMutationGate(req: Request, companyId: string, targetKeys: string[]) {
|
||||
if (!(await isReflectionCoachActor(req, companyId))) return;
|
||||
await changeConsentGateService(db).assertConsented({
|
||||
companyId,
|
||||
actorAgentId: req.actor.type === "agent" ? req.actor.agentId : null,
|
||||
actorRunId: req.actor.runId ?? null,
|
||||
targetKeys,
|
||||
});
|
||||
}
|
||||
|
||||
async function assertCanMutateCompanySkills(req: Request, companyId: string) {
|
||||
async function assertCanMutateCompanySkills(req: Request, companyId: string, targetKeys: string[]) {
|
||||
assertCompanyAccess(req, companyId);
|
||||
const decision = await access.decide({
|
||||
actor: req.actor,
|
||||
action: "skill_config:update",
|
||||
resource: { type: "company", companyId },
|
||||
});
|
||||
if (decision.allowed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.actor.type === "board") {
|
||||
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
|
||||
const allowed = await access.canUser(companyId, req.actor.userId, "skills:create");
|
||||
if (!allowed) {
|
||||
throw forbidden("Missing permission: skills:create");
|
||||
if (decision.reason === "deny_missing_consent" && req.actor.type === "agent" && targetKeys.length > 0) {
|
||||
try {
|
||||
await changeConsentGateService(db).assertConsented({
|
||||
companyId,
|
||||
actorAgentId: req.actor.agentId,
|
||||
actorRunId: req.actor.runId ?? null,
|
||||
targetKeys,
|
||||
});
|
||||
} catch {
|
||||
throw forbidden(decision.explanation, { reason: decision.reason });
|
||||
}
|
||||
return;
|
||||
|
||||
const consentedDecision = await access.decide({
|
||||
actor: req.actor,
|
||||
action: "skill_config:update",
|
||||
resource: { type: "company", companyId },
|
||||
scope: { consentedChange: true },
|
||||
});
|
||||
if (consentedDecision.allowed) {
|
||||
return;
|
||||
}
|
||||
throw forbidden(consentedDecision.explanation, { reason: consentedDecision.reason });
|
||||
}
|
||||
|
||||
if (!req.actor.agentId) {
|
||||
throw forbidden("Agent authentication required");
|
||||
}
|
||||
|
||||
const actorAgent = await agents.getById(req.actor.agentId);
|
||||
if (!actorAgent || actorAgent.companyId !== companyId) {
|
||||
throw forbidden("Agent key cannot access another company");
|
||||
}
|
||||
|
||||
if (canCreateSkills(actorAgent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedByGrant = await access.hasPermission(companyId, "agent", actorAgent.id, "skills:create");
|
||||
if (allowedByGrant) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw forbidden("Missing permission: skills:create");
|
||||
throw forbidden(decision.explanation, { reason: decision.reason });
|
||||
}
|
||||
|
||||
async function assertCanStartSkillTestRuns(req: Request, companyId: string) {
|
||||
@@ -655,8 +635,7 @@ export function companySkillRoutes(db: Db) {
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, skillMutationTargets({ skillId }));
|
||||
await assertCanMutateCompanySkills(req, companyId, skillMutationTargets({ skillId }));
|
||||
const result = await svc.createVersion(companyId, skillId, req.body, skillActor(req));
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
@@ -724,8 +703,7 @@ export function companySkillRoutes(db: Db) {
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, skillMutationTargets({
|
||||
await assertCanMutateCompanySkills(req, companyId, skillMutationTargets({
|
||||
skillId,
|
||||
slug: req.body.slug,
|
||||
}));
|
||||
@@ -858,8 +836,7 @@ export function companySkillRoutes(db: Db) {
|
||||
validate(companySkillCreateSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, skillMutationTargets({
|
||||
await assertCanMutateCompanySkills(req, companyId, skillMutationTargets({
|
||||
slug: req.body.slug,
|
||||
}));
|
||||
const result = await svc.createLocalSkill(companyId, req.body, skillActor(req));
|
||||
@@ -890,8 +867,7 @@ export function companySkillRoutes(db: Db) {
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, skillMutationTargets({ skillId }));
|
||||
await assertCanMutateCompanySkills(req, companyId, skillMutationTargets({ skillId }));
|
||||
const result = await svc.updateSkill(companyId, skillId, req.body);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
@@ -921,8 +897,7 @@ export function companySkillRoutes(db: Db) {
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, skillMutationTargets({ skillId }));
|
||||
await assertCanMutateCompanySkills(req, companyId, skillMutationTargets({ skillId }));
|
||||
const result = await svc.updateFile(
|
||||
companyId,
|
||||
skillId,
|
||||
@@ -986,9 +961,8 @@ export function companySkillRoutes(db: Db) {
|
||||
validate(companySkillImportSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
const source = String(req.body.source ?? "");
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, skillMutationTargets({ source }));
|
||||
await assertCanMutateCompanySkills(req, companyId, skillMutationTargets({ source }));
|
||||
const result = await svc.importFromSource(companyId, source);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
@@ -1027,8 +1001,7 @@ export function companySkillRoutes(db: Db) {
|
||||
validate(companySkillInstallCatalogSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, skillMutationTargets({
|
||||
await assertCanMutateCompanySkills(req, companyId, skillMutationTargets({
|
||||
catalogSkillId: req.body.catalogSkillId,
|
||||
slug: req.body.slug,
|
||||
}));
|
||||
@@ -1063,8 +1036,7 @@ export function companySkillRoutes(db: Db) {
|
||||
validate(companySkillProjectScanRequestSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, skillMutationTargets({ scanProjects: true }));
|
||||
await assertCanMutateCompanySkills(req, companyId, skillMutationTargets({ scanProjects: true }));
|
||||
const result = await svc.scanProjectWorkspaces(companyId, req.body);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
@@ -1095,8 +1067,7 @@ export function companySkillRoutes(db: Db) {
|
||||
router.delete("/companies/:companyId/skills/:skillId", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, skillMutationTargets({ skillId }));
|
||||
await assertCanMutateCompanySkills(req, companyId, skillMutationTargets({ skillId }));
|
||||
const result = await svc.deleteSkill(companyId, skillId);
|
||||
if (!result) {
|
||||
res.status(404).json({ error: "Skill not found" });
|
||||
@@ -1127,8 +1098,7 @@ export function companySkillRoutes(db: Db) {
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, skillMutationTargets({ skillId }));
|
||||
await assertCanMutateCompanySkills(req, companyId, skillMutationTargets({ skillId }));
|
||||
const result = await svc.auditSkill(companyId, skillId);
|
||||
if (!result) {
|
||||
res.status(404).json({ error: "Skill not found" });
|
||||
@@ -1164,8 +1134,7 @@ export function companySkillRoutes(db: Db) {
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, skillMutationTargets({ skillId }));
|
||||
await assertCanMutateCompanySkills(req, companyId, skillMutationTargets({ skillId }));
|
||||
const before = await svc.getById(companyId, skillId);
|
||||
const result = await svc.installUpdate(companyId, skillId, req.body);
|
||||
if (!result) {
|
||||
@@ -1205,8 +1174,7 @@ export function companySkillRoutes(db: Db) {
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, skillMutationTargets({ skillId }));
|
||||
await assertCanMutateCompanySkills(req, companyId, skillMutationTargets({ skillId }));
|
||||
const before = await svc.getById(companyId, skillId);
|
||||
const result = await svc.resetSkill(companyId, skillId, req.body);
|
||||
if (!result) {
|
||||
|
||||
@@ -56,6 +56,7 @@ export type AuthorizationAction =
|
||||
| PermissionKey
|
||||
| "agent_config:read"
|
||||
| "agent_config:update"
|
||||
| "skill_config:update"
|
||||
| "agent:read"
|
||||
| "agent:wake"
|
||||
| "company_scope:read"
|
||||
@@ -93,6 +94,8 @@ export type AuthorizationDecision = {
|
||||
| "allow_local_board"
|
||||
| "allow_instance_admin"
|
||||
| "allow_explicit_grant"
|
||||
| "allow_direct_change"
|
||||
| "allow_consented_change"
|
||||
| "allow_legacy_agent_creator"
|
||||
| "allow_issue_mention_grant"
|
||||
| "allow_self"
|
||||
@@ -104,6 +107,8 @@ export type AuthorizationDecision = {
|
||||
| "deny_company_boundary"
|
||||
| "deny_missing_membership"
|
||||
| "deny_missing_grant"
|
||||
| "deny_missing_consent"
|
||||
| "deny_no_grant"
|
||||
| "deny_policy_restricted"
|
||||
| "deny_low_trust_boundary"
|
||||
| "deny_scope"
|
||||
@@ -125,7 +130,9 @@ function companyIdForResource(resource: AuthorizationResource) {
|
||||
}
|
||||
|
||||
function permissionForAction(action: AuthorizationAction): PermissionKey | null {
|
||||
if (action === "agent_config:read" || action === "agent_config:update") return "agents:create";
|
||||
if (action === "agent_config:read" || action === "agent_config:update" || action === "skill_config:update") {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
action === "agent:read" ||
|
||||
action === "agent:wake" ||
|
||||
@@ -466,6 +473,10 @@ function activeResponsibleUserCanAuthorizeIssueAction(
|
||||
);
|
||||
}
|
||||
|
||||
function scopeBoolean(scope: Record<string, unknown> | null | undefined, key: string) {
|
||||
return scope?.[key] === true;
|
||||
}
|
||||
|
||||
export function authorizationDeniedDetails(decision: AuthorizationDecision) {
|
||||
return {
|
||||
...(decision.code ? { code: decision.code } : {}),
|
||||
@@ -1305,6 +1316,94 @@ export function authorizationService(db: Db) {
|
||||
return broadDecision;
|
||||
}
|
||||
|
||||
async function decideWithAgentConfigReadGrant(
|
||||
principalType: PrincipalType,
|
||||
principalId: string,
|
||||
): Promise<AuthorizationDecision> {
|
||||
const configureDecision = await decidePrincipalGrant({
|
||||
companyId,
|
||||
principalType,
|
||||
principalId,
|
||||
action: input.action,
|
||||
permissionKey: "agents:configure",
|
||||
scope: input.scope,
|
||||
});
|
||||
if (configureDecision.allowed || configureDecision.reason === "deny_missing_membership") {
|
||||
return configureDecision;
|
||||
}
|
||||
|
||||
const suggestDecision = await decidePrincipalGrant({
|
||||
companyId,
|
||||
principalType,
|
||||
principalId,
|
||||
action: input.action,
|
||||
permissionKey: "agents:suggest-changes",
|
||||
scope: input.scope,
|
||||
});
|
||||
if (suggestDecision.allowed || configureDecision.reason === "deny_missing_grant") {
|
||||
return suggestDecision;
|
||||
}
|
||||
return configureDecision;
|
||||
}
|
||||
|
||||
async function decideWithProtectedChangeGrants(
|
||||
principalType: PrincipalType,
|
||||
principalId: string,
|
||||
keys: { direct: PermissionKey; suggest: PermissionKey },
|
||||
): Promise<AuthorizationDecision> {
|
||||
const directDecision = await decidePrincipalGrant({
|
||||
companyId,
|
||||
principalType,
|
||||
principalId,
|
||||
action: input.action,
|
||||
permissionKey: keys.direct,
|
||||
scope: input.scope,
|
||||
});
|
||||
if (directDecision.allowed) {
|
||||
return allow({
|
||||
action: input.action,
|
||||
reason: "allow_direct_change",
|
||||
explanation: `Allowed by direct change permission ${keys.direct}.`,
|
||||
grant: directDecision.grant,
|
||||
});
|
||||
}
|
||||
if (directDecision.reason === "deny_missing_membership") return directDecision;
|
||||
|
||||
const suggestDecision = await decidePrincipalGrant({
|
||||
companyId,
|
||||
principalType,
|
||||
principalId,
|
||||
action: input.action,
|
||||
permissionKey: keys.suggest,
|
||||
scope: input.scope,
|
||||
});
|
||||
if (suggestDecision.allowed) {
|
||||
if (scopeBoolean(input.scope, "consentedChange")) {
|
||||
return allow({
|
||||
action: input.action,
|
||||
reason: "allow_consented_change",
|
||||
explanation: `Allowed by suggest permission ${keys.suggest} after accepted change consent.`,
|
||||
grant: suggestDecision.grant,
|
||||
});
|
||||
}
|
||||
return deny({
|
||||
action: input.action,
|
||||
reason: "deny_missing_consent",
|
||||
explanation: `Permission ${keys.suggest} requires accepted change consent before applying this mutation.`,
|
||||
grant: suggestDecision.grant,
|
||||
});
|
||||
}
|
||||
if (suggestDecision.reason === "deny_missing_membership") return suggestDecision;
|
||||
if (directDecision.reason === "deny_scope") return directDecision;
|
||||
if (suggestDecision.reason === "deny_scope") return suggestDecision;
|
||||
|
||||
return deny({
|
||||
action: input.action,
|
||||
reason: "deny_no_grant",
|
||||
explanation: `Missing permission: ${keys.direct} or ${keys.suggest}.`,
|
||||
});
|
||||
}
|
||||
|
||||
async function denyForAssignmentPolicyIfNeeded(
|
||||
policyEffect: AssignmentPolicyEffect,
|
||||
): Promise<AuthorizationDecision | null> {
|
||||
@@ -1465,6 +1564,21 @@ export function authorizationService(db: Db) {
|
||||
if (policyEffect.kind === "restricted") return denyRestrictedAssignmentPolicy(policyEffect);
|
||||
return grantDecision;
|
||||
}
|
||||
if (input.action === "agent_config:read") {
|
||||
return decideWithAgentConfigReadGrant("user", input.actor.userId);
|
||||
}
|
||||
if (input.action === "agent_config:update") {
|
||||
return decideWithProtectedChangeGrants("user", input.actor.userId, {
|
||||
direct: "agents:configure",
|
||||
suggest: "agents:suggest-changes",
|
||||
});
|
||||
}
|
||||
if (input.action === "skill_config:update") {
|
||||
return decideWithProtectedChangeGrants("user", input.actor.userId, {
|
||||
direct: "skills:create",
|
||||
suggest: "skills:suggest-changes",
|
||||
});
|
||||
}
|
||||
return decidePrincipalGrant({
|
||||
companyId,
|
||||
principalType: "user",
|
||||
@@ -1641,7 +1755,8 @@ export function authorizationService(db: Db) {
|
||||
if (
|
||||
input.action === "agent_config:update" &&
|
||||
input.resource.type === "agent" &&
|
||||
input.resource.agentId === actorAgentId
|
||||
input.resource.agentId === actorAgentId &&
|
||||
!scopeBoolean(input.scope, "requiresChangeGrant")
|
||||
) {
|
||||
return allow({
|
||||
action: input.action,
|
||||
@@ -1650,6 +1765,31 @@ export function authorizationService(db: Db) {
|
||||
});
|
||||
}
|
||||
|
||||
if (input.action === "agent_config:read") {
|
||||
if (input.resource.type === "agent" && input.resource.agentId === actorAgentId) {
|
||||
return allow({
|
||||
action: input.action,
|
||||
reason: "allow_self",
|
||||
explanation: "Allowed because the actor is reading its own agent configuration.",
|
||||
});
|
||||
}
|
||||
return decideWithAgentConfigReadGrant("agent", actorAgentId);
|
||||
}
|
||||
|
||||
if (input.action === "agent_config:update") {
|
||||
return decideWithProtectedChangeGrants("agent", actorAgentId, {
|
||||
direct: "agents:configure",
|
||||
suggest: "agents:suggest-changes",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.action === "skill_config:update") {
|
||||
return decideWithProtectedChangeGrants("agent", actorAgentId, {
|
||||
direct: "skills:create",
|
||||
suggest: "skills:suggest-changes",
|
||||
});
|
||||
}
|
||||
|
||||
if (permissionKey) {
|
||||
const grantDecision = await decidePrincipalGrant({
|
||||
companyId,
|
||||
@@ -1664,8 +1804,6 @@ export function authorizationService(db: Db) {
|
||||
|
||||
if (
|
||||
(input.action === "agents:create" ||
|
||||
input.action === "agent_config:read" ||
|
||||
input.action === "agent_config:update" ||
|
||||
input.action === "tasks:manage_active_checkouts") &&
|
||||
canCreateAgentsLegacy(actorAgent)
|
||||
) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { and, desc, eq, ne } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents, builtInManagedResources, companies, issueThreadInteractions, issues, routines, routineTriggers } from "@paperclipai/db";
|
||||
import { syncRoutineVariablesWithTemplate } from "@paperclipai/shared";
|
||||
import type { Agent, Approval, CompanySkill, Routine, RoutineTrigger, RoutineVariable } from "@paperclipai/shared";
|
||||
import type { Agent, Approval, CompanySkill, PermissionKey, Routine, RoutineTrigger, RoutineVariable } from "@paperclipai/shared";
|
||||
import { conflict, HttpError, notFound, unprocessable } from "../errors.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import { agentInstructionsService } from "./agent-instructions.js";
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "./built-in-agent-metadata.js";
|
||||
import { companySkillService } from "./company-skills.js";
|
||||
import { routineService } from "./routines.js";
|
||||
import { accessService } from "./access.js";
|
||||
|
||||
export type BuiltInAgentStatus = "not_provisioned" | "pending_approval" | "needs_setup" | "ready" | "paused";
|
||||
|
||||
@@ -253,6 +254,11 @@ const DEFINITIONS = validateBuiltInAgentDefinitions([
|
||||
|
||||
const DEFINITIONS_BY_KEY = new Map(DEFINITIONS.map((definition) => [definition.key, definition]));
|
||||
|
||||
const ROOT_AGENT_DEFAULT_CHANGE_GRANTS: PermissionKey[] = ["agents:configure", "skills:create"];
|
||||
const BUILT_IN_AGENT_DEFAULT_GRANTS: Record<string, PermissionKey[]> = {
|
||||
"reflection-coach": ["agents:suggest-changes", "skills:suggest-changes"],
|
||||
};
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -546,6 +552,7 @@ function rowIsBuiltInAgent(row: typeof agents.$inferSelect, key: string) {
|
||||
|
||||
export function builtInAgentService(db: Db) {
|
||||
const agentSvc = agentService(db);
|
||||
const accessSvc = accessService(db);
|
||||
const approvalSvc = approvalService(db);
|
||||
const instructionsSvc = agentInstructionsService();
|
||||
const skillSvc = companySkillService(db);
|
||||
@@ -560,6 +567,51 @@ export function builtInAgentService(db: Db) {
|
||||
return nonBuiltInRoots.length === 1 ? nonBuiltInRoots[0]!.id : null;
|
||||
}
|
||||
|
||||
async function ensureAgentDefaultGrants(companyId: string, agentId: string, grantKeys: PermissionKey[]) {
|
||||
if (grantKeys.length === 0) return 0;
|
||||
await accessSvc.ensureMembership(companyId, "agent", agentId, "member", "active");
|
||||
let ensured = 0;
|
||||
for (const permissionKey of grantKeys) {
|
||||
await accessSvc.setPrincipalPermission(companyId, "agent", agentId, permissionKey, true, null);
|
||||
ensured += 1;
|
||||
}
|
||||
return ensured;
|
||||
}
|
||||
|
||||
async function ensureBuiltInAgentDefaultGrants(agent: Agent, definition: BuiltInAgentDefinition) {
|
||||
if (agent.status === "pending_approval" || agent.status === "terminated") return 0;
|
||||
return ensureAgentDefaultGrants(
|
||||
agent.companyId,
|
||||
agent.id,
|
||||
BUILT_IN_AGENT_DEFAULT_GRANTS[definition.key] ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureRootAgentDefaultChangeGrants(companyId: string) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated")));
|
||||
const rootCeoRows = rows.filter((agent) =>
|
||||
!readBuiltInAgentMarker(agent.metadata) &&
|
||||
!agent.reportsTo &&
|
||||
agent.role.trim().toLowerCase() === "ceo" &&
|
||||
agent.status !== "pending_approval"
|
||||
);
|
||||
if (rootCeoRows.length !== 1) return 0;
|
||||
return ensureAgentDefaultGrants(companyId, rootCeoRows[0]!.id, ROOT_AGENT_DEFAULT_CHANGE_GRANTS);
|
||||
}
|
||||
|
||||
async function ensureCompanyDefaultAgentGrants(companyId: string) {
|
||||
let ensured = await ensureRootAgentDefaultChangeGrants(companyId);
|
||||
for (const definition of DEFINITIONS) {
|
||||
const agent = await findSingleAgent(companyId, definition);
|
||||
if (!agent) continue;
|
||||
ensured += await ensureBuiltInAgentDefaultGrants(agent as Agent, definition);
|
||||
}
|
||||
return ensured;
|
||||
}
|
||||
|
||||
async function defaultProvisionInput(companyId: string, definition: BuiltInAgentDefinition, input: BuiltInAgentProvisionInput) {
|
||||
if (input.adapterType || input.adapterConfig) return input;
|
||||
if (!definition.bundle) return input;
|
||||
@@ -1279,6 +1331,7 @@ export function builtInAgentService(db: Db) {
|
||||
recordRevision: { source: "built-in-agent:ensure" },
|
||||
});
|
||||
if (!updated) throw notFound("Built-in agent not found");
|
||||
await ensureBuiltInAgentDefaultGrants(updated as Agent, definition);
|
||||
const resources = await reconcileBundleResources(updated as Agent, definition, "reconcile");
|
||||
return state(definition, await agentSvc.getById(existing.id) as Agent, resources);
|
||||
}
|
||||
@@ -1313,6 +1366,7 @@ export function builtInAgentService(db: Db) {
|
||||
},
|
||||
});
|
||||
|
||||
await ensureBuiltInAgentDefaultGrants(created, definition);
|
||||
const resources = await reconcileBundleResources(created, definition, "reconcile");
|
||||
return state(definition, await agentSvc.getById(created.id) as Agent, resources);
|
||||
}
|
||||
@@ -1420,6 +1474,7 @@ export function builtInAgentService(db: Db) {
|
||||
recordRevision: { source: "built-in-agent:reconcile-defaults" },
|
||||
});
|
||||
if (!updated) throw notFound("Built-in agent not found");
|
||||
await ensureBuiltInAgentDefaultGrants(updated as Agent, definition);
|
||||
return state(definition, updated as Agent);
|
||||
}
|
||||
|
||||
@@ -1508,7 +1563,8 @@ export function builtInAgentService(db: Db) {
|
||||
}
|
||||
autoEnsured += 1;
|
||||
}
|
||||
return { autoEnsured, pendingApprovals };
|
||||
const defaultGrantsEnsured = await ensureCompanyDefaultAgentGrants(companyId);
|
||||
return { autoEnsured, pendingApprovals, defaultGrantsEnsured };
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1533,6 +1589,7 @@ export function builtInAgentService(db: Db) {
|
||||
runRoutine,
|
||||
requireBuiltInAgent,
|
||||
autoProvisionBundledAgents,
|
||||
ensureCompanyDefaultAgentGrants,
|
||||
reconcileDefinitionDefaults,
|
||||
};
|
||||
}
|
||||
@@ -1544,10 +1601,12 @@ export async function reconcileBuiltInAgentsOnStartup(db: Db) {
|
||||
.from(companies);
|
||||
let autoEnsured = 0;
|
||||
let pendingApprovals = 0;
|
||||
let defaultGrantsEnsured = 0;
|
||||
for (const company of companyRows) {
|
||||
const result = await svc.autoProvisionBundledAgents(company.id);
|
||||
autoEnsured += result.autoEnsured;
|
||||
pendingApprovals += result.pendingApprovals;
|
||||
defaultGrantsEnsured += result.defaultGrantsEnsured;
|
||||
}
|
||||
const rows = await db
|
||||
.select({
|
||||
@@ -1581,5 +1640,5 @@ export async function reconcileBuiltInAgentsOnStartup(db: Db) {
|
||||
reconciled += 1;
|
||||
}
|
||||
|
||||
return { scanned, reconciled, unknown, duplicates, autoEnsured, pendingApprovals };
|
||||
return { scanned, reconciled, unknown, duplicates, autoEnsured, pendingApprovals, defaultGrantsEnsured };
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export function grantsForHumanRole(
|
||||
case "owner":
|
||||
return [
|
||||
{ permissionKey: "agents:create", scope: null },
|
||||
{ permissionKey: "agents:configure", scope: null },
|
||||
{ permissionKey: "skills:create", scope: null },
|
||||
{ permissionKey: "environments:manage", scope: null },
|
||||
{ permissionKey: "users:invite", scope: null },
|
||||
@@ -38,6 +39,7 @@ export function grantsForHumanRole(
|
||||
case "admin":
|
||||
return [
|
||||
{ permissionKey: "agents:create", scope: null },
|
||||
{ permissionKey: "agents:configure", scope: null },
|
||||
{ permissionKey: "skills:create", scope: null },
|
||||
{ permissionKey: "environments:manage", scope: null },
|
||||
{ permissionKey: "users:invite", scope: null },
|
||||
|
||||
@@ -3,8 +3,8 @@ import { promises as fs } from "node:fs";
|
||||
import { execFile } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { builtInManagedResources, type Db } from "@paperclipai/db";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { builtInManagedResources, principalPermissionGrants, type Db } from "@paperclipai/db";
|
||||
import type {
|
||||
CompanyPortabilityAgentManifestEntry,
|
||||
CompanyPortabilityCollisionStrategy,
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
CompanyPortabilitySkillManifestEntry,
|
||||
CompanySkill,
|
||||
AgentEnvConfig,
|
||||
PermissionKey,
|
||||
RoutineVariable,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
@@ -49,6 +50,7 @@ import {
|
||||
issueCommentMetadataSchema,
|
||||
issueCommentPresentationSchema,
|
||||
normalizeAgentUrlKey,
|
||||
PERMISSION_KEYS,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
readPaperclipSkillSyncPreference,
|
||||
@@ -720,6 +722,23 @@ function asBoolean(value: unknown): boolean | null {
|
||||
return typeof value === "boolean" ? value : null;
|
||||
}
|
||||
|
||||
type PortableAgentPermissionGrant = CompanyPortabilityAgentManifestEntry["permissionGrants"][number];
|
||||
|
||||
const VALID_PERMISSION_KEYS = new Set<PermissionKey>(PERMISSION_KEYS);
|
||||
|
||||
function normalizePortablePermissionGrants(value: unknown): PortableAgentPermissionGrant[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.flatMap((entry): PortableAgentPermissionGrant[] => {
|
||||
if (!isPlainRecord(entry)) return [];
|
||||
const permissionKey = asString(entry.permissionKey);
|
||||
if (!permissionKey || !VALID_PERMISSION_KEYS.has(permissionKey as PermissionKey)) return [];
|
||||
return [{
|
||||
permissionKey: permissionKey as PermissionKey,
|
||||
scope: isPlainRecord(entry.scope) ? entry.scope : null,
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function asInteger(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isInteger(value) ? value : null;
|
||||
}
|
||||
@@ -1926,6 +1945,7 @@ const YAML_KEY_PRIORITY = [
|
||||
"adapter",
|
||||
"runtime",
|
||||
"permissions",
|
||||
"permissionGrants",
|
||||
"budgetMonthlyCents",
|
||||
"metadata",
|
||||
] as const;
|
||||
@@ -2692,6 +2712,7 @@ function buildManifestFromPackageFiles(
|
||||
const extensionAdapter = isPlainRecord(extension.adapter) ? extension.adapter : null;
|
||||
const extensionRuntime = isPlainRecord(extension.runtime) ? extension.runtime : null;
|
||||
const extensionPermissions = isPlainRecord(extension.permissions) ? extension.permissions : null;
|
||||
const extensionPermissionGrants = normalizePortablePermissionGrants(extension.permissionGrants);
|
||||
const extensionMetadata = isPlainRecord(extension.metadata) ? extension.metadata : null;
|
||||
const adapterConfig = isPlainRecord(extensionAdapter?.config)
|
||||
? extensionAdapter.config
|
||||
@@ -2715,6 +2736,7 @@ function buildManifestFromPackageFiles(
|
||||
adapterConfig,
|
||||
runtimeConfig,
|
||||
permissions: extensionPermissions ?? {},
|
||||
permissionGrants: extensionPermissionGrants,
|
||||
budgetMonthlyCents:
|
||||
typeof extension.budgetMonthlyCents === "number" && Number.isFinite(extension.budgetMonthlyCents)
|
||||
? Math.max(0, Math.floor(extension.budgetMonthlyCents))
|
||||
@@ -3004,6 +3026,27 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
|
||||
const defaultSecretProvider = getConfiguredSecretProvider();
|
||||
|
||||
async function applyImportedAgentPermissionGrants(
|
||||
companyId: string,
|
||||
agentId: string,
|
||||
permissionGrants: PortableAgentPermissionGrant[],
|
||||
grantedByUserId: string | null,
|
||||
) {
|
||||
if (permissionGrants.length === 0) return;
|
||||
await access.ensureMembership(companyId, "agent", agentId, "member", "active");
|
||||
for (const grant of permissionGrants) {
|
||||
await access.setPrincipalPermission(
|
||||
companyId,
|
||||
"agent",
|
||||
agentId,
|
||||
grant.permissionKey,
|
||||
true,
|
||||
grantedByUserId,
|
||||
grant.scope ?? null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertKnownImportAdapterType(type: string | null | undefined): string {
|
||||
const adapterType = typeof type === "string" ? type.trim() : "";
|
||||
if (!adapterType) {
|
||||
@@ -3346,6 +3389,33 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
const slug = uniqueSlug(baseSlug, usedSlugs);
|
||||
idToSlug.set(agent.id, slug);
|
||||
}
|
||||
const agentPermissionGrantRows = agentRows.length > 0 && typeof (db as { select?: unknown }).select === "function"
|
||||
? await db
|
||||
.select({
|
||||
principalId: principalPermissionGrants.principalId,
|
||||
permissionKey: principalPermissionGrants.permissionKey,
|
||||
scope: principalPermissionGrants.scope,
|
||||
})
|
||||
.from(principalPermissionGrants)
|
||||
.where(and(
|
||||
eq(principalPermissionGrants.companyId, companyId),
|
||||
eq(principalPermissionGrants.principalType, "agent"),
|
||||
inArray(principalPermissionGrants.principalId, agentRows.map((agent) => agent.id)),
|
||||
))
|
||||
: [];
|
||||
const permissionGrantsByAgentId = new Map<string, PortableAgentPermissionGrant[]>();
|
||||
for (const row of agentPermissionGrantRows) {
|
||||
if (!VALID_PERMISSION_KEYS.has(row.permissionKey as PermissionKey)) continue;
|
||||
const grants = permissionGrantsByAgentId.get(row.principalId) ?? [];
|
||||
grants.push({
|
||||
permissionKey: row.permissionKey as PermissionKey,
|
||||
scope: isPlainRecord(row.scope) ? row.scope : null,
|
||||
});
|
||||
permissionGrantsByAgentId.set(row.principalId, grants);
|
||||
}
|
||||
for (const grants of permissionGrantsByAgentId.values()) {
|
||||
grants.sort((left, right) => left.permissionKey.localeCompare(right.permissionKey));
|
||||
}
|
||||
|
||||
const projectsSvc = projectService(db);
|
||||
const issuesSvc = issueService(db);
|
||||
@@ -3609,6 +3679,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
},
|
||||
) as Record<string, unknown>;
|
||||
const portablePermissions = pruneDefaultLikeValue(agent.permissions ?? {}, { dropFalseBooleans: true }) as Record<string, unknown>;
|
||||
const portablePermissionGrants = permissionGrantsByAgentId.get(agent.id) ?? [];
|
||||
const agentEnvInputs = dedupeEnvInputs(
|
||||
envInputs
|
||||
.slice(envInputsStart)
|
||||
@@ -3651,6 +3722,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
},
|
||||
runtime: portableRuntimeConfig,
|
||||
permissions: portablePermissions,
|
||||
permissionGrants: portablePermissionGrants.length > 0 ? portablePermissionGrants : undefined,
|
||||
budgetMonthlyCents: (agent.budgetMonthlyCents ?? 0) > 0 ? agent.budgetMonthlyCents : undefined,
|
||||
metadata: (agent.metadata as Record<string, unknown> | null) ?? null,
|
||||
});
|
||||
@@ -4651,6 +4723,12 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
} catch (err) {
|
||||
warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
await applyImportedAgentPermissionGrants(
|
||||
targetCompany.id,
|
||||
updated.id,
|
||||
manifestAgent.permissionGrants ?? [],
|
||||
actorUserId ?? null,
|
||||
);
|
||||
agentStatusById.set(updated.id, updated.status ?? agentStatusById.get(updated.id) ?? null);
|
||||
await secrets.syncEnvBindingsForTarget?.(
|
||||
targetCompany.id,
|
||||
@@ -4692,6 +4770,12 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
} catch (err) {
|
||||
warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
await applyImportedAgentPermissionGrants(
|
||||
targetCompany.id,
|
||||
created.id,
|
||||
manifestAgent.permissionGrants ?? [],
|
||||
actorUserId ?? null,
|
||||
);
|
||||
agentStatusById.set(created.id, created.status ?? createdStatus);
|
||||
await secrets.syncEnvBindingsForTarget?.(
|
||||
targetCompany.id,
|
||||
|
||||
Reference in new issue
Block a user