[codex] Gate skill mutations with skills:create permission (#8616)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents and board users operate inside a company-scoped control plane
where permissions decide which mutating actions they can perform
> - Company skills are part of the reusable agent-company setup surface,
but skill mutation had been coupled to broader agent-creation authority
> - That coupling meant importing or managing skills required a
permission that also implies hiring power, which is broader than the
operation needs
> - Paperclip already has a grant-based permission vocabulary, so skill
mutation should be authorized through a dedicated `skills:create`
capability while preserving existing default behavior for trusted agents
> - This pull request adds the skill creation permission contract,
enforces it on company skill mutations, exposes it in agent permission
management, and documents the changed CLI/API expectations
> - The benefit is a narrower, auditable permission path for skill
import/create/update/delete flows without forcing agents to receive
broader agent-creation authority

## Linked Issues or Issue Description

No public issue is linked.

Problem: company skill mutation APIs were effectively tied to broader
agent creation authority. This PR splits skill mutation authorization
onto the public `skills:create` permission while keeping existing
default skill creation behavior for agents unless explicitly disabled.

Related public PR found during duplicate search: #5330. That PR uses an
older `canManageSkills` shape; this PR implements the `skills:create`
grant path instead.

## What Changed

- Added `skills:create` to shared permission constants and agent
permission types/validators as `canCreateSkills`.
- Backfilled default human/member role grants for `skills:create`.
- Updated company skill mutation routes to require board/user or agent
access to `skills:create`, while preserving legacy/default agent
behavior through `canCreateSkills` unless explicitly disabled.
- Updated agent permission update handling, UI permission controls,
duplicate-agent payloads, plugin SDK fixtures, and agent detail API
surfaces for `canCreateSkills`.
- Added regression coverage for skill route authorization, permission
schema/default behavior, invite grants, omitted permission updates, and
duplicate-agent payloads.
- Updated CLI and Paperclip skill documentation for the new skill
creation permission.

## Verification

- `pnpm exec vitest run
server/src/__tests__/agent-permissions-service.test.ts
server/src/__tests__/agent-permissions-routes.test.ts
server/src/__tests__/company-skills-routes.test.ts
server/src/__tests__/invite-join-grants.test.ts
ui/src/lib/duplicate-agent-payload.test.ts` — 5 files, 90 tests passed.
- `pnpm --filter @paperclipai/shared typecheck && pnpm --filter
@paperclipai/server typecheck && pnpm --filter @paperclipai/ui
typecheck` — passed.
- `pnpm test:run ...changed files...` was attempted first, but the
stable wrapper rejects explicit file arguments; direct Vitest was used
for the same targeted files.

## Risks

- Moderate authorization risk: this changes the gate for company skill
mutations, so the tests cover board grant checks, agent explicit grant
checks, legacy default allowance, and explicit denial.
- Migration/backfill risk is low: the migration only grants
`skills:create` to existing human roles that already need broad
management capability.
- UI/API compatibility risk is low: `canCreateSkills` remains default-on
for full agent permissions, and the update validator preserves omitted
values so unrelated permission edits do not re-enable disabled skill
creation.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, GPT-5 coding agent with terminal/tool use enabled.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-06-25 09:49:10 -05:00 committed by GitHub
parent 1951c80237
commit ed65d08d57
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 458 additions and 21 deletions

View File

@ -282,7 +282,7 @@ pnpm paperclipai agent local-cli <agent-id-or-shortname> --company-id <company-i
Agent configuration and runtime endpoints:
```sh
pnpm paperclipai agent permissions:update <agent-id> --payload-json '{"canCreateAgents":true,"canAssignTasks":true}'
pnpm paperclipai agent permissions:update <agent-id> --payload-json '{"canCreateAgents":true,"canCreateSkills":true,"canAssignTasks":true}'
pnpm paperclipai agent configuration <agent-id>
pnpm paperclipai agent config-revisions <agent-id>
pnpm paperclipai agent config-revision:get <agent-id> <revision-id>
@ -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`.

View File

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

View File

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

View File

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

View File

@ -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(),
};

