fix(server): revoke terminal run JWTs

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-16 19:23:57 +00:00
1 parent 276e24d210
commit ea34e9b25b
3 files changed
+125 -27

No files matched your search

+7
View File
@@ -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.
@@ -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<Record<string, unknown>> = [];
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();
+44 -26
View File
@@ -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,