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

@@ -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";
};