From 77954ba7a7ebaca9bc5a8db622b216a35564d06e Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 3 Nov 2026 13:17:02 -0600 Subject: [PATCH] fix(timeline): filter foreign and missing agent actors Co-Authored-By: Paperclip --- .../helpers/work-timeline-receipt-cases.ts | 30 +++++++++++++++++++ server/src/services/work-timeline.ts | 8 +++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/server/src/__tests__/helpers/work-timeline-receipt-cases.ts b/server/src/__tests__/helpers/work-timeline-receipt-cases.ts index 53fc9b1322a..e5128f93532 100644 --- a/server/src/__tests__/helpers/work-timeline-receipt-cases.ts +++ b/server/src/__tests__/helpers/work-timeline-receipt-cases.ts @@ -201,4 +201,34 @@ export function registerTimelineReceiptBoundaryCases(f: Fixture) { expect(response.body.events.every((row: any) => actorIds.has(row.actorId))).toBe(true); expect(response.body.edges.every((row: any) => actorIds.has(row.fromActorId) && actorIds.has(row.toActorId))).toBe(true); }); + + it.each(["foreign-actor", "missing-actor", "foreign-target", "missing-target", "resolver"] as const)("does not expose out-of-bound agent identity through %s", async kind => { + const db = f.getDb(); const { companyId, userId, agentAId, agentBId } = await f.seedBase(); + const foreignCompanyId = randomUUID(), privateAgentId = randomUUID(), id = randomUUID(), at = new Date("2026-03-01T11:00:00Z"); + await db.insert(companies).values({ id: foreignCompanyId, name: "Foreign agent company", issuePrefix: "FA" }); + if (!kind.startsWith("missing")) await db.insert(agents).values({ id: privateAgentId, companyId: foreignCompanyId, name: "Foreign private agent", adapterType: "codex_local", status: "paused" }); + await db.insert(issues).values({ id, companyId, title: "Scoped agent facts", status: "done", createdByAgentId: kind === "resolver" ? privateAgentId : agentAId, createdAt: at }); + if (kind === "resolver") { + await db.insert(issueComments).values({ companyId, issueId: id, authorAgentId: privateAgentId, body: "Recorded comment", createdAt: at }); + await db.insert(heartbeatRuns).values({ companyId, agentId: privateAgentId, status: "succeeded", invocationSource: "assignment", contextSnapshot: { issueId: id }, createdAt: at, startedAt: at, finishedAt: at }); + } else { + await db.insert(activityLog).values({ companyId, actorType: "agent", actorId: kind.endsWith("actor") ? privateAgentId : agentAId, + action: "issue.updated", entityType: "issue", entityId: id, createdAt: at, + details: { assigneeAgentId: kind.endsWith("target") ? privateAgentId : agentBId } }); + } + // Valid company-local assignment/run remains visible beside rejected facts. + await db.insert(activityLog).values({ companyId, actorType: "agent", actorId: agentAId, action: "issue.updated", entityType: "issue", entityId: id, createdAt: at, details: { assigneeAgentId: agentBId } }); + const [validRun] = await db.insert(heartbeatRuns).values({ companyId, agentId: agentBId, status: "succeeded", invocationSource: "assignment", contextSnapshot: { issueId: id }, createdAt: at, startedAt: at, finishedAt: at }).returning(); + const response = await request(f.createApp(userId)).get(`/api/companies/${companyId}/timeline`).query({ from: "2026-03-01T00:00:00Z", to: "2026-03-02T00:00:00Z" }); + expect(response.status, JSON.stringify(response.body)).toBe(200); + const json = JSON.stringify(response.body); + for (const secret of [privateAgentId, "Foreign private agent"]) expect(json).not.toContain(secret); + expect(response.body.edges).toContainEqual(expect.objectContaining({ fromActorId: `agent:${agentAId}`, toActorId: `agent:${agentBId}`, kind: "assignment" })); + expect(response.body.spans).toContainEqual(expect.objectContaining({ runId: validRun!.id, actorId: `agent:${agentBId}` })); + const actorIds = new Set(response.body.actors.map((row: any) => row.id)); + expect(response.body.events.every((row: any) => actorIds.has(row.actorId))).toBe(true); + expect(response.body.spans.every((row: any) => actorIds.has(row.actorId))).toBe(true); + expect(response.body.edges.every((row: any) => actorIds.has(row.fromActorId) && actorIds.has(row.toActorId))).toBe(true); + }); + } diff --git a/server/src/services/work-timeline.ts b/server/src/services/work-timeline.ts index 0a570fc9a76..1bce2ef8d36 100644 --- a/server/src/services/work-timeline.ts +++ b/server/src/services/work-timeline.ts @@ -796,7 +796,11 @@ export function workTimelineService(db: Db) { } const actorMaps = await loadActorMaps(input.companyId, actorIds); - const publicActorIds = new Set(Array.from(actorIds).filter(id => !id.startsWith("user:") || actorMaps.users.has(id.slice("user:".length)))); + const publicActorIds = new Set(Array.from(actorIds).filter(id => { + if (id.startsWith("agent:")) return actorMaps.agents.has(id.slice("agent:".length)); + if (id.startsWith("user:")) return actorMaps.users.has(id.slice("user:".length)); + return true; + })); const actors: WorkTimelineActor[] = Array.from(publicActorIds).map((id) => { const [type, rawId] = id.split(":", 2) as [TimelineActorType, string]; if (type === "agent") { @@ -813,7 +817,7 @@ export function workTimelineService(db: Db) { return { actors, - spans: Array.from(spanByRunId.values()).sort((left, right) => left.start.localeCompare(right.start)), + spans: Array.from(spanByRunId.values()).filter(span => publicActorIds.has(span.actorId)).sort((left, right) => left.start.localeCompare(right.start)), events: events.filter(event => publicActorIds.has(event.actorId)).sort((left, right) => left.at.localeCompare(right.at)), edges: edges.filter(edge => publicActorIds.has(edge.fromActorId) && publicActorIds.has(edge.toActorId)).sort((left, right) => left.at.localeCompare(right.at)), pagination: {