View File

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

View File

@ -17,6 +17,7 @@ import type { AgentOrgChainHealth } from "../agent-eligibility.js";
export interface AgentPermissions extends Record<string, unknown> {
canCreateAgents: boolean;
canCreateSkills?: boolean;
trustPreset?: TrustPreset;
authorizationPolicy?: TrustAuthorizationPolicy;
}

View File

@ -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<typeof testAdapterEnvironmentSchema
export const updateAgentPermissionsSchema = z.object({
canCreateAgents: z.boolean(),
canCreateSkills: z.boolean().optional(),
canAssignTasks: z.boolean(),
trustPreset: trustPresetSchema.optional(),
authorizationPolicy: trustAuthorizationPolicySchema.optional(),

View File

@ -1594,6 +1594,32 @@ describe.sequential("agent permission routes", () => {
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",

View File

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

View File

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

View File

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

View File

@ -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",
},

View File

@ -42,9 +42,9 @@ export function companySkillRoutes(db: Db) {
const access = accessService(db);
const svc = companySkillService(db);
function canCreateAgents(agent: { permissions: Record<string, unknown> | null | undefined }) {
if (!agent.permissions || typeof agent.permissions !== "object") return false;
return Boolean((agent.permissions as Record<string, unknown>).canCreateAgents);
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 {
@ -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) => {

View File

@ -1,10 +1,12 @@
export type NormalizedAgentPermissions = Record<string, unknown> & {
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,
};
}

View File

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

View File

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

View File

@ -69,6 +69,7 @@ export interface AgentHireResponse {
export interface AgentPermissionUpdate {
canCreateAgents: boolean;
canCreateSkills: boolean;
canAssignTasks: boolean;
trustPreset?: AgentPermissions["trustPreset"];
authorizationPolicy?: AgentPermissions["authorizationPolicy"];

View File

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

View File

@ -61,6 +61,7 @@ export function buildDuplicateAgentPayload(
budgetMonthlyCents: agent.budgetMonthlyCents ?? 0,
permissions: {
canCreateAgents: Boolean(agent.permissions?.canCreateAgents),
canCreateSkills: agent.permissions?.canCreateSkills !== false,
},
};

View File

@ -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({
<div className="space-y-1">
<div>Can create new agents</div>
<p className="text-xs text-muted-foreground">
Lets this agent create or hire agents and implicitly assign tasks.
Lets this agent create or hire agents. This also grants task assignment authority.
</p>
</div>
<ToggleSwitch
@ -1708,12 +1710,32 @@ function ConfigurationTab({
onCheckedChange={() =>
updatePermissions.mutate({
canCreateAgents: !canCreateAgents,
canCreateSkills,
canAssignTasks: !canCreateAgents ? true : canAssignTasks,
})
}
disabled={updatePermissions.isPending}
/>
</div>
<div className="flex items-center justify-between gap-4 text-sm">
<div className="space-y-1">
<div>Can create/import skills</div>
<p className="text-xs text-muted-foreground">
Lets this agent install, import, create, and scan company skills without creating agents.
</p>
</div>
<ToggleSwitch
checked={canCreateSkills}
onCheckedChange={() =>
updatePermissions.mutate({
canCreateAgents,
canCreateSkills: !canCreateSkills,
canAssignTasks,
})
}
disabled={updatePermissions.isPending}
/>
</div>
<div className="flex items-center justify-between gap-4 text-sm">
<div className="space-y-1">
<div>Can assign tasks</div>
@ -1726,6 +1748,7 @@ function ConfigurationTab({
onCheckedChange={() =>
updatePermissions.mutate({
canCreateAgents,
canCreateSkills,
canAssignTasks: !canAssignTasks,
})
}