From a72aa691be4d8188f0dcdff9d78d4dfe75744760 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Nov 2026 11:17:43 -0600 Subject: [PATCH] feat(cli): manage individual agent permission grants as a Board operator Add a company-scoped Board-only route and CLI grant/revoke commands. Preserve other grants and inactive membership state, serialize mutations, and persist their audit records in the same transaction. Cover real commentary reads before and after grant and revoke. Co-Authored-By: Paperclip --- cli/src/__tests__/agent-lifecycle.test.ts | 11 ++ cli/src/commands/client/agent.ts | 19 +++ doc/CLI.md | 9 ++ packages/shared/src/index.ts | 2 + packages/shared/src/validators/access.ts | 7 ++ packages/shared/src/validators/index.ts | 2 + .../__tests__/agent-permission-grants.test.ts | 114 ++++++++++++++++++ server/src/__tests__/openapi-routes.test.ts | 11 ++ server/src/app.ts | 2 + server/src/routes/agent-permission-grants.ts | 73 +++++++++++ server/src/routes/openapi.ts | 12 ++ 11 files changed, 262 insertions(+) create mode 100644 server/src/__tests__/agent-permission-grants.test.ts create mode 100644 server/src/routes/agent-permission-grants.ts diff --git a/cli/src/__tests__/agent-lifecycle.test.ts b/cli/src/__tests__/agent-lifecycle.test.ts index 0410be70c7c..204e84ec7cc 100644 --- a/cli/src/__tests__/agent-lifecycle.test.ts +++ b/cli/src/__tests__/agent-lifecycle.test.ts @@ -131,6 +131,17 @@ describe("agent lifecycle commands", () => { ]); }); + it("grants and revokes one permission without replacing the grant list", async () => { + const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse())); + vi.stubGlobal("fetch", fetchMock); + await run(["agent", "permission:grant", AGENT_ID, "commentary:view"]); + await run(["agent", "permission:revoke", AGENT_ID, "commentary:view"]); + expect(fetchMock.mock.calls.map(call => [call[0], call[1]?.method, JSON.parse(call[1]?.body)])).toEqual([ + [`http://localhost:3100/api/agents/${AGENT_ID}/permission-grants`, "PATCH", { permissionKey: "commentary:view", enabled: true }], + [`http://localhost:3100/api/agents/${AGENT_ID}/permission-grants`, "PATCH", { permissionKey: "commentary:view", enabled: false }], + ]); + }); + it("refuses to delete an instructions file without confirmation", async () => { const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse())); vi.stubGlobal("fetch", fetchMock); diff --git a/cli/src/commands/client/agent.ts b/cli/src/commands/client/agent.ts index c8e6a122f59..189e313d466 100644 --- a/cli/src/commands/client/agent.ts +++ b/cli/src/commands/client/agent.ts @@ -6,6 +6,7 @@ import { updateAgentInstructionsBundleSchema, updateAgentInstructionsPathSchema, updateAgentPermissionsSchema, + updateAgentPermissionGrantSchema, updateAgentSchema, upsertAgentInstructionsFileSchema, wakeAgentSchema, @@ -462,6 +463,24 @@ export function registerAgentCommands(program: Command): void { }), ); + for (const [command, enabled] of [["permission:grant", true], ["permission:revoke", false]] as const) { + addCommonClientOptions( + agent.command(command) + .description(`${enabled ? "Grant" : "Revoke"} one agent permission (Board permission manager only)`) + .argument("", "Agent UUID") + .argument("", "Permission key, such as commentary:view") + .action(async (agentId: string, permissionKey: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const payload = updateAgentPermissionGrantSchema.parse({ permissionKey, enabled }); + printOutput(await ctx.api.patch(apiPath`/api/agents/${agentId}/permission-grants`, payload), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + } + addCommonClientOptions( agent .command("configuration") diff --git a/doc/CLI.md b/doc/CLI.md index 2f568261688..c767a4a793b 100644 --- a/doc/CLI.md +++ b/doc/CLI.md @@ -588,6 +588,8 @@ Agent configuration and runtime endpoints: ```sh npx paperclipai agent permissions:update --payload-json '{"canCreateAgents":true,"canCreateSkills":true,"canAssignTasks":true}' +npx paperclipai agent permission:grant commentary:view +npx paperclipai agent permission:revoke commentary:view npx paperclipai agent configuration npx paperclipai agent config-revisions npx paperclipai agent config-revision:get @@ -605,6 +607,13 @@ npx paperclipai agent instructions-file:put --path AGENTS.md --conten npx paperclipai agent instructions-file:delete --path AGENTS.md --yes ``` +`permission:grant` and `permission:revoke` require a Board login with +`users:manage_permissions` in the agent's company. They change one permission +key and preserve all other grants. A grant applies without starting a new run. +Granting a key replaces that key's scope with an unscoped company grant; +revoking removes the key. Use `agent get --json` to inspect +`access.grants`. These commands do not reactivate an inactive membership. + Agent config, instructions, skills, project env, environment, secret, and workspace edits affect the next run. Active runs finish with the config they started with. When a saved session, reused workspace, or sandbox lease no longer matches the effective next-run config, Paperclip may start fresh execution and records non-sensitive freshness categories in run result JSON and workspace operation logs. `agent local-cli` is the quickest way to run local Claude/Codex manually as a Paperclip agent: diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9ab71a2cf39..e84cedb5f46 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2374,6 +2374,8 @@ export { updateCompanyMemberWithPermissionsSchema, archiveCompanyMemberSchema, updateMemberPermissionsSchema, + updateAgentPermissionGrantSchema, + type UpdateAgentPermissionGrant, searchAdminUsersQuerySchema, updateUserCompanyAccessSchema, type CreateCostEvent, diff --git a/packages/shared/src/validators/access.ts b/packages/shared/src/validators/access.ts index c046b84d56d..03d78d52b9a 100644 --- a/packages/shared/src/validators/access.ts +++ b/packages/shared/src/validators/access.ts @@ -104,6 +104,13 @@ export const updateMemberPermissionsSchema = z.object({ export type UpdateMemberPermissions = z.infer; +export const updateAgentPermissionGrantSchema = z.object({ + permissionKey: z.enum(PERMISSION_KEYS), + enabled: z.boolean(), +}).strict(); + +export type UpdateAgentPermissionGrant = z.infer; + const editableMembershipStatuses = ["pending", "active", "suspended"] as const; export const updateCompanyMemberSchema = z.object({ diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index d62b0678d5f..248b0b48865 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -768,6 +768,8 @@ export { updateCompanyMemberWithPermissionsSchema, archiveCompanyMemberSchema, updateMemberPermissionsSchema, + updateAgentPermissionGrantSchema, + type UpdateAgentPermissionGrant, searchAdminUsersQuerySchema, updateUserCompanyAccessSchema, type CreateCompanyInvite, diff --git a/server/src/__tests__/agent-permission-grants.test.ts b/server/src/__tests__/agent-permission-grants.test.ts new file mode 100644 index 00000000000..1ac61da295a --- /dev/null +++ b/server/src/__tests__/agent-permission-grants.test.ts @@ -0,0 +1,114 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { and, eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { activityLog, agents, authUsers, companies, companyMemberships, createDb, + principalPermissionGrants, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db"; +import { agentPermissionGrantRoutes } from "../routes/agent-permission-grants.js"; +import { agentCommentaryRoutes } from "../routes/agent-commentary.js"; +import { errorHandler } from "../middleware/error-handler.js"; +import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; + +describe("Board agent permission grants", () => { + let database: Awaited>; + let db: ReturnType; + beforeAll(async () => { + database = await startEmbeddedPostgresTestDatabase("agent-permission-grants-"); + db = createDb(database.connectionString); + }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); + afterAll(async () => { await db?.$client.end(); await database?.cleanup(); }); + + async function seed() { + const companyId = randomUUID(), agentId = randomUUID(), userId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Permission grants", issuePrefix: `G${companyId.slice(0, 6)}` }); + await db.insert(authUsers).values({ id: userId, name: "Owner", email: `${userId}@example.com`, + createdAt: new Date(), updatedAt: new Date() }); + await db.insert(agents).values({ id: agentId, companyId, name: "Reader", adapterType: "process", status: "idle" }); + await db.insert(companyMemberships).values([ + { companyId, principalType: "user", principalId: userId, membershipRole: "owner", status: "active" }, + { companyId, principalType: "agent", principalId: agentId, membershipRole: "member", status: "active" }, + ]); + await db.insert(principalPermissionGrants).values({ companyId, principalType: "user", + principalId: userId, permissionKey: "users:manage_permissions" }); + return { companyId, agentId, userId }; + } + type Fixture = Awaited>; + function app(actor: express.Request["actor"]) { + const api = express(); + api.use(express.json()); + api.use((req, _res, next) => { req.actor = actor; next(); }); + api.use(agentPermissionGrantRoutes(db)); + api.use(agentCommentaryRoutes(db)); + api.use(errorHandler); + return api; + } + function owner(f: Fixture) { + return app({ type: "board", source: "session", userId: f.userId, companyIds: [f.companyId], + memberships: [{ companyId: f.companyId, membershipRole: "owner", status: "active" }] }); + } + const path = (f: Fixture) => `/agents/${f.agentId}/permission-grants`; + const grant = { permissionKey: "commentary:view", enabled: true }; + const grants = (f: Fixture) => db.select().from(principalPermissionGrants).where(and( + eq(principalPermissionGrants.companyId, f.companyId), eq(principalPermissionGrants.principalType, "agent"), + eq(principalPermissionGrants.principalId, f.agentId), + )); + + it("grants and revokes commentary reads while preserving other grants and recording the operator", async () => { + const f = await seed(); + await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "agent", + principalId: f.agentId, permissionKey: "tasks:assign", scope: { keep: true } }); + const reader = app({ type: "agent", source: "agent_key", companyId: f.companyId, agentId: f.agentId }); + const commentary = `/companies/${f.companyId}/agent-commentary`; + expect((await request(reader).get(commentary)).status).toBe(403); + const response = await request(owner(f)).patch(path(f)).send(grant); + expect(response.status, response.text).toBe(200); + expect(response.body).toEqual({ agentId: f.agentId, companyId: f.companyId, ...grant }); + expect((await request(reader).get(commentary)).status).toBe(200); + expect(await grants(f)).toEqual(expect.arrayContaining([ + expect.objectContaining({ permissionKey: "tasks:assign", scope: { keep: true } }), + expect.objectContaining({ permissionKey: "commentary:view", scope: null, grantedByUserId: f.userId }), + ])); + expect((await request(owner(f)).patch(path(f)).send({ ...grant, enabled: false })).status).toBe(200); + expect((await request(reader).get(commentary)).status).toBe(403); + expect(await grants(f)).toEqual([expect.objectContaining({ permissionKey: "tasks:assign", scope: { keep: true } })]); + const audit = await db.select().from(activityLog).where(and(eq(activityLog.entityId, f.agentId), + eq(activityLog.action, "agent.permission_grant_updated"))); + expect(audit).toHaveLength(2); + expect(audit.map(row => row.details)).toEqual(expect.arrayContaining([grant, { ...grant, enabled: false }])); + expect(audit.every(row => row.actorId === f.userId && row.companyId === f.companyId)).toBe(true); + }); + + it("denies agent self-grants, foreign-company owners, and users without permission-management authority", async () => { + const f = await seed(), foreign = await seed(); + const self = app({ type: "agent", source: "agent_key", companyId: f.companyId, agentId: f.agentId }); + expect((await request(self).patch(path(f)).send(grant)).status).toBe(403); + expect((await request(app({ type: "none", source: "none" })).patch(path(f)).send(grant)).status).toBe(401); + expect((await request(owner(foreign)).patch(path(f)).send(grant)).status).toBe(404); + await db.update(companyMemberships).set({ membershipRole: "viewer" }).where(and( + eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, f.userId))); + await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, f.userId)); + expect((await request(owner(f)).patch(path(f)).send(grant)).status).toBe(403); + expect(await grants(f)).toEqual([]); + }); + + it("rejects invalid grants and inactive agents or memberships without reactivating them", async () => { + const f = await seed(), api = owner(f); + expect((await request(api).patch(path(f)).send({ ...grant, permissionKey: "invalid" })).status).toBe(400); + expect((await request(api).patch(path(f)).send({ ...grant, scope: { companyId: "foreign" } })).status).toBe(400); + await db.update(agents).set({ status: "pending_approval" }).where(eq(agents.id, f.agentId)); + expect((await request(api).patch(path(f)).send(grant)).status).toBe(409); + await db.update(agents).set({ status: "idle" }).where(eq(agents.id, f.agentId)); + await db.update(companyMemberships).set({ status: "suspended" }).where(and( + eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, f.agentId))); + expect((await request(api).patch(path(f)).send(grant)).status).toBe(409); + expect(await grants(f)).toEqual([]); + }); + + it("serializes concurrent additions of the same grant", async () => { + const f = await seed(), api = owner(f); + const responses = await Promise.all([request(api).patch(path(f)).send(grant), request(api).patch(path(f)).send(grant)]); + expect(responses.map(response => response.status)).toEqual([200, 200]); + expect(await grants(f)).toHaveLength(1); + }); +}); diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 0b28e22572a..98d8ad9d8e3 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -23,6 +23,7 @@ const apiPrefixes: Record = { "adapters.ts": "/api", "agents.ts": "/api", "agent-project-access.ts": "/api", + "agent-permission-grants.ts": "/api", "agent-commentary.ts": "/api", "agent-chat-messages.ts": "/api", "agent-avatars.ts": "/api", @@ -1180,6 +1181,16 @@ describe("openapi routes", () => { }); }); + it("documents agent permission grants as Board-only with a strict request", () => { + const operation = loadSpecRoutes().spec.paths["/api/agents/{id}/permission-grants"].patch; + expect(operation["x-paperclip-authorization"]).toEqual({ actor: "board" }); + expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]); + expect(operation.requestBody.content["application/json"].schema).toMatchObject({ + additionalProperties: false, required: ["permissionKey", "enabled"], + properties: { enabled: { type: "boolean" }, permissionKey: { enum: expect.arrayContaining(["commentary:view"]) } }, + }); + }); + it("documents additive project access as board-only with a strict request", () => { const { spec } = loadSpecRoutes(); const path = spec.paths["/api/agents/{id}/project-access"]; diff --git a/server/src/app.ts b/server/src/app.ts index 2a3560b5014..df846ded332 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -106,6 +106,7 @@ import { summarySlotRoutes } from "./routes/summary-slots.js"; import { statusCardRoutes } from "./routes/status-cards.js"; import { teamsCatalogRoutes } from "./routes/teams-catalog.js"; import { agentRoutes } from "./routes/agents.js"; +import { agentPermissionGrantRoutes } from "./routes/agent-permission-grants.js"; import { resolveAgentRunnerTargetForCompany } from "./services/agent-runner-selection.js"; import type { SetupTokenSessionService } from "./services/setup-token-session.js"; import { @@ -795,6 +796,7 @@ export async function createApp( }, }), ); + api.use(agentPermissionGrantRoutes(db)); api.use(assetRoutes(db, opts.storageService)); api.use(agentProfileAvatarRoutes(db, opts.storageService)); api.use(projectToolRoutes(db)); diff --git a/server/src/routes/agent-permission-grants.ts b/server/src/routes/agent-permission-grants.ts new file mode 100644 index 00000000000..28a0843e704 --- /dev/null +++ b/server/src/routes/agent-permission-grants.ts @@ -0,0 +1,73 @@ +import { Router } from "express"; +import { agents, companyMemberships, principalPermissionGrants, type Db } from "@paperclipai/db"; +import { updateAgentPermissionGrantSchema, isUuidLike } from "@paperclipai/shared"; +import { and, eq } from "drizzle-orm"; +import { badRequest, conflict, forbidden, notFound } from "../errors.js"; +import { validate } from "../middleware/validate.js"; +import { accessService } from "../services/access.js"; +import { logActivity, publishActivity, type ActivityPublication } from "../services/activity-log.js"; +import { assertAuthenticated, assertBoard, assertCompanyAccess, hasCompanyAccess } from "./authz.js"; + +export function agentPermissionGrantRoutes(db: Db) { + const router = Router(); + + router.patch("/agents/:id/permission-grants", validate(updateAgentPermissionGrantSchema), async (req, res) => { + assertAuthenticated(req); + assertBoard(req); + const id = req.params.id as string; + if (!isUuidLike(id)) throw badRequest("Agent ID must be a UUID"); + const { permissionKey, enabled } = req.body; + const publications: ActivityPublication[] = []; + const result = await db.transaction(async (tx) => { + const agent = await tx.select({ id: agents.id, companyId: agents.companyId, status: agents.status }) + .from(agents).where(eq(agents.id, id)).for("update").then(rows => rows[0]); + if (!agent || !hasCompanyAccess(req, agent.companyId)) throw notFound("Agent not found"); + assertCompanyAccess(req, agent.companyId); + const txDb = tx as unknown as Db; + const access = accessService(txDb); + if (req.actor.source !== "local_implicit" + && !await access.canUser(agent.companyId, req.actor.userId, "users:manage_permissions")) { + throw forbidden("Missing permission: users:manage_permissions"); + } + if (enabled) { + if (agent.status === "pending_approval" || agent.status === "terminated") { + throw conflict("Approve or restore the agent before granting permissions"); + } + const membership = await tx.select().from(companyMemberships).where(and( + eq(companyMemberships.companyId, agent.companyId), + eq(companyMemberships.principalType, "agent"), + eq(companyMemberships.principalId, agent.id), + )).for("update").then(rows => rows[0]); + if (membership && membership.status !== "active") { + throw conflict("Agent company membership is not active"); + } + if (!membership) await access.ensureMembership(agent.companyId, "agent", agent.id); + await tx.insert(principalPermissionGrants).values({ + companyId: agent.companyId, principalType: "agent", principalId: agent.id, + permissionKey, scope: null, grantedByUserId: req.actor.userId ?? null, + }).onConflictDoUpdate({ + target: [principalPermissionGrants.companyId, principalPermissionGrants.principalType, + principalPermissionGrants.principalId, principalPermissionGrants.permissionKey], + set: { scope: null, grantedByUserId: req.actor.userId ?? null, updatedAt: new Date() }, + }); + } else { + await tx.delete(principalPermissionGrants).where(and( + eq(principalPermissionGrants.companyId, agent.companyId), + eq(principalPermissionGrants.principalType, "agent"), + eq(principalPermissionGrants.principalId, agent.id), + eq(principalPermissionGrants.permissionKey, permissionKey), + )); + } + await logActivity(txDb, { + companyId: agent.companyId, actorType: "user", actorId: req.actor.userId ?? "board", + action: "agent.permission_grant_updated", entityType: "agent", entityId: agent.id, + details: { permissionKey, enabled }, + }, publications); + return { agentId: agent.id, companyId: agent.companyId, permissionKey, enabled }; + }); + for (const publication of publications) publishActivity(publication); + res.json(result); + }); + + return router; +} diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 02dea5ce76a..816c533ad41 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -51,6 +51,7 @@ import { updateAgentSchema, updatePrimaryAgentSchema, updateAgentPermissionsSchema, + updateAgentPermissionGrantSchema, addAgentProjectAccessSchema, updateAgentInstructionsPathSchema, updateAgentInstructionsBundleSchema, @@ -1429,6 +1430,7 @@ const browserUseOperations = [ ] as const; const BOARD_ONLY_OPERATIONS = new Set([ + "PATCH /api/agents/{id}/permission-grants", // Budget changes are board-only (assertBoard in routes/costs.ts). "POST /api/companies/{companyId}/budgets/policies", "POST /api/companies/{companyId}/budget-incidents/{incidentId}/resolve", @@ -3834,6 +3836,16 @@ registry.registerPath({ responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, }); +registry.registerPath({ + method: "patch", + path: "/api/agents/{id}/permission-grants", + tags: ["agents"], + summary: "Grant or revoke one explicit agent permission", + description: "Board-only. Requires users:manage_permissions in the agent's company. Enables an unscoped grant or removes that permission key without replacing other grants. Does not reactivate inactive memberships or start agent work. Writes an activity record in the same transaction.", + request: { params: z.object({ id: z.string().uuid() }), body: jsonBody(updateAgentPermissionGrantSchema) }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict }, +}); + const agentProjectAccessResponse = z.object({ projectIds: z.array(z.string().uuid()), canManage: z.boolean() }); registry.registerPath({ method: "get",