From c8c6d9e042e5b0aa9c1691d1bea506a2f5ce5582 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 22 Sep 2026 06:21:53 +0000 Subject: [PATCH] fix(db): require run owner for all secret and approval answers answerRunInput already blocked cross-member answers for credential secret asks and always-allow approvals, but one-time allow/deny and generic OTP secret asks had no run.userId check. Align the DB gate so shared-space prompt answers cannot be hijacked at the persistence layer. Co-authored-by: cynarAI --- packages/db/src/events.test.ts | 94 +++++++++++++++++++++++++++++++++- packages/db/src/events.ts | 4 +- 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/packages/db/src/events.test.ts b/packages/db/src/events.test.ts index 4b93d7e..1ab2d24 100644 --- a/packages/db/src/events.test.ts +++ b/packages/db/src/events.test.ts @@ -969,7 +969,7 @@ describe("answerRunInput", () => { update: vi.fn().mockResolvedValue({ id: "message-1" }), }, run: { - findFirst: vi.fn().mockResolvedValue({ botId: "bot-1" }), + findFirst: vi.fn().mockResolvedValue({ botId: "bot-1", userId: "user-1" }), updateMany: vi.fn().mockResolvedValue({ count: 1 }), findUnique: vi.fn().mockResolvedValue({ status: "queued" }), }, @@ -1111,6 +1111,54 @@ describe("answerRunInput", () => { }); }); + it("does not let another workspace member approve a consequential action with allow", async () => { + const tx = { + $queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), + message: { + findFirst: vi.fn().mockResolvedValue({ + id: "message-1", + blocks: [ + { + kind: "ask", + approvalEffectId: "effect-1", + text: "Review before writing", + status: "pending", + actions: [ + { id: "allow", label: "Allow once" }, + { id: "deny", label: "Deny" }, + ], + }, + ], + }), + }, + run: { + findFirst: vi.fn().mockResolvedValue({ botId: "bot-1", userId: "run-owner" }), + updateMany: vi.fn(), + }, + externalEffect: { + findFirst: vi.fn().mockResolvedValue({ id: "effect-1", status: "intended" }), + update: vi.fn(), + }, + }; + const prisma = { + $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), + } as unknown as PrismaClient; + + await expect( + answerRunInput(prisma, { + spaceId: "workspace-1", + threadId: "thread-1", + runId: "run-1", + messageId: "message-1", + answeredByUserId: "other-member", + answer: "allow", + }), + ).resolves.toBe(false); + + expect(tx.externalEffect.update).not.toHaveBeenCalled(); + expect(tx.run.updateMany).not.toHaveBeenCalled(); + }); + it("does not let another workspace member create an always-allow rule for the run owner", async () => { const tx = { $queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), @@ -1382,6 +1430,50 @@ describe("answerRunInput", () => { }); }); + it("does not let another workspace member answer a generic secret ask for the run owner", async () => { + const store = vi.fn(); + const tx = { + $queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]), + run: { + findFirst: vi.fn().mockResolvedValue({ botId: "bot-1", userId: "owner" }), + updateMany: vi.fn(), + }, + message: { + findFirst: vi.fn().mockResolvedValue({ + id: "message-1", + blocks: [ + { + kind: "ask", + text: "Enter your code", + input: "secret", + status: "pending", + }, + ], + }), + }, + }; + const prisma = { + $transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)), + } as unknown as PrismaClient; + expect( + await answerRunInput( + prisma, + { + spaceId: "workspace-1", + threadId: "thread-1", + runId: "run-1", + messageId: "message-1", + answeredByUserId: "other-member", + answer: "123456", + }, + new TestFanout(), + { store }, + ), + ).toBe(false); + expect(store).not.toHaveBeenCalled(); + expect(tx.run.updateMany).not.toHaveBeenCalled(); + }); + it("does not let another workspace member save a credential as the run owner", async () => { const store = vi.fn(); const tx = { diff --git a/packages/db/src/events.ts b/packages/db/src/events.ts index 5cb4bcc..90a2daf 100644 --- a/packages/db/src/events.ts +++ b/packages/db/src/events.ts @@ -551,11 +551,12 @@ export async function answerRunInput( : undefined; if (choiceAsk && !selectedChoice) return null; if (secretAsk && !runSecretWriter) return null; - if (secretAsk && pendingAsk.credential && run.userId !== input.answeredByUserId) return null; + if (secretAsk && run.userId !== input.answeredByUserId) return null; let approvalEffect: { id: string; kind: string } | null = null; let approvalUserId: string | null = null; if (approvalAsk) { + if (run.userId !== input.answeredByUserId) return null; if (!pendingAsk.actions?.some((action) => action.id === input.answer)) return null; approvalEffect = await tx.externalEffect.findFirst({ where: { @@ -567,7 +568,6 @@ export async function answerRunInput( }); if (!approvalEffect) return null; if (input.answer === "always") { - if (run.userId !== input.answeredByUserId) return null; approvalUserId = input.answeredByUserId; } }