PAP-11957 scope Hermes task bridge credentials

This commit is contained in:
Dotta committed 2026-06-26 13:54:14 +00:00
1 parent f0e2bffe42
commit 73e5bd8f4e
22 files changed
+493 -78

No files matched your search

+6 -2
View File
@@ -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=<scoped-agent-api-key>
PAPERCLIP_BRIDGE_API_KEY=<task-bridge-scoped-agent-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 "<approved-parent-issue-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:
@@ -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."
```
@@ -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,
+6 -1
View File
@@ -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
@@ -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());
@@ -0,0 +1 @@
ALTER TABLE "agent_api_keys" ADD COLUMN IF NOT EXISTS "scope_config" jsonb;
@@ -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
}
]
}
+3 -1
View File
@@ -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<AgentApiKeyScope | null>(),
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
revokedAt: timestamp("revoked_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
+6
View File
@@ -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,
+2
View File
@@ -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<string, unknown> {
canCreateAgents: boolean;
@@ -116,6 +117,7 @@ export type ClearAgentErrorResponse = Agent;
export interface AgentKeyCreated {
id: string;
name: string;
scope: AgentApiKeyScope;
token: string;
createdAt: Date;
}
+37
View File
@@ -114,8 +114,45 @@ export const updateAgentInstructionsPathSchema = z.object({
export type UpdateAgentInstructionsPath = z.infer<typeof updateAgentInstructionsPathSchema>;
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<typeof agentApiKeyScopeSchema>;
export type TaskBridgeAgentKeyScope = z.infer<typeof taskBridgeAgentKeyScopeSchema>;
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<typeof createAgentKeySchema>;
+6
View File
@@ -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,
@@ -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",
});
});
});
@@ -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") {
+3 -2
View File
@@ -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 <same-value-as-API_SERVER_KEY> in docs and tests.
`;
+2 -1
View File
@@ -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",
};
+14 -2
View File
@@ -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);
+52 -19
View File
@@ -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<ReturnType<typeof svc.getById>> | 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
+24 -3
View File
@@ -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
+152 -1
View File
@@ -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<AuthorizationResource, { type: "issue" }>,
) {
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<AuthorizationResource, { type: "issue" }>;
}) {
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<AuthorizationDecision | null> {
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,
+3
View File
@@ -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";
};
+4 -2
View File
@@ -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<AgentSkillSnapshot>(agentPath(id, companyId, "/skills")),
syncSkills: (id: string, desiredSkills: Array<string | AgentDesiredSkillEntry>, companyId?: string) =>
api.post<AgentSkillSnapshot>(agentPath(id, companyId, "/skills/sync"), { desiredSkills }),
createKey: (id: string, name: string, companyId?: string) =>
api.post<AgentKeyCreated>(agentPath(id, companyId, "/keys"), { name }),
createKey: (id: string, name: string, companyId?: string, scope?: AgentApiKeyScope) =>
api.post<AgentKeyCreated>(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) =>