From 1114ebaf4277486caa1da0f7ecbc326b9a8a26f7 Mon Sep 17 00:00:00 2001 From: Waseem Ilyas <1478353+Waseemilyas@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:05:53 +0000 Subject: [PATCH] fix(server): keep the JWT run-mismatch audit when the claimed run is missing 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 > - Rejected agent JWTs whose run header disagrees with the signed claim are audited into activity_log so operators can see spoof attempts > - The audit insert writes the claimed run id into run_id, which is a real foreign key to heartbeat_runs; a bogus claim violates the FK and the catch swallows it, so the audit row is silently lost exactly when it matters most > - This pull request verifies the claimed run exists before trusting it into the FK column; the claim itself stays in entityId and details > - The benefit is a reliable audit trail for rejected credentials ## Linked Issues or Issue Description Refs #10498 — the anonymous fall-through half was fixed on master already (every failed-credential branch now returns an explicit 401/403/422); this closes the residual FK-swallowed audit defect. ## What Changed - server/src/middleware/auth.ts: auditAgentJwtRunHeaderMismatch looks up the claimed run id and populates runId only when the row exists; entityId and details.claimRunId carry the claim either way. - server/src/__tests__/agent-auth-middleware.test.ts: a mismatch whose claimed run does not exist still produces the audit row with runId omitted. ## Verification - pnpm --filter @paperclipai/server vitest run src/__tests__/agent-auth-middleware.test.ts — 17 tests pass. ## Risks - Low risk. One extra indexed select on a rare rejection path; the audit payload is unchanged apart from runId now being conditional. ## Model Used - Anthropic Claude — SWE-2 Max agent via Devin CLI, tool use and code execution. Co-authored-by: Paperclip --- .../__tests__/agent-auth-middleware.test.ts | 32 +++++++++++++++++++ server/src/middleware/auth.ts | 14 +++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/server/src/__tests__/agent-auth-middleware.test.ts b/server/src/__tests__/agent-auth-middleware.test.ts index 23cb8e06fa..9adc3003fb 100644 --- a/server/src/__tests__/agent-auth-middleware.test.ts +++ b/server/src/__tests__/agent-auth-middleware.test.ts @@ -372,6 +372,38 @@ describe("agent auth middleware", () => { }); }); + it("audits a run header mismatch even when the claimed run does not exist", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const claimedRunId = randomUUID(); + const spoofedRunId = randomUUID(); + const { db, activity } = createDbState({ + agent: { id: agentId, companyId }, + }); + const token = createLocalAgentJwt(agentId, companyId, "codex_local", claimedRunId, "user-claim"); + + const res = await request(createApp(db)) + .get("/actor") + .set("Authorization", `Bearer ${token}`) + .set("X-Paperclip-Run-Id", spoofedRunId); + + expect(res.status).toBe(422); + expect(res.body.code).toBe("agent_jwt_run_id_mismatch"); + expect(activity).toHaveLength(1); + expect(activity[0]).toMatchObject({ + companyId, + actorType: "agent", + actorId: agentId, + action: "auth.agent_jwt_run_header_mismatch", + entityType: "heartbeat_run", + entityId: claimedRunId, + details: { claimRunId: claimedRunId, headerRunId: spoofedRunId }, + }); + // run_id carries a real FK to heartbeat_runs; a nonexistent claim must not + // be written into it or the whole audit row is lost to the FK violation. + expect(activity[0].runId).toBeUndefined(); + }); + it("falls back to the run row responsible user for legacy claim-less agent JWTs", async () => { const companyId = randomUUID(); const agentId = randomUUID(); diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index 8de3f1c171..f636d57fbc 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -157,6 +157,18 @@ async function auditAgentJwtRunHeaderMismatch( input: { companyId: string; agentId: string; claimRunId: string; headerRunId: string; method: string; url: string }, ) { try { + // The claimed run id is untrusted input from a rejected token. activity_log + // .run_id is a real FK to heartbeat_runs, so writing it unconditionally + // makes the insert fail — and the catch swallows it — exactly when the + // claim is bogus. Keep the claim in entityId/details and populate the FK + // column only when the row actually exists. + const claimedRunExists = isUuidLike(input.claimRunId) + ? await db + .select({ id: heartbeatRuns.id }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, input.claimRunId)) + .then((rows) => rows.length > 0) + : false; await db.insert(activityLog).values({ companyId: input.companyId, actorType: "agent", @@ -165,7 +177,7 @@ async function auditAgentJwtRunHeaderMismatch( entityType: "heartbeat_run", entityId: input.claimRunId, ...(isUuidLike(input.agentId) ? { agentId: input.agentId } : {}), - ...(isUuidLike(input.claimRunId) ? { runId: input.claimRunId } : {}), + ...(claimedRunExists ? { runId: input.claimRunId } : {}), details: { claimRunId: input.claimRunId, headerRunId: input.headerRunId,