From 05d1decd2b6575a0856baf247d42836c84cd65bc Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 26 Oct 2026 18:11:22 -0500 Subject: [PATCH 1/2] fix: hand off delegated chat verification key changes Block agent-chosen verification credentials before provider work or vault writes. Return the saved human handoff, prevent channel secret-reference rebinding through generic tool updates, and protect native setup receipts. Keep authorized bot-token repair available. Co-Authored-By: Paperclip --- .../connection-management.integration.test.ts | 27 ++++++++++++++++++- server/src/__tests__/openapi-routes.test.ts | 3 +++ server/src/routes/chat-channels.ts | 12 ++++++--- server/src/routes/openapi.ts | 4 +-- .../services/chat-channels/configuration.ts | 10 +++++++ .../runner-api-sensitive-response.ts | 1 + .../native-runtime/runner-api.test.ts | 1 + server/src/services/tool-access.ts | 6 +++++ 8 files changed, 58 insertions(+), 6 deletions(-) diff --git a/server/src/__tests__/connection-management.integration.test.ts b/server/src/__tests__/connection-management.integration.test.ts index 51ffd178cc9..d7dc4677df7 100644 --- a/server/src/__tests__/connection-management.integration.test.ts +++ b/server/src/__tests__/connection-management.integration.test.ts @@ -18,6 +18,7 @@ import { getServerAdapter, registerServerAdapter, unregisterServerAdapter } from import { configureAgentLifecycle } from "../services/agent-lifecycle.js"; import { agentService } from "../services/agents.js"; import { accessService } from "../services/access.js"; +import { secretService } from "../services/secrets.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js"; @@ -652,9 +653,33 @@ describe("connection management with production default agents", () => { }; try { const created = await f.request("POST", `/companies/${f.companyId}/chat-endpoints`, { provider: "slack", assignedAgentId: f.agentId, slackSetupMethod: "manual" }); - const configured = await f.request("POST", `/chat-endpoints/${created.body.id}/setup`, { action: "configure", credentials: { botToken: "xoxb-fixture-token", signingSecret: "fixture-signing-secret" } }); + const setupPath = `/chat-endpoints/${created.body.id}/setup`; + const cookie = await server.humanSession(f.userId); + const configuredResponse = await fetch(server.apiUrl + "/api" + setupPath, { method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" }, + body: JSON.stringify({ action: "configure", credentials: { botToken: "xoxb-fixture-token", signingSecret: "fixture-signing-secret" } }) }); + const configured = { status: configuredResponse.status, body: await configuredResponse.json() }; expect(configured, JSON.stringify(configured)).toMatchObject({ status: 200 }); expect(JSON.stringify(configured.body)).not.toContain("xoxb-fixture-token"); + const [before] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId)); + const botTokenRef = before.credentialSecretRefs.find(ref => ref.configPath === "credentials.botToken")!; + const rebound = before.credentialSecretRefs.map(ref => ref.configPath === "credentials.signingSecret" ? { ...ref, secretId: botTokenRef.secretId } : ref); + expect(await f.request("PATCH", `/tool-connections/${created.body.connectionId}`, { credentialSecretRefs: rebound }, f.jwt)) + .toMatchObject({ status: 403, body: { code: "chat_endpoint_credentials_require_setup" } }); + for (const action of ["reconnect", "configure"] as const) for (const token of [f.key.token, f.jwt]) { + const chosen = "agent-chosen-webhook-key"; + const handoff = await f.request("POST", setupPath, { action, credentials: { signingSecret: chosen } }, token); + expect(handoff, JSON.stringify(handoff)).toMatchObject({ status: 200, body: { status: "needs_user_action", endpointId: created.body.id, + requiredUserAction: { kind: "enter_credentials", userId: f.userId, resource: { id: created.body.id } } } }); + expect(JSON.stringify(handoff.body)).not.toContain(chosen); + const [after] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId)); + expect(after.credentialSecretRefs).toEqual(before.credentialSecretRefs); + const signing = after.credentialSecretRefs.find(ref => ref.configPath === "credentials.signingSecret")!; + expect(await secretService(server.db).resolveSecretValue(f.companyId, signing.secretId, "latest", { + accessContext: { consumerType: "system", consumerId: "integration-fixture", actorType: "system" }, + })).toBe("fixture-signing-secret"); + } + // Ordinary write-only bot-token repair remains delegated under current authority. + expect(await f.request("POST", setupPath, { action: "reconnect", credentials: { botToken: "xoxb-fixture-token" } }, f.jwt)).toMatchObject({ status: 200, body: { id: created.body.id } }); // Identity is a provider fixture; production HTTP admission and policy remain real. const [principal] = await server.db.insert(chatExternalPrincipals).values({ companyId: f.companyId, provider: "slack", providerAccountId: "TFIXTURE", externalId: "UHUMAN", isBot: false }).returning(); await server.db.insert(chatIdentityLinks).values({ companyId: f.companyId, endpointId: created.body.id, principalId: principal.id, paperclipUserId: f.userId, status: "linked", confirmedAt: new Date() }); diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 0cda5bd1579..48da426bab0 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -754,6 +754,9 @@ describe("openapi routes", () => { ).toEqual(["current", "stale", "unverified"]); const setup = spec.paths["/api/chat-endpoints/{endpointId}/setup"].post; + const setupResponse = setup.responses["200"].content["application/json"].schema; + expect((setupResponse.oneOf ?? setupResponse.anyOf)[1].properties.requiredUserAction.properties.kind.enum).toEqual(["enter_credentials"]); + expect(setup.description).toContain("without changing credentials"); expect( setup.requestBody.content["application/json"].schema.properties.action .enum, diff --git a/server/src/routes/chat-channels.ts b/server/src/routes/chat-channels.ts index 4e7f19f6ee0..1dfea15656d 100644 --- a/server/src/routes/chat-channels.ts +++ b/server/src/routes/chat-channels.ts @@ -433,9 +433,15 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) { validate(configureChatEndpointSchema), async (req, res) => { if (!(await assertEndpointManagementAccess(req, res))) return; - res.json( - await service.configure(endpointId(req), req.body, actorUserId(req), connectionRequestContext(req)), - ); + res.set("Cache-Control", "no-store"); + try { + res.json(await service.configure(endpointId(req), req.body, actorUserId(req), connectionRequestContext(req))); + } catch (error) { + if (req.actor.type !== "agent" || !(error instanceof HttpError) || error.status !== 403 + || !error.details || typeof error.details !== "object" || !("code" in error.details) + || error.details.code !== "chat_endpoint_credential_entry_requires_human") throw error; + res.json(await setupHandoff(req, "The responsible user must enter provider verification credentials in the saved setup.", "enter_credentials")); + } }, ); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index dab6135db41..d4903dcf40d 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -2497,13 +2497,13 @@ registry.registerPath({ tags: ["chat-channels"], summary: "Configure or change chat endpoint lifecycle state", description: - "Runs a setup or lifecycle action. `configure` and `reconnect` accept provider credentials (Slack: `botToken`, `signingSecret`; GitHub: `appId`, `privateKey` after Paperclip generates the webhook secret; Discord: `applicationId`, `guildId`, `botToken`; Microsoft Teams: `clientId`, `tenantId`, `clientSecret`; Telegram: `botToken`; iMessage Photon: `projectSecret`, with nonsecret `photon.projectId` and `photon.lineId` configuration). Credentials are stored as Paperclip secret references and are never returned. Other actions do not require credentials.", + "Runs a setup or lifecycle action. `configure` and `reconnect` accept provider credentials (Slack: `botToken`, `signingSecret`; GitHub: `appId`, `privateKey` after Paperclip generates the webhook secret; Discord: `applicationId`, `guildId`, `botToken`; Microsoft Teams: `clientId`, `tenantId`, `clientSecret`; Telegram: `botToken`; iMessage Photon: `projectSecret`, with nonsecret `photon.projectId` and `photon.lineId` configuration). Agent-supplied signing secrets, webhook secrets, private keys, client secrets, and project secrets return a saved human credential-entry handoff without changing credentials. Authorized write-only bot tokens and API keys remain supported. Credentials are stored as Paperclip secret references and are never returned. Other actions do not require credentials.", request: { params: z.object({ endpointId: z.string().uuid() }), body: jsonBody(configureChatEndpointSchema), }, responses: { - 200: r.ok(chatEndpointResponseSchema), + 200: r.ok(z.union([chatEndpointResponseSchema, chatEndpointCredentialHandoffSchema])), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, diff --git a/server/src/services/chat-channels/configuration.ts b/server/src/services/chat-channels/configuration.ts index eda63d37f31..5fc9cb5565a 100644 --- a/server/src/services/chat-channels/configuration.ts +++ b/server/src/services/chat-channels/configuration.ts @@ -62,6 +62,7 @@ import { import { badRequest, conflict, + forbidden, notFound, unprocessable, } from "../../errors.js"; @@ -144,6 +145,15 @@ export function createChatConfiguration(db: Db, options: ChatConfigurationOption }, ); } + // These keys establish provider authentication or inbound trust. They + // must come from the human setup step or a verified provider handoff. + if (managementContext?.actor.type === "agent" && suppliedCredentialKeys.some( + key => ["signingSecret", "webhookSecret", "privateKey", "clientSecret", "projectSecret"].includes(key), + )) { + throw forbidden("The responsible user must enter provider verification credentials in the saved setup", { + code: "chat_endpoint_credential_entry_requires_human", + }); + } } return withCredentialMutationLease(record.endpoint, (credentialLease) => configureWithCredentialLease( diff --git a/server/src/services/native-runtime/runner-api-sensitive-response.ts b/server/src/services/native-runtime/runner-api-sensitive-response.ts index c135df2c852..d4eb121076f 100644 --- a/server/src/services/native-runtime/runner-api-sensitive-response.ts +++ b/server/src/services/native-runtime/runner-api-sensitive-response.ts @@ -1,4 +1,5 @@ export function runnerApiSensitiveResponse(method: string, path: string): boolean { + if (method === "POST" && /^\/api\/chat-endpoints\/\{[^}]+\}\/setup$/.test(path)) return true; if (/(\/apps\/connect$|\/email\/connections$|setup-secret|\/github\/(setup|identity\/start)|\/slack\/resume|start-authorization|finalize-oauth-access|\/oauth\/|\/registration|\/managed\/authorize|\/install$|\/login|\/setup-token-login-sessions|\/local\/attempts)/.test(path)) return true; if (/\/secrets\/\{[^}]+\}\/value$/.test(path)) return true; return /^\/api\/tool-gateway\/sessions(?:\/|$)/.test(path) diff --git a/server/src/services/native-runtime/runner-api.test.ts b/server/src/services/native-runtime/runner-api.test.ts index b32db58ef49..634ee737146 100644 --- a/server/src/services/native-runtime/runner-api.test.ts +++ b/server/src/services/native-runtime/runner-api.test.ts @@ -60,6 +60,7 @@ describe("bounded response capture receipts", () => { describe("connection setup response protection", () => { it.each([ + ["POST", "/api/chat-endpoints/{endpointId}/setup"], ["POST", "/api/chat-endpoints/{endpointId}/setup-secret"], ["POST", "/api/chat-endpoints/{endpointId}/github/setup"], ["POST", "/api/chat-endpoints/{endpointId}/slack/resume"], diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index cead6192f79..6cb2220af72 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -4592,6 +4592,12 @@ export function toolAccessService( managementContext?: ConnectionManagementContext, ): Promise => { const existing = await getConnectionRow(connectionId); + if (managementContext?.actor.type === "agent" && existing.connectionPurpose === "channel" + && (input.credentialRefs !== undefined || input.credentialSecretRefs !== undefined)) { + throw forbidden("Use chat endpoint setup to change channel credentials; verification keys require human entry", { + code: "chat_endpoint_credentials_require_setup", + }); + } if (existing.connectionPurpose === "ai" && (input.config || input.transportConfig || input.credentialRefs || input.credentialSecretRefs || (input.credentialPolicy && input.credentialPolicy !== existing.credentialPolicy))) throw badRequest("Use AI account reconnect to change credentials. Provider, sign-in method, and ownership cannot be changed."); const config = normalizeGoogleSheetsConnectionConfig( input.config ?? input.transportConfig ?? existing.config, From c8cc70ae30fa73aa2a9e94fc518e2b08cd2ee50b Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 26 Oct 2026 18:11:22 -0500 Subject: [PATCH 2/2] test: assert persisted rate admission bucket identity Update the two Linux CI assertions for the bucket identity used by paired caller and target checks. Preserve the catalog creator attribution follow-up in the companion PR so both reviews remain below the file limit. Co-Authored-By: Paperclip --- .../__tests__/tool-access-policy-service.test.ts | 3 ++- .../services/tool-access/catalog/default-access.ts | 13 +++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/server/src/__tests__/tool-access-policy-service.test.ts b/server/src/__tests__/tool-access-policy-service.test.ts index 525f05db57d..45ffe0cd8f2 100644 --- a/server/src/__tests__/tool-access-policy-service.test.ts +++ b/server/src/__tests__/tool-access-policy-service.test.ts @@ -1105,7 +1105,8 @@ describeEmbeddedPostgres("tool access policy service", () => { .where(eq(toolInvocations.id, invocation.id)); expect(await svc.decide(input)).toMatchObject({ decision: "rate_limited" }); expect(invocation.policyExplanation?.consumedRateAdmissions).toEqual([ - { policyId: ratePolicy.id, versionHash: expect.stringMatching(/^[a-f0-9]{64}$/) }, + { policyId: ratePolicy.id, versionHash: expect.stringMatching(/^[a-f0-9]{64}$/), + bucketKey: `${ratePolicy.id}:agent:${input.actor.agentId}|tool:send_email` }, ]); const counters = await db.select().from(toolRateLimitCounters); expect(await svc.decide(input, { admittedInvocationId: invocation.id })).toMatchObject({ allowed: true }); diff --git a/server/src/services/tool-access/catalog/default-access.ts b/server/src/services/tool-access/catalog/default-access.ts index 408a33d9b14..c9a459dfafc 100644 --- a/server/src/services/tool-access/catalog/default-access.ts +++ b/server/src/services/tool-access/catalog/default-access.ts @@ -1,4 +1,3 @@ -import { connectionActorUserId } from "../actor.js"; import { lockManagedAppPolicies, lockToolAssignmentAgents } from "../assignment-locks.js"; import type { ActorInfo, ToolAccessMutationDb } from "../types.js"; import { and, eq, inArray } from "drizzle-orm"; @@ -112,7 +111,9 @@ export function createCatalogDefaultAccess(db: Db) { ? (input.actor.actorId ?? null) : null, createdByUserId: - connectionActorUserId(input.actor), + input.actor?.actorType === "user" + ? (input.actor.actorId ?? null) + : null, }); } else if (resetToRecommendedDefaults) { // Removing an app may retain its profile row when another record still @@ -162,7 +163,9 @@ export function createCatalogDefaultAccess(db: Db) { ? (input.actor.actorId ?? null) : null, createdByUserId: - connectionActorUserId(input.actor), + input.actor?.actorType === "user" + ? (input.actor.actorId ?? null) + : null, }); } @@ -296,7 +299,9 @@ export function createCatalogDefaultAccess(db: Db) { ? (input.actor.actorId ?? null) : null, createdByUserId: - connectionActorUserId(input.actor), + input.actor?.actorType === "user" + ? (input.actor.actorId ?? null) + : null, }) .returning(); results.push(toPolicy(created));