mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
PAP-11957 scope Hermes task bridge credentials
This commit is contained in:
1 parent
f0e2bffe42
commit
73e5bd8f4e
22 files changed
+493
-78
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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.
|
||||
`;
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
+3
@@ -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";
|
||||
};
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in new issue
Block a user