From 096d2b0039f0209ca16c19755fec2fbe12665b58 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 26 Oct 2026 13:37:44 -0500 Subject: [PATCH] Keep GitHub webhook secret rotation in human setup Return a saved setup handoff for delegated agents and reject plaintext signing-secret generation at the service boundary. Verify key/JWT handoffs preserve credential refs and human sessions can still rotate. Co-Authored-By: Paperclip --- .../connection-management.integration.test.ts | 13 +++++++++++++ server/src/routes/chat-channels.ts | 4 ++++ .../services/chat-channels/github/setup-secret.ts | 4 ++++ 3 files changed, 21 insertions(+) diff --git a/server/src/__tests__/connection-management.integration.test.ts b/server/src/__tests__/connection-management.integration.test.ts index 7e6056e62a0..cd08b7b7daa 100644 --- a/server/src/__tests__/connection-management.integration.test.ts +++ b/server/src/__tests__/connection-management.integration.test.ts @@ -647,6 +647,14 @@ describe("connection management with production default agents", () => { const imported = await f.request("POST", `/chat-endpoints/${created.body.id}/github/app`, { appId: String(appId), privateKey, webhookSecret: "fixture-webhook-secret", clientId: "fixture-client", clientSecret: "fixture-secret" }); expect(imported, JSON.stringify(imported)).toMatchObject({ status: 200 }); expect(JSON.stringify(imported.body)).not.toContain("PRIVATE KEY"); + const [beforeRotation] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId)); + for (const token of [f.key.token, f.jwt]) { + const rotation = await f.request("POST", `/chat-endpoints/${created.body.id}/setup-secret`, {}, token); + expect(rotation, JSON.stringify(rotation)).toMatchObject({ status: 200, body: { status: "needs_user_action", endpointId: created.body.id } }); + expect(rotation.body).not.toHaveProperty("webhookSecret"); + } + const [afterRotation] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId)); + expect(afterRotation.credentialSecretRefs).toEqual(beforeRotation.credentialSecretRefs); const refreshed = await f.request("POST", `/chat-endpoints/${created.body.id}/github/repositories/refresh`, {}); expect(refreshed, JSON.stringify(refreshed)).toMatchObject({ status: 200 }); expect(JSON.stringify(refreshed.body)).toContain("fixture/repo"); @@ -668,6 +676,11 @@ describe("connection management with production default agents", () => { expect((await humanRequest(callback)).status).toBe(403); const confirmed = await humanRequest(`/api/chat-endpoints/${created.body.id}/github/identity/confirm`, "POST", { githubUserId: "1357" }); expect(confirmed.status, JSON.stringify(await confirmed.json())).toBe(200); + const humanRotation = await humanRequest(`/api/chat-endpoints/${created.body.id}/setup-secret`, "POST", {}); + expect(humanRotation.status).toBe(201); + expect(await humanRotation.json()).toEqual({ webhookSecret: expect.stringMatching(/^[a-f0-9]{64}$/) }); + const [humanRotated] = await server.db.select().from(toolConnections).where(eq(toolConnections.id, created.body.connectionId)); + expect(humanRotated.credentialSecretRefs).not.toEqual(beforeRotation.credentialSecretRefs); } finally { provider = null; } }); diff --git a/server/src/routes/chat-channels.ts b/server/src/routes/chat-channels.ts index 29e4e8815dd..2eec96346c0 100644 --- a/server/src/routes/chat-channels.ts +++ b/server/src/routes/chat-channels.ts @@ -429,6 +429,10 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) { router.post("/chat-endpoints/:endpointId/setup-secret", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/setup-secret"), async (req, res) => { if (!(await assertEndpointManagementAccess(req, res))) return; res.set("Cache-Control", "no-store"); + if (req.actor.type === "agent") { + res.json(await setupHandoff(req, "The responsible user must rotate and enter the webhook signing secret in GitHub.")); + return; + } res .status(201) .json( diff --git a/server/src/services/chat-channels/github/setup-secret.ts b/server/src/services/chat-channels/github/setup-secret.ts index 4d96d51f99b..26221b0bbe9 100644 --- a/server/src/services/chat-channels/github/setup-secret.ts +++ b/server/src/services/chat-channels/github/setup-secret.ts @@ -26,6 +26,7 @@ import type { import { conflict, + forbidden, notFound, unprocessable, } from "../../../errors.js"; @@ -61,6 +62,9 @@ export function createGitHubSetupSecret(db: Db, options: GitHubSetupSecretOption managementContext?: ConnectionManagementContext, ) { await managementContext?.reauthorize(); + if (managementContext?.actor.type === "agent") { + throw forbidden("Webhook signing secrets require a human setup handoff"); + } const initial = await endpointRecord(endpointId); if (!initial) throw notFound("Chat endpoint not found"); if (initial.endpoint.provider !== "github") {