diff --git a/doc/CLI.md b/doc/CLI.md index 052cfb6bc7..2b4bfe6fc5 100644 --- a/doc/CLI.md +++ b/doc/CLI.md @@ -282,7 +282,7 @@ pnpm paperclipai agent local-cli --company-id --payload-json '{"canCreateAgents":true,"canAssignTasks":true}' +pnpm paperclipai agent permissions:update --payload-json '{"canCreateAgents":true,"canCreateSkills":true,"canAssignTasks":true}' pnpm paperclipai agent configuration pnpm paperclipai agent config-revisions pnpm paperclipai agent config-revision:get @@ -399,6 +399,11 @@ By default the command creates a `todo` issue assigned to the target agent and w Required Paperclip runtime skills (heartbeat, etc.) remain server-enforced and are added on top of whatever the desired set names. +Company skill mutations (`skills install`, `skills import`, `skills create`, and +`skills scan-projects`) require board authentication, an explicit `skills:create` +grant, or an agent whose permissions keep `canCreateSkills` enabled. They do not +require `agents:create` unless the command also creates agents. + ### Catalog (app-shipped skills) The Paperclip app ships a curated catalog under `@paperclipai/skills-catalog`. diff --git a/packages/db/src/migrations/0111_backfill_skill_create_human_defaults.sql b/packages/db/src/migrations/0111_backfill_skill_create_human_defaults.sql new file mode 100644 index 0000000000..11a8fa963a --- /dev/null +++ b/packages/db/src/migrations/0111_backfill_skill_create_human_defaults.sql @@ -0,0 +1,29 @@ +INSERT INTO "principal_permission_grants" ( + "company_id", + "principal_type", + "principal_id", + "permission_key", + "scope", + "granted_by_user_id", + "created_at", + "updated_at" +) +SELECT + "company_id", + 'user', + "principal_id", + 'skills:create', + NULL, + NULL, + now(), + now() +FROM "company_memberships" +WHERE "principal_type" = 'user' + AND "status" = 'active' + AND "membership_role" IN ('owner', 'admin') +ON CONFLICT ( + "company_id", + "principal_type", + "principal_id", + "permission_key" +) DO NOTHING; diff --git a/packages/db/src/migrations/0112_rename_skill_create_permission_key.sql b/packages/db/src/migrations/0112_rename_skill_create_permission_key.sql new file mode 100644 index 0000000000..1a1103915d --- /dev/null +++ b/packages/db/src/migrations/0112_rename_skill_create_permission_key.sql @@ -0,0 +1,30 @@ +INSERT INTO "principal_permission_grants" ( + "company_id", + "principal_type", + "principal_id", + "permission_key", + "scope", + "granted_by_user_id", + "created_at", + "updated_at" +) +SELECT + "company_id", + "principal_type", + "principal_id", + 'skills:create', + "scope", + "granted_by_user_id", + "created_at", + now() +FROM "principal_permission_grants" +WHERE "permission_key" = 'skill:create' +ON CONFLICT ( + "company_id", + "principal_type", + "principal_id", + "permission_key" +) DO NOTHING; + +DELETE FROM "principal_permission_grants" +WHERE "permission_key" = 'skill:create'; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 16ba529fec..06d0dfe20d 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -778,6 +778,20 @@ "when": 1781902400000, "tag": "0110_document_company_cascade", "breakpoints": true + }, + { + "idx": 111, + "version": "7", + "when": 1781902500000, + "tag": "0111_backfill_skill_create_human_defaults", + "breakpoints": true + }, + { + "idx": 112, + "version": "7", + "when": 1781902600000, + "tag": "0112_rename_skill_create_permission_key", + "breakpoints": true } ] } diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index aaacebbcb8..2cf2483b1b 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -1956,7 +1956,10 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { spentMonthlyCents: 0, pauseReason: null, pausedAt: null, - permissions: { canCreateAgents: Boolean(declaration.permissions?.canCreateAgents) }, + permissions: { + canCreateAgents: Boolean(declaration.permissions?.canCreateAgents), + canCreateSkills: declaration.permissions?.canCreateSkills !== false, + }, lastHeartbeatAt: null, metadata: managedAgentMetadata(agentKey), createdAt: now, @@ -1994,7 +1997,10 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { spentMonthlyCents: 0, pauseReason: null, pausedAt: null, - permissions: { canCreateAgents: Boolean(declaration.permissions?.canCreateAgents) }, + permissions: { + canCreateAgents: Boolean(declaration.permissions?.canCreateAgents), + canCreateSkills: declaration.permissions?.canCreateSkills !== false, + }, lastHeartbeatAt: null, metadata: managedAgentMetadata(agentKey), createdAt: now, @@ -2015,7 +2021,10 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { adapterConfig: declaration.adapterConfig ?? {}, runtimeConfig: declaration.runtimeConfig ?? {}, budgetMonthlyCents: declaration.budgetMonthlyCents ?? 0, - permissions: { canCreateAgents: Boolean(declaration.permissions?.canCreateAgents) }, + permissions: { + canCreateAgents: Boolean(declaration.permissions?.canCreateAgents), + canCreateSkills: declaration.permissions?.canCreateSkills !== false, + }, metadata: managedAgentMetadata(agentKey, resolved.agent.metadata), updatedAt: new Date(), }; diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 024bf6e090..f64896909a 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -779,6 +779,7 @@ export type JoinRequestStatus = (typeof JOIN_REQUEST_STATUSES)[number]; export const PERMISSION_KEYS = [ "agents:create", + "skills:create", "environments:manage", "users:invite", "users:manage_permissions", diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index 8fc7ec5a88..ad16c7dad8 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -17,6 +17,7 @@ import type { AgentOrgChainHealth } from "../agent-eligibility.js"; export interface AgentPermissions extends Record { canCreateAgents: boolean; + canCreateSkills?: boolean; trustPreset?: TrustPreset; authorizationPolicy?: TrustAuthorizationPolicy; } diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index e7eff192ce..69f1b5b233 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -12,6 +12,7 @@ import { agentDesiredSkillSelectionSchema } from "./adapter-skills.js"; export const agentPermissionsSchema = z.object({ canCreateAgents: z.boolean().optional().default(false), + canCreateSkills: z.boolean().optional().default(true), trustPreset: trustPresetSchema.optional(), authorizationPolicy: trustAuthorizationPolicySchema.optional(), }).catchall(z.unknown()); @@ -161,6 +162,7 @@ export type TestAdapterEnvironment = z.infer { expect(res.body.access.taskAssignSource).toBe("agent_creator"); }); + it("preserves disabled skill creation when unrelated permission updates omit that field", async () => { + mockAgentService.updatePermissions.mockResolvedValue({ + ...baseAgent, + permissions: { canCreateAgents: false, canCreateSkills: false }, + }); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await requestApp(app, (baseUrl) => request(baseUrl) + .patch(`/api/agents/${agentId}/permissions`) + .send({ canCreateAgents: false, canAssignTasks: true })); + + expect(res.status).toBe(200); + expect(mockAgentService.updatePermissions).toHaveBeenCalledWith(agentId, { + canCreateAgents: false, + canAssignTasks: true, + }); + expect(res.body.permissions.canCreateSkills).toBe(false); + }); + it("rejects CEO permission updates outside the caller company scope", async () => { const app = await createApp({ type: "agent", diff --git a/server/src/__tests__/agent-permissions-service.test.ts b/server/src/__tests__/agent-permissions-service.test.ts index 2a80d25ab9..ddd7dc9b22 100644 --- a/server/src/__tests__/agent-permissions-service.test.ts +++ b/server/src/__tests__/agent-permissions-service.test.ts @@ -1,4 +1,8 @@ import { describe, expect, it } from "vitest"; +import { + agentPermissionsSchema, + updateAgentPermissionsSchema, +} from "@paperclipai/shared"; import { defaultPermissionsForRole, normalizeAgentPermissions, @@ -12,8 +16,35 @@ describe("agent permissions service", () => { expect(defaultPermissionsForRole("engineer").canCreateAgents).toBe(false); }); + it("enables skill creation for every role by default", () => { + expect(defaultPermissionsForRole("ceo").canCreateSkills).toBe(true); + expect(defaultPermissionsForRole("CTO").canCreateSkills).toBe(true); + expect(defaultPermissionsForRole("engineering-manager").canCreateSkills).toBe(true); + expect(defaultPermissionsForRole("engineer").canCreateSkills).toBe(true); + }); + it("preserves explicit canCreateAgents overrides", () => { expect(normalizeAgentPermissions({ canCreateAgents: false }, "cto").canCreateAgents).toBe(false); expect(normalizeAgentPermissions({ canCreateAgents: true }, "engineer").canCreateAgents).toBe(true); }); + + it("defaults missing skill creation permission to true and preserves explicit false", () => { + expect(normalizeAgentPermissions({}, "engineer").canCreateSkills).toBe(true); + expect(normalizeAgentPermissions({ canCreateSkills: false }, "ceo").canCreateSkills).toBe(false); + expect(normalizeAgentPermissions({ canCreateSkills: true }, "engineer").canCreateSkills).toBe(true); + }); + + it("validates skill creation permission with a default-on value", () => { + expect(agentPermissionsSchema.parse({ canCreateAgents: false }).canCreateSkills).toBe(true); + expect(agentPermissionsSchema.parse({ canCreateAgents: false, canCreateSkills: false }).canCreateSkills).toBe(false); + expect(updateAgentPermissionsSchema.parse({ + canCreateAgents: false, + canAssignTasks: false, + }).canCreateSkills).toBeUndefined(); + expect(updateAgentPermissionsSchema.parse({ + canCreateAgents: false, + canCreateSkills: false, + canAssignTasks: false, + }).canCreateSkills).toBe(false); + }); }); diff --git a/server/src/__tests__/company-skills-routes.test.ts b/server/src/__tests__/company-skills-routes.test.ts index 5436b2f46d..bac7a475fc 100644 --- a/server/src/__tests__/company-skills-routes.test.ts +++ b/server/src/__tests__/company-skills-routes.test.ts @@ -27,7 +27,15 @@ const mockCompanySkillService = vi.hoisted(() => ({ deleteComment: vi.fn(), importFromSource: vi.fn(), installFromCatalog: vi.fn(), + createLocalSkill: vi.fn(), + updateSkill: vi.fn(), + updateFile: vi.fn(), + scanProjectWorkspaces: vi.fn(), deleteSkill: vi.fn(), + auditSkill: vi.fn(), + getById: vi.fn(), + installUpdate: vi.fn(), + resetSkill: vi.fn(), })); const mockCatalogService = vi.hoisted(() => ({ @@ -256,11 +264,95 @@ describe("company skill mutation permissions", () => { }, warnings: [], }); + mockCompanySkillService.createLocalSkill.mockResolvedValue({ + id: "skill-1", + companyId: "company-1", + key: "company/company-1/review", + slug: "review", + name: "Review", + description: null, + markdown: "# Review", + sourceType: "local_path", + sourceLocator: "/tmp/review", + sourceRef: null, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + iconUrl: null, + color: null, + tagline: null, + authorName: null, + homepageUrl: null, + categories: [], + sharingScope: "company", + publicShareToken: null, + forkedFromSkillId: null, + forkedFromCompanyId: null, + starCount: 0, + installCount: 1, + forkCount: 0, + currentVersionId: null, + metadata: null, + createdAt: new Date("2026-05-26T00:00:00.000Z"), + updatedAt: new Date("2026-05-26T00:00:00.000Z"), + }); + mockCompanySkillService.updateSkill.mockResolvedValue({ + id: "skill-1", + slug: "review", + sharingScope: "company", + }); + mockCompanySkillService.updateFile.mockResolvedValue({ + skillId: "skill-1", + path: "SKILL.md", + kind: "skill", + content: "# Review", + language: "markdown", + markdown: true, + editable: true, + }); + mockCompanySkillService.scanProjectWorkspaces.mockResolvedValue({ + scannedProjects: 0, + scannedWorkspaces: 0, + discovered: 0, + imported: [], + updated: [], + skipped: [], + conflicts: [], + warnings: [], + }); mockCompanySkillService.deleteSkill.mockResolvedValue({ id: "skill-1", slug: "find-skills", name: "Find Skills", }); + mockCompanySkillService.auditSkill.mockResolvedValue({ + skillId: "skill-1", + installedHash: "sha256:abc", + originHash: "sha256:abc", + verdict: "pass", + codes: [], + findings: [], + scannedAt: "2026-05-26T00:00:00.000Z", + scanVersion: "1", + }); + mockCompanySkillService.getById.mockResolvedValue({ + id: "skill-1", + slug: "review", + sourceRef: "sha256:abc", + metadata: { originHash: "sha256:abc" }, + }); + mockCompanySkillService.installUpdate.mockResolvedValue({ + id: "skill-1", + slug: "review", + sourceRef: "sha256:def", + metadata: { originHash: "sha256:def" }, + }); + mockCompanySkillService.resetSkill.mockResolvedValue({ + id: "skill-1", + slug: "review", + sourceRef: "sha256:def", + metadata: { originHash: "sha256:def" }, + }); mockCatalogService.listCatalogSkillsOrEmpty.mockReturnValue([]); mockCatalogService.getCatalogSkillOrThrow.mockReturnValue({ id: "paperclipai:bundled:software-development:review", @@ -312,6 +404,74 @@ describe("company skill mutation permissions", () => { }); }); + it("allows board users with skills:create to create, import, install, update, delete, audit, and reset company skills", async () => { + const app = await createApp({ + type: "board", + userId: "board-user", + companyIds: ["company-1"], + source: "session", + isInstanceAdmin: false, + }); + + await request(app) + .post("/api/companies/company-1/skills") + .send({ name: "Review", slug: "review", markdown: "# Review" }) + .expect(201); + await request(app) + .post("/api/companies/company-1/skills/import") + .send({ source: "https://github.com/vercel-labs/agent-browser" }) + .expect(201); + await request(app) + .post("/api/companies/company-1/skills/install-catalog") + .send({ catalogSkillId: "paperclipai:bundled:software-development:review" }) + .expect(201); + await request(app) + .patch("/api/companies/company-1/skills/skill-1") + .send({ description: "Updated" }) + .expect(200); + await request(app) + .delete("/api/companies/company-1/skills/skill-1") + .expect(200); + await request(app) + .post("/api/companies/company-1/skills/skill-1/audit") + .send({}) + .expect(200); + await request(app) + .post("/api/companies/company-1/skills/skill-1/reset") + .send({}) + .expect(200); + + expect(mockAccessService.canUser).toHaveBeenCalledWith("company-1", "board-user", "skills:create"); + expect(mockAccessService.canUser).not.toHaveBeenCalledWith("company-1", "board-user", "agents:create"); + expect(mockCompanySkillService.createLocalSkill).toHaveBeenCalled(); + expect(mockCompanySkillService.importFromSource).toHaveBeenCalled(); + expect(mockCompanySkillService.installFromCatalog).toHaveBeenCalled(); + expect(mockCompanySkillService.updateSkill).toHaveBeenCalled(); + expect(mockCompanySkillService.deleteSkill).toHaveBeenCalled(); + expect(mockCompanySkillService.auditSkill).toHaveBeenCalled(); + expect(mockCompanySkillService.resetSkill).toHaveBeenCalled(); + }); + + it("blocks board users without skills:create from mutating company skills", async () => { + mockAccessService.canUser.mockResolvedValue(false); + + const res = await request(await createApp({ + type: "board", + userId: "board-user", + companyIds: ["company-1"], + source: "session", + isInstanceAdmin: false, + })) + .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"); + expect(mockAccessService.canUser).toHaveBeenCalledWith("company-1", "board-user", "skills:create"); + expect(mockAccessService.canUser).not.toHaveBeenCalledWith("company-1", "board-user", "agents:create"); + expect(mockCompanySkillService.importFromSource).not.toHaveBeenCalled(); + }); + it("serves catalog listing without mutating company skills", async () => { mockCatalogService.listCatalogSkillsOrEmpty.mockReturnValue([ { @@ -553,11 +713,11 @@ describe("company skill mutation permissions", () => { }); }); - it("blocks same-company agents without management permission from mutating company skills", async () => { + it("blocks same-company agents with skill creation disabled from mutating company skills", async () => { mockAgentService.getById.mockResolvedValue({ id: "agent-1", companyId: "company-1", - permissions: {}, + permissions: { canCreateSkills: false }, }); const res = await request(await createApp({ @@ -570,6 +730,9 @@ 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", "agent-1", "skills:create"); + expect(mockAccessService.hasPermission).not.toHaveBeenCalledWith("company-1", "agent", "agent-1", "agents:create"); expect(mockCompanySkillService.importFromSource).not.toHaveBeenCalled(); }); @@ -577,7 +740,7 @@ describe("company skill mutation permissions", () => { mockAgentService.getById.mockResolvedValue({ id: "agent-1", companyId: "company-1", - permissions: { canCreateAgents: true }, + permissions: { canCreateSkills: true }, }); const res = await request(await createApp({ @@ -675,11 +838,11 @@ describe("company skill mutation permissions", () => { }); }); - it("allows agents with canCreateAgents to mutate company skills", async () => { + it("allows agents with canCreateSkills to mutate company skills", async () => { mockAgentService.getById.mockResolvedValue({ id: "agent-1", companyId: "company-1", - permissions: { canCreateAgents: true }, + permissions: { canCreateSkills: true }, }); const res = await request(await createApp({ @@ -698,6 +861,91 @@ describe("company skill mutation permissions", () => { ); }); + it("allows same-company agents with missing skill creation permission to mutate company skills", async () => { + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + companyId: "company-1", + permissions: {}, + }); + + 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(201); + 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 () => { + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + 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: "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(201); + expect(mockAccessService.hasPermission).toHaveBeenCalledWith("company-1", "agent", "agent-1", "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: "agent-1", + companyId: "company-1", + permissions: { canCreateSkills: false }, + }); + mockAccessService.hasPermission.mockImplementation(async ( + _companyId: string, + _principalType: string, + _principalId: string, + key: string, + ) => { + return 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"); + expect(mockAccessService.hasPermission).toHaveBeenCalledWith("company-1", "agent", "agent-1", "skills:create"); + expect(mockCompanySkillService.importFromSource).not.toHaveBeenCalled(); + }); + it("returns a blocking error when attempting to delete a skill still used by agents", async () => { const { unprocessable } = await import("../errors.js"); mockCompanySkillService.deleteSkill.mockImplementationOnce(async () => { diff --git a/server/src/__tests__/invite-join-grants.test.ts b/server/src/__tests__/invite-join-grants.test.ts index 6834f763e2..0e05562fa9 100644 --- a/server/src/__tests__/invite-join-grants.test.ts +++ b/server/src/__tests__/invite-join-grants.test.ts @@ -68,6 +68,7 @@ describe("human invite roles", () => { it("maps owner to the full management grant set", () => { expect(grantsForHumanRole("owner")).toEqual([ { permissionKey: "agents:create", scope: null }, + { permissionKey: "skills:create", scope: null }, { permissionKey: "environments:manage", scope: null }, { permissionKey: "users:invite", scope: null }, { permissionKey: "users:manage_permissions", scope: null }, @@ -79,6 +80,7 @@ describe("human invite roles", () => { it("maps admin to management grants including environment management", () => { expect(grantsForHumanRole("admin")).toEqual([ { permissionKey: "agents:create", scope: null }, + { permissionKey: "skills:create", scope: null }, { permissionKey: "environments:manage", scope: null }, { permissionKey: "users:invite", scope: null }, { permissionKey: "tasks:assign", scope: null }, diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index cd92409781..055abfb5c1 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -2527,6 +2527,7 @@ export function agentRoutes( entityId: agent.id, details: { canCreateAgents: agent.permissions?.canCreateAgents ?? false, + canCreateSkills: agent.permissions?.canCreateSkills ?? true, canAssignTasks: effectiveCanAssignTasks, trustPreset: agent.permissions?.trustPreset ?? "standard", }, diff --git a/server/src/routes/company-skills.ts b/server/src/routes/company-skills.ts index fe7556f2b6..e2e176e4ca 100644 --- a/server/src/routes/company-skills.ts +++ b/server/src/routes/company-skills.ts @@ -42,9 +42,9 @@ export function companySkillRoutes(db: Db) { const access = accessService(db); const svc = companySkillService(db); - function canCreateAgents(agent: { permissions: Record | null | undefined }) { - if (!agent.permissions || typeof agent.permissions !== "object") return false; - return Boolean((agent.permissions as Record).canCreateAgents); + function canCreateSkills(agent: { permissions: Record | null | undefined }) { + if (!agent.permissions || typeof agent.permissions !== "object") return true; + return (agent.permissions as Record).canCreateSkills !== false; } function asString(value: unknown): string | null { @@ -94,9 +94,9 @@ export function companySkillRoutes(db: Db) { if (req.actor.type === "board") { if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; - const allowed = await access.canUser(companyId, req.actor.userId, "agents:create"); + const allowed = await access.canUser(companyId, req.actor.userId, "skills:create"); if (!allowed) { - throw forbidden("Missing permission: agents:create"); + throw forbidden("Missing permission: skills:create"); } return; } @@ -110,12 +110,16 @@ export function companySkillRoutes(db: Db) { throw forbidden("Agent key cannot access another company"); } - const allowedByGrant = await access.hasPermission(companyId, "agent", actorAgent.id, "agents:create"); - if (allowedByGrant || canCreateAgents(actorAgent)) { + if (canCreateSkills(actorAgent)) { return; } - throw forbidden("Missing permission: can create agents"); + const allowedByGrant = await access.hasPermission(companyId, "agent", actorAgent.id, "skills:create"); + if (allowedByGrant) { + return; + } + + throw forbidden("Missing permission: skills:create"); } router.get("/skills/catalog", async (req, res) => { diff --git a/server/src/services/agent-permissions.ts b/server/src/services/agent-permissions.ts index cd470960b0..49eba9aa4e 100644 --- a/server/src/services/agent-permissions.ts +++ b/server/src/services/agent-permissions.ts @@ -1,10 +1,12 @@ export type NormalizedAgentPermissions = Record & { canCreateAgents: boolean; + canCreateSkills: boolean; }; export function defaultPermissionsForRole(role: string): NormalizedAgentPermissions { return { canCreateAgents: role.trim().toLowerCase() === "ceo", + canCreateSkills: true, }; } @@ -25,5 +27,9 @@ export function normalizeAgentPermissions( typeof record.canCreateAgents === "boolean" ? record.canCreateAgents : defaults.canCreateAgents, + canCreateSkills: + typeof record.canCreateSkills === "boolean" + ? record.canCreateSkills + : defaults.canCreateSkills, }; } diff --git a/server/src/services/company-member-roles.ts b/server/src/services/company-member-roles.ts index 593770ca42..d3443098d9 100644 --- a/server/src/services/company-member-roles.ts +++ b/server/src/services/company-member-roles.ts @@ -28,6 +28,7 @@ export function grantsForHumanRole( case "owner": return [ { permissionKey: "agents:create", scope: null }, + { permissionKey: "skills:create", scope: null }, { permissionKey: "environments:manage", scope: null }, { permissionKey: "users:invite", scope: null }, { permissionKey: "users:manage_permissions", scope: null }, @@ -37,6 +38,7 @@ export function grantsForHumanRole( case "admin": return [ { permissionKey: "agents:create", scope: null }, + { permissionKey: "skills:create", scope: null }, { permissionKey: "environments:manage", scope: null }, { permissionKey: "users:invite", scope: null }, { permissionKey: "tasks:assign", scope: null }, diff --git a/skills/paperclip/references/company-skills.md b/skills/paperclip/references/company-skills.md index 5f746629c0..8bb103ec8a 100644 --- a/skills/paperclip/references/company-skills.md +++ b/skills/paperclip/references/company-skills.md @@ -22,8 +22,9 @@ set. ## Permission Model - Company skill reads: any same-company actor -- Company skill mutations: board, CEO, or an agent with the effective `agents:create` capability +- Company skill mutations: board, a human/agent principal with an explicit `skills:create` grant, or an agent whose `canCreateSkills` permission is enabled. `canCreateSkills` defaults on for agents unless explicitly disabled. - Agent skill assignment: same permission model as updating that agent +- Team installs continue to require `agents:create` because they import or create agents in addition to attaching skills. ## Core Endpoints diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index e9271831b7..fc29bac98e 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -69,6 +69,7 @@ export interface AgentHireResponse { export interface AgentPermissionUpdate { canCreateAgents: boolean; + canCreateSkills: boolean; canAssignTasks: boolean; trustPreset?: AgentPermissions["trustPreset"]; authorizationPolicy?: AgentPermissions["authorizationPolicy"]; diff --git a/ui/src/lib/duplicate-agent-payload.test.ts b/ui/src/lib/duplicate-agent-payload.test.ts index 3109cb609a..e024042c02 100644 --- a/ui/src/lib/duplicate-agent-payload.test.ts +++ b/ui/src/lib/duplicate-agent-payload.test.ts @@ -32,7 +32,7 @@ const baseAgent: AgentDetail = { spentMonthlyCents: 123, pauseReason: null, pausedAt: null, - permissions: { canCreateAgents: true }, + permissions: { canCreateAgents: true, canCreateSkills: true }, lastHeartbeatAt: null, metadata: { source: "test" }, createdAt: new Date("2026-05-10T00:00:00.000Z"), @@ -72,7 +72,7 @@ describe("duplicate agent payload", () => { runtimeConfig: { heartbeat: { enabled: true } }, defaultEnvironmentId: "environment-1", budgetMonthlyCents: 500, - permissions: { canCreateAgents: true }, + permissions: { canCreateAgents: true, canCreateSkills: true }, metadata: { source: "test" }, instructionsBundle: { entryFile: "AGENTS.md", diff --git a/ui/src/lib/duplicate-agent-payload.ts b/ui/src/lib/duplicate-agent-payload.ts index 714e1f0474..b2f750865b 100644 --- a/ui/src/lib/duplicate-agent-payload.ts +++ b/ui/src/lib/duplicate-agent-payload.ts @@ -61,6 +61,7 @@ export function buildDuplicateAgentPayload( budgetMonthlyCents: agent.budgetMonthlyCents ?? 0, permissions: { canCreateAgents: Boolean(agent.permissions?.canCreateAgents), + canCreateSkills: agent.permissions?.canCreateSkills !== false, }, }; diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 97edf8dc95..8005509165 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1640,6 +1640,7 @@ function ConfigurationTab({ }, [onSavingChange, isConfigSaving]); const canCreateAgents = Boolean(agent.permissions?.canCreateAgents); + const canCreateSkills = agent.permissions?.canCreateSkills !== false; const canAssignTasks = Boolean(agent.access?.canAssignTasks); const taskAssignSource = agent.access?.taskAssignSource ?? "none"; const taskAssignLocked = agent.role === "ceo" || canCreateAgents; @@ -1687,6 +1688,7 @@ function ConfigurationTab({ onChange={(nextPermissions) => updatePermissions.mutate({ canCreateAgents, + canCreateSkills, canAssignTasks, ...buildPermissionsForTrustPreset(nextPermissions, nextPermissions.trustPreset === "low_trust_review" ? "low_trust_review" : "standard"), }) @@ -1700,7 +1702,7 @@ function ConfigurationTab({
Can create new agents

- Lets this agent create or hire agents and implicitly assign tasks. + Lets this agent create or hire agents. This also grants task assignment authority.

updatePermissions.mutate({ canCreateAgents: !canCreateAgents, + canCreateSkills, canAssignTasks: !canCreateAgents ? true : canAssignTasks, }) } disabled={updatePermissions.isPending} /> +
+
+
Can create/import skills
+

+ Lets this agent install, import, create, and scan company skills without creating agents. +

+
+ + updatePermissions.mutate({ + canCreateAgents, + canCreateSkills: !canCreateSkills, + canAssignTasks, + }) + } + disabled={updatePermissions.isPending} + /> +
Can assign tasks
@@ -1726,6 +1748,7 @@ function ConfigurationTab({ onCheckedChange={() => updatePermissions.mutate({ canCreateAgents, + canCreateSkills, canAssignTasks: !canAssignTasks, }) }