From 73e5bd8f4efb358cace5e56067fc42f4335cfda2 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 26 Jun 2026 03:25:04 +0000 Subject: [PATCH] PAP-11957 scope Hermes task bridge credentials --- packages/adapters/hermes-local/README.md | 8 +- .../skills/paperclip-task-bridge/SKILL.md | 26 ++- .../paperclip-task-bridge/paperclip-task.mjs | 50 ++---- packages/adapters/hermes-local/src/index.ts | 7 +- .../src/server/paperclip-task-bridge.test.ts | 12 +- .../0113_agent_api_key_scope_config.sql | 1 + packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema/agent_api_keys.ts | 4 +- packages/shared/src/index.ts | 6 + packages/shared/src/types/agent.ts | 2 + packages/shared/src/validators/agent.ts | 37 +++++ packages/shared/src/validators/index.ts | 6 + .../__tests__/authorization-service.test.ts | 105 ++++++++++++ ...ue-agent-mutation-ownership-routes.test.ts | 16 ++ server/src/adapters/hermes-gateway-doc.ts | 5 +- server/src/middleware/auth.ts | 3 +- server/src/routes/agents.ts | 16 +- server/src/routes/issues.ts | 71 +++++--- server/src/services/agents.ts | 27 +++- server/src/services/authorization.ts | 153 +++++++++++++++++- server/src/types/express.d.ts | 3 + ui/src/api/agents.ts | 6 +- 22 files changed, 493 insertions(+), 78 deletions(-) create mode 100644 packages/db/src/migrations/0113_agent_api_key_scope_config.sql diff --git a/packages/adapters/hermes-local/README.md b/packages/adapters/hermes-local/README.md index 1279a66f532..a514c4ce8bc 100644 --- a/packages/adapters/hermes-local/README.md +++ b/packages/adapters/hermes-local/README.md @@ -118,7 +118,7 @@ Configure Paperclip access in Hermes env/profile secrets, not prompt text: ```bash PAPERCLIP_API_URL=http://127.0.0.1:3100/api -PAPERCLIP_API_KEY= +PAPERCLIP_BRIDGE_API_KEY= ``` Optional env values: @@ -132,7 +132,7 @@ commands: ```bash node ./paperclip-task.mjs list-assigned -node ./paperclip-task.mjs create-task --title "Investigate checkout failures" --description "Capture failing request and root cause." +node ./paperclip-task.mjs create-task --parent-id "" --title "Investigate checkout failures" --description "Capture failing request and root cause." node ./paperclip-task.mjs comment --issue PAP-123 --body "Found the failing request path." node ./paperclip-task.mjs update-status --issue PAP-123 --status in_review --comment "Ready for review." ``` @@ -141,6 +141,10 @@ The helper reads credentials from environment variables and prints only JSON summaries. It supports `create-task`, `comment`, `update-status`, and `list-assigned`. +Create the bridge key with `scope.kind = "task_bridge"` plus a `parentIssueId` +or `projectId` boundary. Do not use a normal claimed agent API key for +internet-facing Hermes chat/webhook task-bridge operations. + ### 3. Assign work Create issues in Paperclip and assign them to your Hermes agent. On each heartbeat, Hermes will: diff --git a/packages/adapters/hermes-local/skills/paperclip-task-bridge/SKILL.md b/packages/adapters/hermes-local/skills/paperclip-task-bridge/SKILL.md index 96b29e98d6e..b325cec5162 100644 --- a/packages/adapters/hermes-local/skills/paperclip-task-bridge/SKILL.md +++ b/packages/adapters/hermes-local/skills/paperclip-task-bridge/SKILL.md @@ -12,15 +12,35 @@ Use this skill when a Hermes-originated request needs to create or update Paperc Configure these in Hermes env/profile secrets, not in prompt text: - `PAPERCLIP_API_URL` - Paperclip base URL, with or without `/api`. -- `PAPERCLIP_API_KEY` - scoped Paperclip agent API key for the Hermes actor. +- `PAPERCLIP_BRIDGE_API_KEY` - a Paperclip agent API key created with `scope.kind = "task_bridge"`. Optional: +- `PAPERCLIP_API_KEY` - fallback env var for older profiles; it must still contain a `task_bridge` scoped key, never a full agent key. - `PAPERCLIP_COMPANY_ID` - skips one identity lookup when set. - `PAPERCLIP_AGENT_ID` - skips one identity lookup when set. - `PAPERCLIP_RUN_ID` - sent as `X-Paperclip-Run-Id` on mutating requests when Hermes is running inside a Paperclip heartbeat. -Never print or paste API keys. The helper reads credentials from environment variables and only prints response summaries. +Never print or paste API keys. The helper reads credentials from environment variables and only prints response summaries. Do not put a normal claimed agent API key in an internet-facing Hermes runtime; normal keys can use broad same-company Paperclip routes. + +## Create a Bridge Key + +Create the key from a board-authenticated Paperclip API session and store the returned token once: + +```sh +curl -X POST "$PAPERCLIP_API_URL/api/agents/$HERMES_AGENT_ID/keys" \ + -H "Authorization: Bearer $BOARD_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Hermes task bridge", + "scope": { + "kind": "task_bridge", + "parentIssueId": "00000000-0000-4000-8000-000000000000" + } + }' +``` + +Use `parentIssueId` or `parentIssueIds` when Hermes should only create child tasks under approved work. Use `projectId` or `projectIds` when the approved boundary is a project. A bridge key can create tasks only inside that boundary, can comment/update only bridge-created or assigned issues, and cannot use company-wide issue list/search/read surfaces. ## Helper @@ -34,7 +54,7 @@ Commands: ```sh node ./paperclip-task.mjs list-assigned -node ./paperclip-task.mjs create-task --title "Investigate checkout failures" --description "Capture failing request and root cause." +node ./paperclip-task.mjs create-task --parent-id "00000000-0000-4000-8000-000000000000" --title "Investigate checkout failures" --description "Capture failing request and root cause." node ./paperclip-task.mjs comment --issue PAP-123 --body "Found the failing request path." node ./paperclip-task.mjs update-status --issue PAP-123 --status in_review --comment "Ready for review." ``` diff --git a/packages/adapters/hermes-local/skills/paperclip-task-bridge/paperclip-task.mjs b/packages/adapters/hermes-local/skills/paperclip-task-bridge/paperclip-task.mjs index d7b4da9b5df..956e9532819 100755 --- a/packages/adapters/hermes-local/skills/paperclip-task-bridge/paperclip-task.mjs +++ b/packages/adapters/hermes-local/skills/paperclip-task-bridge/paperclip-task.mjs @@ -17,7 +17,9 @@ Usage: Environment: PAPERCLIP_API_URL Paperclip base URL, with or without /api. - PAPERCLIP_API_KEY Scoped Paperclip agent API key. + PAPERCLIP_BRIDGE_API_KEY + Task-bridge Paperclip API key with kind=task_bridge scope. + PAPERCLIP_API_KEY Fallback bridge key env var. Do not use a full agent key. PAPERCLIP_COMPANY_ID Optional company id override. PAPERCLIP_AGENT_ID Optional agent id override. PAPERCLIP_RUN_ID Optional run id for X-Paperclip-Run-Id on mutations. @@ -101,8 +103,8 @@ function normalizeApiBaseUrl(raw) { } function getConfig() { - const apiKey = process.env.PAPERCLIP_API_KEY?.trim(); - if (!apiKey) throw new UsageError("PAPERCLIP_API_KEY is required"); + const apiKey = process.env.PAPERCLIP_BRIDGE_API_KEY?.trim() || process.env.PAPERCLIP_API_KEY?.trim(); + if (!apiKey) throw new UsageError("PAPERCLIP_BRIDGE_API_KEY is required"); return { apiBaseUrl: normalizeApiBaseUrl(process.env.PAPERCLIP_API_URL), apiKey, @@ -217,39 +219,21 @@ function parseLimit(args) { return value; } -async function resolveIssueId(config, companyId, ref) { - if (UUID_RE.test(ref)) return ref; - const query = new URLSearchParams({ - q: ref, - limit: "10", - }); - const issues = await apiFetch(config, `/companies/${encodeURIComponent(companyId)}/issues?${query}`); - const match = Array.isArray(issues) - ? issues.find((issue) => issue?.identifier === ref || issue?.id === ref) - : null; - if (!match?.id) throw new ApiError(404, { error: `Issue not found for reference ${ref}` }); - return match.id; -} - async function listAssigned(config, args) { const identity = await resolveIdentity(config); const status = readStringFlag(args, "status") || "todo,in_progress,in_review,blocked"; const limit = parseLimit(args); - const query = new URLSearchParams({ - assigneeAgentId: identity.agentId, - status, - limit: String(limit), - sortField: "updated", - sortDir: "desc", - includeBlockedBy: "true", - }); - const issues = await apiFetch(config, `/companies/${encodeURIComponent(identity.companyId)}/issues?${query}`); + const issues = await apiFetch(config, "/agents/me/inbox-lite"); + const allowedStatuses = new Set(status.split(",").map((entry) => entry.trim()).filter(Boolean)); + const filteredIssues = Array.isArray(issues) + ? issues.filter((issue) => !allowedStatuses.size || allowedStatuses.has(issue?.status)).slice(0, limit) + : []; printJson({ command: "list-assigned", companyId: identity.companyId, agentId: identity.agentId, - count: Array.isArray(issues) ? issues.length : 0, - issues: Array.isArray(issues) ? issues.map(issueSummary) : [], + count: filteredIssues.length, + issues: filteredIssues.map(issueSummary), }); } @@ -298,37 +282,33 @@ async function createTask(config, args) { } async function comment(config, args) { - const identity = await resolveIdentity(config); const issueRef = requireStringFlag(args, "issue"); const bodyText = await readBody(args, "body", "body-file"); if (!bodyText || bodyText.trim().length === 0) throw new UsageError("comment requires --body or --body-file"); - const issueId = await resolveIssueId(config, identity.companyId, issueRef); const commentBody = { body: bodyText, ...(boolFlag(args, "resume") ? { resume: true } : {}), ...(boolFlag(args, "reopen") ? { reopen: true } : {}), }; - const created = await apiFetch(config, `/issues/${encodeURIComponent(issueId)}/comments`, { + const created = await apiFetch(config, `/issues/${encodeURIComponent(issueRef)}/comments`, { method: "POST", mutating: true, body: commentBody, }); - printJson({ command: "comment", issueId, comment: commentSummary(created) }); + printJson({ command: "comment", issue: issueRef, comment: commentSummary(created) }); } async function updateStatus(config, args) { - const identity = await resolveIdentity(config); const issueRef = requireStringFlag(args, "issue"); const status = validateEnum(requireStringFlag(args, "status"), STATUSES, "status"); const commentText = await readBody(args, "comment", "comment-file"); - const issueId = await resolveIssueId(config, identity.companyId, issueRef); const body = { status, ...(commentText && commentText.trim().length > 0 ? { comment: commentText } : {}), ...(boolFlag(args, "resume") ? { resume: true } : {}), ...(boolFlag(args, "reopen") ? { reopen: true } : {}), }; - const issue = await apiFetch(config, `/issues/${encodeURIComponent(issueId)}`, { + const issue = await apiFetch(config, `/issues/${encodeURIComponent(issueRef)}`, { method: "PATCH", mutating: true, body, diff --git a/packages/adapters/hermes-local/src/index.ts b/packages/adapters/hermes-local/src/index.ts index 7cf1298f1ef..5f9a535c5f4 100644 --- a/packages/adapters/hermes-local/src/index.ts +++ b/packages/adapters/hermes-local/src/index.ts @@ -115,7 +115,8 @@ to create, comment on, update, or list Paperclip tasks. Configure credentials through Hermes env/profile secrets, never in prompt text: - \`PAPERCLIP_API_URL\` - Paperclip base URL, with or without \`/api\` -- \`PAPERCLIP_API_KEY\` - scoped Paperclip agent API key +- \`PAPERCLIP_BRIDGE_API_KEY\` - Paperclip agent API key created with \`scope.kind = "task_bridge"\` +- optional fallback \`PAPERCLIP_API_KEY\` - must still be a task_bridge key, never a normal claimed agent key - optional \`PAPERCLIP_COMPANY_ID\`, \`PAPERCLIP_AGENT_ID\`, and \`PAPERCLIP_RUN_ID\` The bridge is separate from adapter execution: @@ -124,6 +125,10 @@ The bridge is separate from adapter execution: - \`hermes_gateway\` means Paperclip wakes remote Hermes through Hermes's API server. - \`paperclip-task-bridge\` means Hermes calls Paperclip's REST API to manage tasks. +Create task bridge keys with a parent issue or project boundary. Do not expose +normal claimed Paperclip agent API keys to internet-facing Hermes chat/webhook +task-bridge surfaces. + ## Available Template Variables - \`{{agentId}}\` — Paperclip agent ID diff --git a/packages/adapters/hermes-local/src/server/paperclip-task-bridge.test.ts b/packages/adapters/hermes-local/src/server/paperclip-task-bridge.test.ts index 8d64cd72585..1801407269d 100644 --- a/packages/adapters/hermes-local/src/server/paperclip-task-bridge.test.ts +++ b/packages/adapters/hermes-local/src/server/paperclip-task-bridge.test.ts @@ -68,7 +68,7 @@ describe("paperclip-task-bridge helper", () => { res.end(JSON.stringify({ id: "11111111-1111-4111-8111-111111111111", companyId: "22222222-2222-4222-8222-222222222222" })); return; } - if (req.method === "GET" && req.url?.startsWith("/api/companies/22222222-2222-4222-8222-222222222222/issues")) { + if (req.method === "GET" && req.url === "/api/agents/me/inbox-lite") { res.end(JSON.stringify([ { id: "33333333-3333-4333-8333-333333333333", @@ -94,7 +94,7 @@ describe("paperclip-task-bridge helper", () => { })); return; } - if (req.method === "POST" && req.url === "/api/issues/33333333-3333-4333-8333-333333333333/comments") { + if (req.method === "POST" && req.url === "/api/issues/PAP-123/comments") { res.statusCode = 201; res.end(JSON.stringify({ id: "55555555-5555-4555-8555-555555555555", @@ -105,7 +105,7 @@ describe("paperclip-task-bridge helper", () => { })); return; } - if (req.method === "PATCH" && req.url === "/api/issues/33333333-3333-4333-8333-333333333333") { + if (req.method === "PATCH" && req.url === "/api/issues/PAP-123") { res.end(JSON.stringify({ id: "33333333-3333-4333-8333-333333333333", identifier: "PAP-123", @@ -134,7 +134,7 @@ describe("paperclip-task-bridge helper", () => { function env() { return { PAPERCLIP_API_URL: baseUrl, - PAPERCLIP_API_KEY: apiKey, + PAPERCLIP_BRIDGE_API_KEY: apiKey, PAPERCLIP_COMPANY_ID: "22222222-2222-4222-8222-222222222222", PAPERCLIP_AGENT_ID: "11111111-1111-4111-8111-111111111111", PAPERCLIP_RUN_ID: "66666666-6666-4666-8666-666666666666", @@ -149,7 +149,7 @@ describe("paperclip-task-bridge helper", () => { expect(result.stdout).toContain('"identifier": "PAP-123"'); expect(result.stdout).not.toContain(apiKey); expect(result.stderr).not.toContain(apiKey); - expect(requests.some((request) => request.url.includes("assigneeAgentId=11111111-1111-4111-8111-111111111111"))).toBe(true); + expect(requests.some((request) => request.url === "/api/agents/me/inbox-lite")).toBe(true); }); it("creates tasks assigned to the authenticated agent by default", async () => { @@ -169,7 +169,7 @@ describe("paperclip-task-bridge helper", () => { expect(result.stdout).not.toContain(apiKey); }); - it("comments and updates status by resolving issue identifiers", async () => { + it("comments and updates status through direct issue identifier routes", async () => { const comment = await runHelper(["comment", "--issue", "PAP-123", "--body", "Progress from Hermes"], env()); const update = await runHelper(["update-status", "--issue", "PAP-123", "--status", "in_review", "--comment", "Ready"], env()); diff --git a/packages/db/src/migrations/0113_agent_api_key_scope_config.sql b/packages/db/src/migrations/0113_agent_api_key_scope_config.sql new file mode 100644 index 00000000000..52167d2526d --- /dev/null +++ b/packages/db/src/migrations/0113_agent_api_key_scope_config.sql @@ -0,0 +1 @@ +ALTER TABLE "agent_api_keys" ADD COLUMN IF NOT EXISTS "scope_config" jsonb; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 06d0dfe20d4..9df822164a9 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -792,6 +792,13 @@ "when": 1781902600000, "tag": "0112_rename_skill_create_permission_key", "breakpoints": true + }, + { + "idx": 113, + "version": "7", + "when": 1782440000000, + "tag": "0113_agent_api_key_scope_config", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/agent_api_keys.ts b/packages/db/src/schema/agent_api_keys.ts index 025fab437f4..8cc430ae008 100644 --- a/packages/db/src/schema/agent_api_keys.ts +++ b/packages/db/src/schema/agent_api_keys.ts @@ -1,4 +1,5 @@ -import { pgTable, uuid, text, timestamp, index } from "drizzle-orm/pg-core"; +import { pgTable, uuid, text, timestamp, index, jsonb } from "drizzle-orm/pg-core"; +import type { AgentApiKeyScope } from "@paperclipai/shared"; import { agents } from "./agents.js"; import { companies } from "./companies.js"; @@ -10,6 +11,7 @@ export const agentApiKeys = pgTable( companyId: uuid("company_id").notNull().references(() => companies.id), name: text("name").notNull(), keyHash: text("key_hash").notNull(), + scopeConfig: jsonb("scope_config").$type(), lastUsedAt: timestamp("last_used_at", { withTimezone: true }), revokedAt: timestamp("revoked_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 493d552b4bc..0edbf98426b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1002,6 +1002,10 @@ export { updateAgentInstructionsBundleSchema, upsertAgentInstructionsFileSchema, updateAgentInstructionsPathSchema, + agentApiKeyScopeSchema, + normalizeAgentApiKeyScope, + standardAgentKeyScopeSchema, + taskBridgeAgentKeyScopeSchema, createAgentKeySchema, agentMineInboxQuerySchema, wakeAgentSchema, @@ -1015,6 +1019,8 @@ export { type UpdateAgentInstructionsBundle, type UpsertAgentInstructionsFile, type UpdateAgentInstructionsPath, + type AgentApiKeyScope, + type TaskBridgeAgentKeyScope, type CreateAgentKey, type AgentMineInboxQuery, type WakeAgent, diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index ad16c7dad80..38df57ba1e8 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -14,6 +14,7 @@ import type { TrustPreset, } from "../trust-policy.js"; import type { AgentOrgChainHealth } from "../agent-eligibility.js"; +import type { AgentApiKeyScope } from "../validators/agent.js"; export interface AgentPermissions extends Record { canCreateAgents: boolean; @@ -116,6 +117,7 @@ export type ClearAgentErrorResponse = Agent; export interface AgentKeyCreated { id: string; name: string; + scope: AgentApiKeyScope; token: string; createdAt: Date; } diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index 69f1b5b233d..65934b7e6d9 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -114,8 +114,45 @@ export const updateAgentInstructionsPathSchema = z.object({ export type UpdateAgentInstructionsPath = z.infer; +export const taskBridgeAgentKeyScopeSchema = z.object({ + kind: z.literal("task_bridge"), + projectId: z.string().uuid().optional().nullable(), + projectIds: z.array(z.string().uuid()).max(50).optional(), + parentIssueId: z.string().uuid().optional().nullable(), + parentIssueIds: z.array(z.string().uuid()).max(50).optional(), + allowedAssigneeAgentIds: z.array(z.string().uuid()).max(50).optional(), +}).strict().superRefine((value, ctx) => { + const hasProjectBoundary = Boolean(value.projectId) || Boolean(value.projectIds?.length); + const hasParentBoundary = Boolean(value.parentIssueId) || Boolean(value.parentIssueIds?.length); + if (!hasProjectBoundary && !hasParentBoundary) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "task_bridge keys require at least one project or parent issue boundary", + path: ["projectId"], + }); + } +}); + +export const standardAgentKeyScopeSchema = z.object({ + kind: z.literal("standard"), +}).strict(); + +export const agentApiKeyScopeSchema = z.union([ + standardAgentKeyScopeSchema, + taskBridgeAgentKeyScopeSchema, +]); + +export type AgentApiKeyScope = z.infer; +export type TaskBridgeAgentKeyScope = z.infer; + +export function normalizeAgentApiKeyScope(value: unknown): AgentApiKeyScope { + const parsed = agentApiKeyScopeSchema.safeParse(value); + return parsed.success ? parsed.data : { kind: "standard" }; +} + export const createAgentKeySchema = z.object({ name: z.string().min(1).default("default"), + scope: agentApiKeyScopeSchema.optional().default({ kind: "standard" }), }); export type CreateAgentKey = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index f81ddf60a8c..31a8ab843fa 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -207,6 +207,10 @@ export { updateAgentInstructionsBundleSchema, upsertAgentInstructionsFileSchema, updateAgentInstructionsPathSchema, + agentApiKeyScopeSchema, + normalizeAgentApiKeyScope, + standardAgentKeyScopeSchema, + taskBridgeAgentKeyScopeSchema, createAgentKeySchema, agentMineInboxQuerySchema, wakeAgentSchema, @@ -220,6 +224,8 @@ export { type UpdateAgentInstructionsBundle, type UpsertAgentInstructionsFile, type UpdateAgentInstructionsPath, + type AgentApiKeyScope, + type TaskBridgeAgentKeyScope, type CreateAgentKey, type AgentMineInboxQuery, type WakeAgent, diff --git a/server/src/__tests__/authorization-service.test.ts b/server/src/__tests__/authorization-service.test.ts index 2c1dd29427d..f95ff51f54a 100644 --- a/server/src/__tests__/authorization-service.test.ts +++ b/server/src/__tests__/authorization-service.test.ts @@ -73,6 +73,8 @@ async function createIssue( projectId?: string | null; parentId?: string | null; assigneeAgentId?: string | null; + originKind?: string | null; + originId?: string | null; } = {}, ) { return db @@ -86,6 +88,8 @@ async function createIssue( projectId: input.projectId ?? null, parentId: input.parentId ?? null, assigneeAgentId: input.assigneeAgentId ?? null, + originKind: input.originKind ?? "manual", + originId: input.originId ?? null, }) .returning() .then((rows) => rows[0]!); @@ -1187,4 +1191,105 @@ describeEmbeddedPostgres("authorization service", () => { grant: { permissionKey: "tasks:assign" }, }); }); + + it("scopes task bridge keys away from company-wide reads and unrelated issue writes", async () => { + const company = await createCompany(db, "TaskBridge"); + const bridgeAgent = await createAgent(db, company.id); + const targetAgent = await createAgent(db, company.id); + const project = await createProject(db, company.id, "Bridge"); + const parentIssue = await createIssue(db, company.id, { projectId: project.id }); + const assignedIssue = await createIssue(db, company.id, { assigneeAgentId: bridgeAgent.id }); + const keyId = randomUUID(); + const bridgeCreatedIssue = await createIssue(db, company.id, { + originKind: "task_bridge", + originId: keyId, + }); + const unrelatedIssue = await createIssue(db, company.id); + const actor = { + type: "agent" as const, + agentId: bridgeAgent.id, + companyId: company.id, + source: "agent_key" as const, + keyId, + keyScope: { + kind: "task_bridge" as const, + parentIssueId: parentIssue.id, + allowedAssigneeAgentIds: [targetAgent.id], + }, + }; + const authz = authorizationService(db); + + await expect(authz.decide({ + actor, + action: "company_scope:read", + resource: { type: "company", companyId: company.id }, + })).resolves.toMatchObject({ + allowed: false, + reason: "deny_scope", + }); + + await expect(authz.decide({ + actor, + action: "tasks:assign", + resource: { + type: "issue", + companyId: company.id, + parentIssueId: parentIssue.id, + assigneeAgentId: targetAgent.id, + }, + })).resolves.toMatchObject({ + allowed: true, + }); + + await expect(authz.decide({ + actor, + action: "tasks:assign", + resource: { + type: "issue", + companyId: company.id, + projectId: project.id, + assigneeUserId: randomUUID(), + }, + })).resolves.toMatchObject({ + allowed: false, + reason: "deny_scope", + }); + + await expect(authz.decide({ + actor, + action: "issue:comment", + resource: { + type: "issue", + companyId: company.id, + issueId: assignedIssue.id, + }, + })).resolves.toMatchObject({ + allowed: true, + }); + + await expect(authz.decide({ + actor, + action: "issue:mutate", + resource: { + type: "issue", + companyId: company.id, + issueId: bridgeCreatedIssue.id, + }, + })).resolves.toMatchObject({ + allowed: true, + }); + + await expect(authz.decide({ + actor, + action: "issue:mutate", + resource: { + type: "issue", + companyId: company.id, + issueId: unrelatedIssue.id, + }, + })).resolves.toMatchObject({ + allowed: false, + reason: "deny_scope", + }); + }); }); diff --git a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts index f2aabc93e42..e810f3085a4 100644 --- a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts +++ b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts @@ -669,6 +669,22 @@ describe("agent issue mutation checkout ownership", () => { mockStorageService.deleteObject.mockResolvedValue(undefined); }); + it("denies company-wide issue list routes for task bridge keys", async () => { + const app = await createApp(peerActor({ + keyId: "99999999-9999-4999-8999-999999999999", + keyScope: { + kind: "task_bridge", + parentIssueId: issueId, + }, + })); + + const res = await request(app).get(`/api/companies/${companyId}/issues`); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("Task bridge keys cannot use company-wide issue list APIs"); + expect(mockIssueService.list).not.toHaveBeenCalled(); + }); + it("uses the company-scope fast path on the issue list route", async () => { mockAccessService.decide.mockImplementation(async (input: { action: string }) => { if (input.action === "company_scope:read") { diff --git a/server/src/adapters/hermes-gateway-doc.ts b/server/src/adapters/hermes-gateway-doc.ts index a836de377c5..2db9a10de53 100644 --- a/server/src/adapters/hermes-gateway-doc.ts +++ b/server/src/adapters/hermes-gateway-doc.ts @@ -14,7 +14,7 @@ Don't use when: Runtime distinction: - hermes_local: Paperclip starts Hermes on the Paperclip host through the external Hermes adapter plugin. - hermes_gateway: Paperclip calls an already-running Hermes API server using agentDefaultsPayload.apiBaseUrl. -- Hermes-originated Paperclip API usage: Hermes calls Paperclip with PAPERCLIP_API_URL and PAPERCLIP_API_KEY after invite approval and key claim. Do not use agentDefaultsPayload.apiBaseUrl for Paperclip API calls. +- Hermes-originated Paperclip API usage: Hermes calls Paperclip with PAPERCLIP_API_URL and PAPERCLIP_BRIDGE_API_KEY. Create that Paperclip key with scope.kind = "task_bridge" and an approved parent/project boundary; do not expose a normal claimed agent key to Hermes-facing chat/webhook surfaces. Do not use agentDefaultsPayload.apiBaseUrl for Paperclip API calls. Hermes gateway process setup: - Set API_SERVER_ENABLED=true. @@ -49,7 +49,8 @@ Network examples: - Reverse proxy/TLS: publish Hermes behind HTTPS and set agentDefaultsPayload.apiBaseUrl = "https://hermes-gateway.example"; set agentDefaultsPayload.paperclipApiUrl = "https://paperclip.example". Keep API_SERVER_KEY required at the origin or proxy. Security notes: -- Treat API_SERVER_KEY and claimed PAPERCLIP_API_KEY as secrets. +- Treat API_SERVER_KEY and PAPERCLIP_BRIDGE_API_KEY as secrets. +- Never use a normal claimed Paperclip agent API key for internet-facing Hermes-originated task bridge calls; task_bridge keys cannot use company-wide issue list/search/read surfaces and can only mutate bridge-created or assigned issues. - Prefer private network or TLS for non-loopback gateway access. - Use placeholders such as in docs and tests. `; diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index c0cbf954dd9..46650a0beb7 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -4,7 +4,7 @@ import { and, eq, isNull } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agentApiKeys, agents, authUsers, companies, companyMemberships, instanceUserRoles } from "@paperclipai/db"; import { verifyLocalAgentJwt } from "../agent-auth-jwt.js"; -import type { DeploymentMode } from "@paperclipai/shared"; +import { normalizeAgentApiKeyScope, type DeploymentMode } from "@paperclipai/shared"; import type { BetterAuthSessionResult } from "../auth/better-auth.js"; import { logger } from "./logger.js"; import { boardAuthService } from "../services/board-auth.js"; @@ -192,6 +192,7 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa agentId: key.agentId, companyId: key.companyId, keyId: key.id, + keyScope: normalizeAgentApiKeyScope(key.scopeConfig), runId: runIdHeader || undefined, source: "agent_key", }; diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 4fcef1ac2be..57c63ea9ad5 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1947,6 +1947,18 @@ export function agentRoutes( res.json(buildLowTrustSelfView(agent)); return; } + if (req.actor.keyScope?.kind === "task_bridge") { + res.json({ + id: agent.id, + companyId: agent.companyId, + name: agent.name, + role: agent.role, + title: agent.title, + status: agent.status, + keyScope: req.actor.keyScope, + }); + return; + } res.json(await buildAgentDetail(agent)); }); @@ -3188,7 +3200,7 @@ export function agentRoutes( if (!agent) { return; } - const key = await svc.createApiKey(id, req.body.name); + const key = await svc.createApiKey(id, req.body.name, req.body.scope); await logActivity(db, { companyId: agent.companyId, @@ -3197,7 +3209,7 @@ export function agentRoutes( action: "agent.key_created", entityType: "agent", entityId: agent.id, - details: { keyId: key.id, name: key.name }, + details: { keyId: key.id, name: key.name, scope: key.scope }, }); res.status(201).json(key); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index fdc3c19816e..931838a812c 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1847,6 +1847,25 @@ export function issueRoutes( throw forbidden(decision.explanation); } + function isTaskBridgeKeyActor(req: Request) { + return req.actor.type === "agent" && req.actor.source === "agent_key" && req.actor.keyScope?.kind === "task_bridge"; + } + + function taskBridgeOriginForActor(req: Request) { + return isTaskBridgeKeyActor(req) && req.actor.keyId + ? { originKind: "task_bridge", originId: req.actor.keyId } + : null; + } + + async function assertTaskBridgeCreateAllowed( + req: Request, + companyId: string, + assignmentScope: TaskAssignmentAuthorizationScope, + ) { + if (!isTaskBridgeKeyActor(req)) return; + await assertCanAssignTasks(req, companyId, assignmentScope); + } + async function decideIssueAccess( req: Request, issue: { @@ -2924,6 +2943,10 @@ export function issueRoutes( router.get("/companies/:companyId/issues", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); + if (isTaskBridgeKeyActor(req)) { + res.status(403).json({ error: "Task bridge keys cannot use company-wide issue list APIs" }); + return; + } const assigneeUserFilterRaw = req.query.assigneeUserId as string | undefined; const touchedByUserFilterRaw = req.query.touchedByUserId as string | undefined; const inboxArchivedByUserFilterRaw = req.query.inboxArchivedByUserId as string | undefined; @@ -3084,6 +3107,10 @@ export function issueRoutes( router.get("/companies/:companyId/issues/count", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); + if (isTaskBridgeKeyActor(req)) { + res.status(403).json({ error: "Task bridge keys cannot use company-wide issue count APIs" }); + return; + } const attention = req.query.attention as string | undefined; const hasPlanDocument = parseOptionalBooleanQuery(req.query.hasPlanDocument); if (attention !== "blocked") { @@ -5005,7 +5032,7 @@ export function issueRoutes( if (watchdogProductBugFollowUp === false) return; const effectiveParentId = watchdogProductBugFollowUp ? null : rawCreateBody.parentId; let createParent: Awaited> | null = null; - if (req.actor.type === "agent" && !effectiveParentId && !watchdogProductBugFollowUp) { + if (req.actor.type === "agent" && !effectiveParentId && !watchdogProductBugFollowUp && !isTaskBridgeKeyActor(req)) { const companyScopeDecision = await access.decide({ actor: req.actor, action: "company_scope:read", @@ -5022,7 +5049,7 @@ export function issueRoutes( res.status(404).json({ error: "Parent issue not found" }); return; } - if (!(await assertIssueReadAllowed(req, res, createParent))) return; + if (!isTaskBridgeKeyActor(req) && !(await assertIssueReadAllowed(req, res, createParent))) return; } if ( !watchdogProductBugFollowUp && @@ -5068,17 +5095,19 @@ export function issueRoutes( : {}), }; if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, { companyId }, createBody))) return; + const createAssignmentScope = { + projectId: await resolveAssignmentProjectId({ + companyId, + projectId: createBody.projectId, + parentIssueId: createBody.parentId, + }), + parentIssueId: createBody.parentId ?? null, + assigneeAgentId: createBody.assigneeAgentId ?? null, + assigneeUserId: rawCreateBody.assigneeUserId ?? null, + }; + await assertTaskBridgeCreateAllowed(req, companyId, createAssignmentScope); if (rawCreateBody.assigneeAgentId || rawCreateBody.assigneeUserId) { - await assertCanAssignTasks(req, companyId, { - projectId: await resolveAssignmentProjectId({ - companyId, - projectId: createBody.projectId, - parentIssueId: createBody.parentId, - }), - parentIssueId: createBody.parentId ?? null, - assigneeAgentId: createBody.assigneeAgentId ?? null, - assigneeUserId: rawCreateBody.assigneeUserId ?? null, - }); + await assertCanAssignTasks(req, companyId, createAssignmentScope); } await assertIssueEnvironmentSelection(companyId, createBody.executionWorkspaceSettings?.environmentId); @@ -5096,6 +5125,7 @@ export function issueRoutes( }, actor); const issue = await svc.create(companyId, { ...createBody, + ...(taskBridgeOriginForActor(req) ?? {}), id: issueId, executionPolicy, ...(sourceTrust ? { sourceTrust } : {}), @@ -5213,7 +5243,7 @@ export function issueRoutes( return; } assertCompanyAccess(req, parent.companyId); - if (!(await assertIssueReadAllowed(req, res, parent))) return; + if (!isTaskBridgeKeyActor(req) && !(await assertIssueReadAllowed(req, res, parent))) return; if (!(await assertTaskWatchdogCreateIssueAllowed(req, res, parent.companyId, parent))) return; if (await assertLowTrustControlPlaneDenied(req, res, parent.companyId, parent)) return; assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body)); @@ -5226,13 +5256,15 @@ export function issueRoutes( ...(normalizedAssigneeAgentId !== undefined ? { assigneeAgentId: normalizedAssigneeAgentId } : {}), }; if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, parent, createBody))) return; + const childAssignmentScope = { + projectId: createBody.projectId ?? parent.projectId ?? null, + parentIssueId: parent.id, + assigneeAgentId: createBody.assigneeAgentId ?? null, + assigneeUserId: createBody.assigneeUserId ?? null, + }; + await assertTaskBridgeCreateAllowed(req, parent.companyId, childAssignmentScope); if (req.body.assigneeAgentId || req.body.assigneeUserId) { - await assertCanAssignTasks(req, parent.companyId, { - projectId: createBody.projectId ?? parent.projectId ?? null, - parentIssueId: parent.id, - assigneeAgentId: createBody.assigneeAgentId ?? null, - assigneeUserId: createBody.assigneeUserId ?? null, - }); + await assertCanAssignTasks(req, parent.companyId, childAssignmentScope); } await assertIssueEnvironmentSelection(parent.companyId, createBody.executionWorkspaceSettings?.environmentId); @@ -5255,6 +5287,7 @@ export function issueRoutes( }, actor); const { issue, parentBlockerAdded } = await svc.createChild(parent.id, { ...createBody, + ...(taskBridgeOriginForActor(req) ?? {}), id: issueId, executionPolicy, ...(currentSerializedChild diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index d83bb790aa4..9be01405032 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -20,8 +20,10 @@ import { AGENT_DEFAULT_MAX_CONCURRENT_RUNS, getAgentWorkEligibility, isUuidLike, + normalizeAgentApiKeyScope, normalizeAgentUrlKey, type AgentEligibilityAgent, + type AgentApiKeyScope, } from "@paperclipai/shared"; import { conflict, notFound, unprocessable } from "../errors.js"; import { syncAgentAdapterEnvBindings } from "./agent-secret-bindings.js"; @@ -756,7 +758,7 @@ export function agentService(db: Db) { }); }, - createApiKey: async (id: string, name: string) => { + createApiKey: async (id: string, name: string, scope: AgentApiKeyScope = { kind: "standard" }) => { const existing = await getById(id); if (!existing) throw notFound("Agent not found"); if (existing.status === "pending_approval") { @@ -775,6 +777,7 @@ export function agentService(db: Db) { companyId: existing.companyId, name, keyHash, + scopeConfig: scope.kind === "standard" ? null : scope, }) .returning() .then((rows) => rows[0]); @@ -782,6 +785,7 @@ export function agentService(db: Db) { return { id: created.id, name: created.name, + scope: normalizeAgentApiKeyScope(created.scopeConfig), token, createdAt: created.createdAt, }; @@ -792,11 +796,19 @@ export function agentService(db: Db) { .select({ id: agentApiKeys.id, name: agentApiKeys.name, + scopeConfig: agentApiKeys.scopeConfig, createdAt: agentApiKeys.createdAt, revokedAt: agentApiKeys.revokedAt, }) .from(agentApiKeys) - .where(eq(agentApiKeys.agentId, id)), + .where(eq(agentApiKeys.agentId, id)) + .then((rows) => rows.map((row) => ({ + id: row.id, + name: row.name, + scope: normalizeAgentApiKeyScope(row.scopeConfig), + createdAt: row.createdAt, + revokedAt: row.revokedAt, + }))), getKeyById: async (keyId: string) => db @@ -805,12 +817,21 @@ export function agentService(db: Db) { agentId: agentApiKeys.agentId, companyId: agentApiKeys.companyId, name: agentApiKeys.name, + scopeConfig: agentApiKeys.scopeConfig, createdAt: agentApiKeys.createdAt, revokedAt: agentApiKeys.revokedAt, }) .from(agentApiKeys) .where(eq(agentApiKeys.id, keyId)) - .then((rows) => rows[0] ?? null), + .then((rows) => { + const row = rows[0] ?? null; + return row + ? { + ...row, + scope: normalizeAgentApiKeyScope(row.scopeConfig), + } + : null; + }), revokeKey: async (agentId: string, keyId: string) => { const rows = await db diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index a4c513009ce..d478310a3c7 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -10,7 +10,7 @@ import { principalPermissionGrants, projects, } from "@paperclipai/db"; -import type { PermissionKey, PrincipalType } from "@paperclipai/shared"; +import type { AgentApiKeyScope, PermissionKey, PrincipalType, TaskBridgeAgentKeyScope } from "@paperclipai/shared"; import { LOW_TRUST_REVIEW_PRESET, extractAgentMentionIds, type LowTrustBoundary } from "@paperclipai/shared"; import { LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH, @@ -29,6 +29,8 @@ export type AuthorizationActor = isInstanceAdmin?: boolean; agentId?: string | null; companyId?: string | null; + keyId?: string | null; + keyScope?: AgentApiKeyScope | null; runId?: string | null; source?: | "local_implicit" @@ -66,6 +68,8 @@ export type AuthorizationResource = parentIssueId?: string | null; assigneeAgentId?: string | null; assigneeUserId?: string | null; + originKind?: string | null; + originId?: string | null; status?: string | null; }; @@ -211,6 +215,8 @@ type IssueAuthorizationRow = { assigneeUserId: string | null; status: string; executionPolicy: unknown; + originKind: string | null; + originId: string | null; }; function evaluateAuthorizationPolicyForAssignment( @@ -550,6 +556,8 @@ export function authorizationService(db: Db) { assigneeUserId: issues.assigneeUserId, status: issues.status, executionPolicy: issues.executionPolicy, + originKind: issues.originKind, + originId: issues.originId, }) .from(issues) .where(eq(issues.id, issueId)) @@ -802,6 +810,130 @@ export function authorizationService(db: Db) { return null; } + function taskBridgeScopeIds( + scope: TaskBridgeAgentKeyScope, + singularKey: "projectId" | "parentIssueId", + pluralKey: "projectIds" | "parentIssueIds", + ) { + return [ + ...(typeof scope[singularKey] === "string" ? [scope[singularKey]] : []), + ...(Array.isArray(scope[pluralKey]) ? scope[pluralKey] : []), + ].filter((value): value is string => typeof value === "string" && value.length > 0); + } + + async function parentIssueMatchesTaskBridgeBoundary( + parentIssueId: string | null | undefined, + companyId: string, + allowedParentIssueIds: string[], + ) { + if (!parentIssueId || allowedParentIssueIds.length === 0) return false; + if (allowedParentIssueIds.includes(parentIssueId)) return true; + for (const rootIssueId of allowedParentIssueIds) { + if (await issueIdIsDescendantOf(parentIssueId, rootIssueId, companyId)) return true; + } + return false; + } + + async function issueMatchesTaskBridgeCreateBoundary( + scope: TaskBridgeAgentKeyScope, + resource: Extract, + ) { + const allowedProjectIds = taskBridgeScopeIds(scope, "projectId", "projectIds"); + const allowedParentIssueIds = taskBridgeScopeIds(scope, "parentIssueId", "parentIssueIds"); + if (resource.projectId && allowedProjectIds.includes(resource.projectId)) return true; + if (await parentIssueMatchesTaskBridgeBoundary(resource.parentIssueId, resource.companyId, allowedParentIssueIds)) { + return true; + } + if (resource.parentIssueId && allowedProjectIds.length > 0) { + const parent = await loadIssue(resource.parentIssueId); + if (parent?.companyId === resource.companyId && parent.projectId && allowedProjectIds.includes(parent.projectId)) { + return true; + } + } + return false; + } + + async function issueMatchesTaskBridgeWriteBoundary(input: { + actorAgentId: string; + keyId: string; + resource: Extract; + }) { + const issue = input.resource.issueId ? await loadIssue(input.resource.issueId) : null; + const assigneeAgentId = issue?.assigneeAgentId ?? input.resource.assigneeAgentId ?? null; + if (assigneeAgentId === input.actorAgentId) return true; + const originKind = issue?.originKind ?? input.resource.originKind ?? null; + const originId = issue?.originId ?? input.resource.originId ?? null; + return originKind === "task_bridge" && originId === input.keyId; + } + + async function decideTaskBridgeAccess(input: { + actorAgentId: string; + action: AuthorizationAction; + resource: AuthorizationResource; + scope: TaskBridgeAgentKeyScope; + keyId: string; + }): Promise { + const denyBridge = (explanation: string) => + deny({ + action: input.action, + reason: "deny_scope", + explanation, + }); + const allowBridge = (explanation: string) => + allow({ + action: input.action, + reason: "allow_explicit_grant", + explanation, + }); + + if ( + input.action === "company_scope:read" || + input.action === "agent:read" || + input.action === "agent:wake" || + input.action === "project:read" || + input.action === "runtime:manage" || + input.action === "secrets:read" + ) { + return denyBridge("Task bridge keys cannot use company-wide, peer-agent, project, runtime, or secret APIs."); + } + + if (input.action === "tasks:assign") { + if (input.resource.type !== "issue") { + return denyBridge("Task bridge assignment requires an issue resource."); + } + if (!(await issueMatchesTaskBridgeCreateBoundary(input.scope, input.resource))) { + return denyBridge("Task bridge key is outside its approved parent or project boundary."); + } + if (input.resource.assigneeUserId) { + return denyBridge("Task bridge keys cannot assign work to board users."); + } + const allowedAssigneeAgentIds = input.scope.allowedAssigneeAgentIds ?? []; + if ( + input.resource.assigneeAgentId && + input.resource.assigneeAgentId !== input.actorAgentId && + !allowedAssigneeAgentIds.includes(input.resource.assigneeAgentId) + ) { + return denyBridge("Task bridge key cannot assign work to that agent."); + } + return allowBridge("Allowed by task bridge create boundary."); + } + + if (input.action === "issue:read" || input.action === "issue:comment" || input.action === "issue:mutate") { + if (input.resource.type !== "issue") { + return denyBridge("Task bridge issue access requires an issue resource."); + } + return await issueMatchesTaskBridgeWriteBoundary({ + actorAgentId: input.actorAgentId, + keyId: input.keyId, + resource: input.resource, + }) + ? allowBridge("Allowed for bridge-created or assigned issue.") + : denyBridge("Task bridge key can only access assigned or bridge-created issues."); + } + + return null; + } + async function assignmentTargetIsInCompany(resource: AuthorizationResource) { if (resource.type !== "issue") return true; if (resource.assigneeAgentId) { @@ -1183,6 +1315,25 @@ export function authorizationService(db: Db) { }); } + if (input.actor.source === "agent_key" && input.actor.keyScope?.kind === "task_bridge") { + const keyId = input.actor.keyId ?? null; + if (!keyId) { + return deny({ + action: input.action, + reason: "deny_scope", + explanation: "Task bridge key context is missing.", + }); + } + const taskBridgeDecision = await decideTaskBridgeAccess({ + actorAgentId, + action: input.action, + resource: input.resource, + scope: input.actor.keyScope, + keyId, + }); + if (taskBridgeDecision) return taskBridgeDecision; + } + const lowTrustDecision = await decideLowTrustAccess({ actorAgentId, action: input.action, diff --git a/server/src/types/express.d.ts b/server/src/types/express.d.ts index 17f94e752e9..4d04d1cc480 100644 --- a/server/src/types/express.d.ts +++ b/server/src/types/express.d.ts @@ -1,5 +1,7 @@ export {}; +import type { AgentApiKeyScope } from "@paperclipai/shared"; + declare global { namespace Express { interface Request { @@ -18,6 +20,7 @@ declare global { }>; isInstanceAdmin?: boolean; keyId?: string; + keyScope?: AgentApiKeyScope; runId?: string; source?: "local_implicit" | "session" | "board_key" | "agent_key" | "agent_jwt" | "cloud_tenant" | "none"; }; diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index fc29bac98ec..45e134c3cd1 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -15,6 +15,7 @@ import type { Approval, AgentConfigRevision, ClearAgentErrorResponse, + AgentApiKeyScope, } from "@paperclipai/shared"; import type { AdapterModelProfileDefinition, @@ -26,6 +27,7 @@ import { ApiError, api } from "./client"; export interface AgentKey { id: string; name: string; + scope: AgentApiKeyScope; createdAt: Date; revokedAt: Date | null; } @@ -178,8 +180,8 @@ export const agentsApi = { api.get(agentPath(id, companyId, "/skills")), syncSkills: (id: string, desiredSkills: Array, companyId?: string) => api.post(agentPath(id, companyId, "/skills/sync"), { desiredSkills }), - createKey: (id: string, name: string, companyId?: string) => - api.post(agentPath(id, companyId, "/keys"), { name }), + createKey: (id: string, name: string, companyId?: string, scope?: AgentApiKeyScope) => + api.post(agentPath(id, companyId, "/keys"), { name, ...(scope ? { scope } : {}) }), revokeKey: (agentId: string, keyId: string, companyId?: string) => api.delete<{ ok: true }>(agentPath(agentId, companyId, `/keys/${encodeURIComponent(keyId)}`)), runtimeState: (id: string, companyId?: string) =>