From 1bb4dcbe4b24bb8160db912b43dbf062859ebc5c Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 11 Nov 2026 22:05:16 -0600 Subject: [PATCH] fix(routines): agents can only create authenticated webhook triggers (#21006) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Routines can have webhook triggers that outside services call > - Agents can create and change routine triggers through the API > - The choice of webhook authentication belongs to a person > - This pull request lets agents create authenticated webhook triggers only, and keeps every choice open to people on the board > - The benefit is that agents can only create authenticated webhook triggers ## Linked Issues or Issue Description **What happened** In a chat eval, an agent set up a routine webhook trigger with a weaker authentication choice than the default. **Expected behavior** Agents can only create authenticated webhook triggers. When a sender cannot authenticate, the agent asks a person, who decides on the board. **Steps to reproduce** 1. Ask an agent in chat to give a routine a webhook for a test service. 2. Read the trigger that it creates. ## What Changed - `server/src/services/routines.ts`: trigger creation, trigger updates and revision restores refuse an agent that does not keep the webhook trigger authenticated. The error tells the agent to use the default or a signed mode, or to ask a person. Board users keep every option. An agent can still edit other fields of a trigger that a person set up. - `skills/paperclip/references/routines.md`: says agents can only create authenticated webhook triggers. - Test: new case in `routines-service.test.ts`. ## Verification - `cd server && npx vitest run src/__tests__/routines-service.test.ts`: 104 passed. The new test fails without the service change (red) and passes with it (green). - `npx vitest run src/__tests__/routines-routes.test.ts src/__tests__/company-portability.test.ts`: passed. - The chat eval `routines.webhook-trigger` already requires one authenticated trigger. It runs again on a combined throwaway branch; results are in a PR comment. ## Risks - An agent-run company import that would add such a trigger now fails with the same error and rolls back. A person can run that import from the board. - Agents must use the default or a signed mode. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), tool use and code execution in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- server/src/__tests__/routines-service.test.ts | 20 ++++++++++++ server/src/services/routines.ts | 32 ++++++++++++++++--- skills/paperclip/references/routines.md | 2 +- 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index d70f3802a6c..a8f50daa868 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -251,6 +251,26 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { expect(f.wakeups).toEqual([]); }); + it("lets only a person choose an unauthenticated public webhook trigger", async () => { + const f = await seedFixture(); + const webhook = { kind: "webhook", enabled: true, replayWindowSec: 300 } as const; + const agent = { agentId: f.agentId }; + await expect(f.svc.createTrigger(f.routine.id, { ...webhook, signingMode: "none" }, agent)) + .rejects.toMatchObject({ status: 403, message: expect.stringContaining("Agents can only create authenticated webhook triggers") }); + const signed = await f.svc.createTrigger(f.routine.id, { ...webhook, signingMode: "bearer" }, agent); + expect(signed.trigger.signingMode).toBe("bearer"); + await expect(f.svc.updateTrigger(signed.trigger.id, { signingMode: "none" }, agent)).rejects.toMatchObject({ status: 403 }); + const open = await f.svc.createTrigger(f.routine.id, { ...webhook, signingMode: "none" }, { userId: "board-user" }); + expect(open.trigger.signingMode).toBe("none"); + await expect(f.svc.updateTrigger(open.trigger.id, { label: "Inbound", signingMode: "none" }, agent)) + .resolves.toMatchObject({ trigger: { label: "Inbound", signingMode: "none" } }); + await f.svc.updateTrigger(signed.trigger.id, { signingMode: "none" }, { userId: "board-user" }); + const unsafeRevision = (await f.svc.listRevisions(f.routine.id))[0]!.id; + await f.svc.updateTrigger(signed.trigger.id, { signingMode: "bearer" }, { userId: "board-user" }); + await expect(f.svc.restoreRevision(f.routine.id, unsafeRevision, agent)).rejects.toMatchObject({ status: 403 }); + expect((await f.svc.getTrigger(signed.trigger.id))!.signingMode).toBe("bearer"); + }); + it("preserves creation ancestry through regular webhooks, child tasks, and standalone follow-ups", async () => { vi.stubEnv("PAPERCLIP_AGENT_JWT_SECRET", randomUUID().repeat(2)); const f = await seedFixture(); diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index 39c6b24dac6..e12ed9432c8 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -163,6 +163,13 @@ type Actor = { actorResponsibleUserId?: string | null; runId?: string | null; }; + +export const AGENT_UNAUTHENTICATED_WEBHOOK_ERROR = + "Agents can only create authenticated webhook triggers. Use signingMode \"bearer\" (the default) or a signed mode such as \"hmac_sha256\". If the sender cannot authenticate its requests, ask a person to choose an unauthenticated webhook on the board."; + +function assertAgentWebhookAuthenticated(actor: Actor, signingMode: string | null | undefined) { + if (actor.agentId && signingMode === "none") throw forbidden(AGENT_UNAUTHENTICATED_WEBHOOK_ERROR); +} type RoutineRow = typeof routines.$inferSelect; type RoutineTriggerRow = typeof routineTriggers.$inferSelect; @@ -2537,6 +2544,7 @@ export function routineService( } if (input.kind === "webhook") { + assertAgentWebhookAuthenticated(actor, input.signingMode); publicId = crypto.randomBytes(12).toString("hex"); const created = await createWebhookSecret(routine.companyId, routine.id, actor); secretId = created.secret.id; @@ -2623,8 +2631,17 @@ export function routineService( const result = await db.transaction(async (tx) => { const txDb = tx as unknown as Db; - await notifyDeliveryWork(txDb, DELIVERY_QUEUES.routineSchedules); await tx.execute(sql`select id from ${routines} where ${routines.id} = ${existing.routineId} for update`); + const current = await txDb + .select({ signingMode: routineTriggers.signingMode }) + .from(routineTriggers) + .where(eq(routineTriggers.id, id)) + .then((rows) => rows[0] ?? null); + if (!current) return null; + if (existing.kind === "webhook" && current.signingMode !== "none") { + assertAgentWebhookAuthenticated(actor, patch.signingMode); + } + await notifyDeliveryWork(txDb, DELIVERY_QUEUES.routineSchedules); const [updated] = await txDb .update(routineTriggers) .set({ @@ -2635,7 +2652,7 @@ export function routineService( cronExpression, timezone, nextRunAt, - signingMode: patch.signingMode === undefined ? existing.signingMode : patch.signingMode, + signingMode: patch.signingMode === undefined ? current.signingMode : patch.signingMode, replayWindowSec: patch.replayWindowSec === undefined ? existing.replayWindowSec : patch.replayWindowSec, updatedByAgentId: actor.agentId ?? null, updatedByUserId: actor.userId ?? null, @@ -2779,10 +2796,8 @@ export function routineService( const snapshot = routineRevisionSnapshotSchema.parse(targetRevision.snapshot) as RoutineRevisionSnapshotV1; const routineSnapshot = snapshot.routine; await assertRestorableAssignee(existingRoutine.companyId, routineSnapshot.assigneeAgentId, actor); - const result = await db.transaction(async (tx) => { const txDb = tx as unknown as Db; - await notifyDeliveryWork(txDb, DELIVERY_QUEUES.routineSchedules); await tx.execute(sql`select id from ${routines} where ${routines.id} = ${existingRoutine.id} for update`); const locked = await txDb .select() @@ -2797,9 +2812,16 @@ export function routineService( } const currentTriggers = await txDb - .select({ id: routineTriggers.id }) + .select({ id: routineTriggers.id, signingMode: routineTriggers.signingMode }) .from(routineTriggers) .where(and(eq(routineTriggers.companyId, locked.companyId), eq(routineTriggers.routineId, locked.id))); + const unauthenticatedTriggerIds = new Set(currentTriggers.filter((trigger) => trigger.signingMode === "none").map((trigger) => trigger.id)); + for (const trigger of snapshot.triggers) { + if (trigger.kind === "webhook" && !unauthenticatedTriggerIds.has(trigger.id)) { + assertAgentWebhookAuthenticated(actor, trigger.signingMode); + } + } + await notifyDeliveryWork(txDb, DELIVERY_QUEUES.routineSchedules); const currentTriggerIds = new Set(currentTriggers.map((trigger) => trigger.id)); const missingWebhookTriggers = snapshot.triggers .filter((trigger) => trigger.kind === "webhook" && !currentTriggerIds.has(trigger.id)); diff --git a/skills/paperclip/references/routines.md b/skills/paperclip/references/routines.md index 3378cffb69e..cf48e3d4402 100644 --- a/skills/paperclip/references/routines.md +++ b/skills/paperclip/references/routines.md @@ -157,7 +157,7 @@ POST /api/routines/{routineId}/triggers } ``` -- `signingMode`: `bearer` (default) or `hmac_sha256` +- `signingMode`: `bearer` (default) or `hmac_sha256`. Agents can only create authenticated webhook triggers; if a sender cannot authenticate, ask a person to set that trigger up on the board. - `replayWindowSec`: 30-86400 (default 300) - Response includes the webhook URL (`publicId`-based) and the signing secret - Fire externally: `POST /api/routine-triggers/public/{publicId}/fire`