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,