From ea34e9b25b4f6045d339db9a70b46fe32ea05d8e Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 14 Aug 2026 19:48:39 +0000 Subject: [PATCH] fix(server): revoke terminal run JWTs Co-Authored-By: Paperclip --- doc/SPEC-implementation.md | 7 ++ .../__tests__/agent-auth-middleware.test.ts | 75 ++++++++++++++++++- server/src/middleware/auth.ts | 70 ++++++++++------- 3 files changed, 125 insertions(+), 27 deletions(-) diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 39314c26623..583bc5a6663 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -598,6 +598,13 @@ Every issue PATCH emits an `issue.updated` activity receipt containing the actor, responsible user, run, authorization reason, and field-level before/after changes so both agent and board edits are visible in the issue activity stream. +Local-agent run JWTs are valid only while their exact persisted heartbeat run is +`running` and has no completion timestamp. The server revalidates that lifecycle +state on every authenticated request. Moving the run to any terminal status +revokes the run JWT immediately; there is no post-terminal grace window for +agent API reads or writes. Server-owned finalization and recovery bookkeeping +uses internal authority rather than the terminated run's credential. + ## 9.4 Permission Terminology and Default Visibility Rule Paperclip V1 keeps a company-scoped visibility model as the default because centralized authorization and scoped work-object controls are not yet a core V1 control surface. diff --git a/server/src/__tests__/agent-auth-middleware.test.ts b/server/src/__tests__/agent-auth-middleware.test.ts index ff27e685b77..3394779109f 100644 --- a/server/src/__tests__/agent-auth-middleware.test.ts +++ b/server/src/__tests__/agent-auth-middleware.test.ts @@ -33,7 +33,14 @@ function createSelectChain(rowsForTable: (table: unknown) => unknown[]) { function createDbState(input: { agent: { id: string; companyId: string; status?: string }; agentKey?: { id: string; agentId: string; companyId: string; keyHash: string; responsibleUserId?: string | null }; - run?: { id: string; companyId: string; agentId: string; responsibleUserId?: string | null }; + run?: { + id: string; + companyId: string; + agentId: string; + status?: string; + finishedAt?: Date | null; + responsibleUserId?: string | null; + }; }) { const activity: Array> = []; const agentRow = { @@ -57,6 +64,8 @@ function createDbState(input: { id: input.run.id, companyId: input.run.companyId, agentId: input.run.agentId, + status: input.run.status ?? "running", + finishedAt: input.run.finishedAt ?? null, responsibleUserId: input.run.responsibleUserId ?? null, } : null; @@ -286,6 +295,70 @@ describe("agent auth middleware", () => { }); }); + it.each(["succeeded", "failed", "cancelled", "timed_out", "interrupted"])( + "revokes the run JWT before reads or writes when the run is %s", + async (status) => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const runId = randomUUID(); + const issueId = randomUUID(); + const { db } = createDbState({ + agent: { id: agentId, companyId }, + run: { + id: runId, + companyId, + agentId, + status, + finishedAt: new Date(), + responsibleUserId: "user-claim", + }, + }); + const token = createLocalAgentJwt(agentId, companyId, "codex_local", runId, "user-claim"); + + const app = createApp(db); + const readRes = await request(app) + .get(`/companies/${companyId}/issues/${issueId}`) + .set("Authorization", `Bearer ${token}`) + .set("X-Paperclip-Run-Id", runId); + const writeRes = await request(app) + .patch(`/companies/${companyId}/issues/${issueId}`) + .set("Authorization", `Bearer ${token}`) + .set("X-Paperclip-Run-Id", runId) + .send({ title: "should not write" }); + + expect(readRes.status).toBe(401); + expect(readRes.body.error).toBe("Agent run is not active"); + expect(writeRes.status).toBe(401); + expect(writeRes.body.error).toBe("Agent run is not active"); + }, + ); + + it("fails closed when a running run already has a completion timestamp", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const runId = randomUUID(); + const { db } = createDbState({ + agent: { id: agentId, companyId }, + run: { + id: runId, + companyId, + agentId, + status: "running", + finishedAt: new Date(), + responsibleUserId: "user-claim", + }, + }); + const token = createLocalAgentJwt(agentId, companyId, "codex_local", runId, "user-claim"); + + const res = await request(createApp(db)) + .get(`/companies/${companyId}/protected`) + .set("Authorization", `Bearer ${token}`) + .set("X-Paperclip-Run-Id", runId); + + expect(res.status).toBe(401); + expect(res.body.error).toBe("Agent run is not active"); + }); + it("rejects fork-minted run JWTs before issue reads or writes reach live issue data", async () => { const companyId = randomUUID(); const agentId = randomUUID(); diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index 01df6fadf50..7017c2c4d38 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -46,7 +46,7 @@ function pruneCloudTenantWriteDebounce( } import { instanceSettingsService } from "../services/instance-settings.js"; import { ensureHumanRoleDefaultGrants } from "../services/principal-access-compatibility.js"; -import { forbidden, unprocessable } from "../errors.js"; +import { forbidden, unauthorized, unprocessable } from "../errors.js"; export { isCloudManagedInstance } from "../services/cloud-instance.js"; @@ -58,13 +58,18 @@ function normalizeOptionalString(value: string | null | undefined) { return value?.trim() || null; } -async function resolveLegacyRunResponsibleUserId( +async function loadAgentJwtRun( db: Db, input: { companyId: string; agentId: string; runId: string }, ) { if (!isUuidLike(input.runId)) return null; - const run = await db - .select({ responsibleUserId: heartbeatRuns.responsibleUserId }) + return db + .select({ + id: heartbeatRuns.id, + status: heartbeatRuns.status, + finishedAt: heartbeatRuns.finishedAt, + responsibleUserId: heartbeatRuns.responsibleUserId, + }) .from(heartbeatRuns) .where( and( @@ -74,7 +79,6 @@ async function resolveLegacyRunResponsibleUserId( ), ) .then((rows) => rows[0] ?? null); - return normalizeOptionalString(run?.responsibleUserId); } async function loadResponsibleUserMemberships( @@ -301,22 +305,6 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa return; } - const agentRecord = await db - .select() - .from(agents) - .where(eq(agents.id, claims.sub)) - .then((rows) => rows[0] ?? null); - - if (!agentRecord || agentRecord.companyId !== claims.company_id) { - next(); - return; - } - - if (agentRecord.status === "terminated" || agentRecord.status === "pending_approval") { - next(); - return; - } - const normalizedRunIdHeader = normalizeOptionalString(runIdHeader); if (normalizedRunIdHeader && normalizedRunIdHeader !== claims.run_id) { await auditAgentJwtRunHeaderMismatch(db, { @@ -337,13 +325,43 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa return; } + const [agentRecord, jwtRun] = await Promise.all([ + db + .select() + .from(agents) + .where(eq(agents.id, claims.sub)) + .then((rows) => rows[0] ?? null), + loadAgentJwtRun(db, { + companyId: claims.company_id, + agentId: claims.sub, + runId: claims.run_id, + }), + ]); + + if (!agentRecord || agentRecord.companyId !== claims.company_id) { + next(); + return; + } + + if (agentRecord.status === "terminated" || agentRecord.status === "pending_approval") { + next(); + return; + } + + // A local-agent JWT is a capability for exactly one live heartbeat run, + // not a long-lived agent credential. Revalidate the persisted lifecycle + // on every request so terminalizing the run revokes the capability + // immediately instead of leaving it usable until the JWT's expiry. + // `finishedAt` is checked as a fail-closed guard for an inconsistent row + // whose status was not updated atomically with finalization. + if (!jwtRun || jwtRun.status !== "running" || jwtRun.finishedAt !== null) { + next(unauthorized("Agent run is not active")); + return; + } + const onBehalfOfUserId = claims.responsible_user_id !== undefined ? normalizeOptionalString(claims.responsible_user_id) - : await resolveLegacyRunResponsibleUserId(db, { - companyId: claims.company_id, - agentId: claims.sub, - runId: claims.run_id, - }); + : normalizeOptionalString(jwtRun.responsibleUserId); const onBehalfOfMemberships = await loadResponsibleUserMemberships(db, { companyId: claims.company_id, userId: onBehalfOfUserId,