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 <cynarAI@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-09-22 06:21:53 +00:00 committed by Ingo Wagner
parent ca292d2cc0
commit c8c6d9e042
2 changed files with 95 additions and 3 deletions

View File

@ -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 = {

View File

@ -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;
}
}