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 646a815d1d4..c767a4a793b 100644 --- a/doc/CLI.md +++ b/doc/CLI.md @@ -9,7 +9,9 @@ Paperclip CLI now supports both: ## Browser-approved Board authentication `npx paperclipai auth login --api-base ` creates a ten-minute -challenge and prints a browser approval link. Sign in to the instance, review +challenge and prints a browser approval link. The link uses the instance's +canonical public origin when configured, so reverse proxies do not send the +browser to an internal upstream hostname. Sign in to the instance, review the requesting command and full Board access, then approve. The pending token cannot authenticate before approval. The CLI verifies its resulting identity before storing its credential in its private auth store. The default Board key @@ -586,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 @@ -603,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..5dbbf69c0cc --- /dev/null +++ b/server/src/__tests__/agent-permission-grants.test.ts @@ -0,0 +1,127 @@ +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, vi } 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"; +import * as activityLogging from "../services/activity-log.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([]); + await db.delete(companyMemberships).where(and( + eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, f.agentId))); + expect((await request(api).patch(path(f)).send(grant)).status).toBe(409); + }); + + 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); + }); + + it("rolls back the grant if its audit record cannot be saved", async () => { + const f = await seed(); + const failure = vi.spyOn(activityLogging, "logActivity").mockRejectedValueOnce(new Error("Audit storage unavailable")); + try { + expect((await request(owner(f)).patch(path(f)).send(grant)).status).toBe(500); + expect(await grants(f)).toEqual([]); + } finally { failure.mockRestore(); } + }); +}); diff --git a/server/src/__tests__/cli-auth-routes.test.ts b/server/src/__tests__/cli-auth-routes.test.ts index c60cf770d06..24b6857ecb9 100644 --- a/server/src/__tests__/cli-auth-routes.test.ts +++ b/server/src/__tests__/cli-auth-routes.test.ts @@ -53,7 +53,7 @@ function registerModuleMocks() { let appImportCounter = 0; -async function createApp(actor: any, db: any = {} as any, options: { cliAuthCreateRateLimiter?: InviteRateLimiter; cliAuthPollRateLimiter?: InviteRateLimiter } = {}) { +async function createApp(actor: any, db: any = {} as any, options: { authPublicBaseUrl?: string; cliAuthCreateRateLimiter?: InviteRateLimiter; cliAuthPollRateLimiter?: InviteRateLimiter } = {}) { appImportCounter += 1; const routeModulePath = `../routes/access.js?cli-auth-routes-${appImportCounter}`; const middlewareModulePath = `../middleware/index.js?cli-auth-routes-${appImportCounter}`; @@ -137,6 +137,31 @@ describe("cli auth routes", () => { expect(res.body.approvalUrl).toContain("/cli-auth/12345678-1234-4123-8123-123456789abc?token=pcp_cli_auth_secret"); }); + it("uses the configured public origin for CLI approval behind an upstream proxy", async () => { + mockBoardAuthService.createCliAuthChallenge.mockResolvedValue({ + challenge: { + id: "12345678-1234-4123-8123-123456789abc", + expiresAt: new Date(Date.now() + 60_000), + }, + challengeSecret: "pcp_cli_auth_secret", + pendingBoardToken: "pcp_board_token", + }); + const app = await createApp({ type: "none", source: "none" }, {}, { + authPublicBaseUrl: "https://workspace.example.com/", + }); + const res = await request(app) + .post("/api/cli-auth/challenges") + .set("Host", "upstream.example.net") + .set("X-Forwarded-Host", "upstream.example.net") + .set("X-Forwarded-Proto", "https") + .send({ command: "paperclipai auth login", requestedAccess: "board" }); + + expect(res.status).toBe(201); + expect(res.body.approvalUrl).toBe( + `https://workspace.example.com${res.body.approvalPath}`, + ); + }); + it("bounds anonymous create and poll independently without trusting spoofed forwarding headers", async () => { let now = 1_000; mockBoardAuthService.createCliAuthChallenge.mockResolvedValue({ 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/access.ts b/server/src/routes/access.ts index 3279a1ec780..96e549afdfa 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -2795,7 +2795,7 @@ export function accessRoutes( created.challenge.id, created.challengeSecret, ); - const baseUrl = requestBaseUrl(req); + const baseUrl = resolveBaseUrl(req, opts.authPublicBaseUrl); res.status(201).json({ id: created.challenge.id, token: created.challengeSecret, diff --git a/server/src/routes/agent-permission-grants.ts b/server/src/routes/agent-permission-grants.ts new file mode 100644 index 00000000000..ae2e529d142 --- /dev/null +++ b/server/src/routes/agent-permission-grants.ts @@ -0,0 +1,72 @@ +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("Cannot grant permissions to an unapproved or terminated agent"); + } + 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"); + } + 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",