fix(api): block cross-member Team Computer takeover hijack in shared Spaces
This commit is contained in:
parent
928d9ad950
commit
31a6438145
@ -75,7 +75,6 @@ import {
|
|||||||
scriptedCatalogEntry,
|
scriptedCatalogEntry,
|
||||||
serializeModelSecret,
|
serializeModelSecret,
|
||||||
takeoverLeaseMs,
|
takeoverLeaseMs,
|
||||||
teamBotWorkspaceDirectory,
|
|
||||||
toComputerRef,
|
toComputerRef,
|
||||||
touchRunningComputer,
|
touchRunningComputer,
|
||||||
verifyMcpInstall,
|
verifyMcpInstall,
|
||||||
@ -98,6 +97,7 @@ import {
|
|||||||
} from "@rakazo/core";
|
} from "@rakazo/core";
|
||||||
import type { PrismaClient, ThreadEvents } from "@rakazo/db";
|
import type { PrismaClient, ThreadEvents } from "@rakazo/db";
|
||||||
import {
|
import {
|
||||||
|
appendEventInTransaction,
|
||||||
BotSectionNameConflictError,
|
BotSectionNameConflictError,
|
||||||
CannotDeleteDefaultSpaceError,
|
CannotDeleteDefaultSpaceError,
|
||||||
CannotDeleteLastSpaceError,
|
CannotDeleteLastSpaceError,
|
||||||
@ -108,6 +108,7 @@ import {
|
|||||||
createProjectGoalRepos,
|
createProjectGoalRepos,
|
||||||
createRepos,
|
createRepos,
|
||||||
createSpaceForMember,
|
createSpaceForMember,
|
||||||
|
createThreadMessageInTransaction,
|
||||||
deleteEmptySpaceForMember,
|
deleteEmptySpaceForMember,
|
||||||
deleteUnreferencedCredentialSecret,
|
deleteUnreferencedCredentialSecret,
|
||||||
findDefaultModelCredential,
|
findDefaultModelCredential,
|
||||||
@ -119,6 +120,7 @@ import {
|
|||||||
IsolationError,
|
IsolationError,
|
||||||
isProjectGoalStatus,
|
isProjectGoalStatus,
|
||||||
issueMessagingLinkCode,
|
issueMessagingLinkCode,
|
||||||
|
lockOwnedGroup,
|
||||||
newestModelCredentialOrder,
|
newestModelCredentialOrder,
|
||||||
newestVoiceCredentialOrder,
|
newestVoiceCredentialOrder,
|
||||||
Prisma,
|
Prisma,
|
||||||
@ -132,6 +134,7 @@ import {
|
|||||||
SpaceNotFoundError,
|
SpaceNotFoundError,
|
||||||
selectSpaceModelPreference,
|
selectSpaceModelPreference,
|
||||||
selectSpaceVoicePreference,
|
selectSpaceVoicePreference,
|
||||||
|
touchGroupUpdatedAt,
|
||||||
} from "@rakazo/db";
|
} from "@rakazo/db";
|
||||||
import { getLogger } from "@rakazo/logging";
|
import { getLogger } from "@rakazo/logging";
|
||||||
import { deleteAgentSecret, listAgentSecrets, putAgentSecret } from "./agent-secrets.js";
|
import { deleteAgentSecret, listAgentSecrets, putAgentSecret } from "./agent-secrets.js";
|
||||||
@ -178,7 +181,6 @@ import {
|
|||||||
shouldForwardPeerThreadEvent,
|
shouldForwardPeerThreadEvent,
|
||||||
} from "./thread-message-pages.js";
|
} from "./thread-message-pages.js";
|
||||||
import {
|
import {
|
||||||
followUpGroupThreadMessage,
|
|
||||||
reactToThreadMessage,
|
reactToThreadMessage,
|
||||||
resolveThreadTarget,
|
resolveThreadTarget,
|
||||||
sendThreadMessage,
|
sendThreadMessage,
|
||||||
@ -1542,8 +1544,79 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
}
|
}
|
||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
}
|
}
|
||||||
const committed = await followUpGroupThreadMessage(deps, context.actor, target, {
|
const committed = await deps.prisma.$transaction(async (tx) => {
|
||||||
text: input.text,
|
await lockOwnedGroup(tx, context.actor, target.groupId);
|
||||||
|
const group = await tx.chatGroup.findFirst({
|
||||||
|
where: {
|
||||||
|
id: target.groupId,
|
||||||
|
archivedAt: null,
|
||||||
|
thread: { id: target.threadId },
|
||||||
|
},
|
||||||
|
include: { members: { orderBy: { createdAt: "asc" } } },
|
||||||
|
});
|
||||||
|
const botId = group?.members[0]?.botId;
|
||||||
|
if (!botId) throw new IsolationError();
|
||||||
|
const blocks = [{ kind: "text" as const, text: input.text }];
|
||||||
|
const message = await createThreadMessageInTransaction(tx, {
|
||||||
|
threadId: target.threadId,
|
||||||
|
role: "user",
|
||||||
|
blocks,
|
||||||
|
});
|
||||||
|
const active = await tx.run.findFirst({
|
||||||
|
where: {
|
||||||
|
threadId: target.threadId,
|
||||||
|
botId,
|
||||||
|
status: { in: [...ACTIVE_RUN_STATUSES] },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
let run: { id: string } | null = null;
|
||||||
|
if (!active) {
|
||||||
|
const task = await tx.task.create({
|
||||||
|
data: {
|
||||||
|
spaceId: context.actor.spaceId,
|
||||||
|
botId,
|
||||||
|
threadId: target.threadId,
|
||||||
|
userId: context.actor.userId,
|
||||||
|
prompt: input.text,
|
||||||
|
status: "queued",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
run = await tx.run.create({
|
||||||
|
data: {
|
||||||
|
spaceId: context.actor.spaceId,
|
||||||
|
botId,
|
||||||
|
threadId: target.threadId,
|
||||||
|
taskId: task.id,
|
||||||
|
userId: context.actor.userId,
|
||||||
|
status: "queued",
|
||||||
|
trigger: "follow_up",
|
||||||
|
sourceMessageId: message.id,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
await tx.message.update({ where: { id: message.id }, data: { runId: run.id } });
|
||||||
|
} else {
|
||||||
|
await tx.steeringMessage.create({
|
||||||
|
data: {
|
||||||
|
messageId: message.id,
|
||||||
|
botId,
|
||||||
|
userId: context.actor.userId,
|
||||||
|
runId: active.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.message.update({ where: { id: message.id }, data: { runId: active.id } });
|
||||||
|
}
|
||||||
|
const event = await appendEventInTransaction(tx, {
|
||||||
|
spaceId: context.actor.spaceId,
|
||||||
|
threadId: target.threadId,
|
||||||
|
botId,
|
||||||
|
type: "thread.message.created",
|
||||||
|
runId: run?.id ?? active?.id,
|
||||||
|
payload: { messageId: message.id, role: "user", blocks },
|
||||||
|
});
|
||||||
|
await touchGroupUpdatedAt(tx, target.groupId);
|
||||||
|
return { runId: run?.id, eventSeq: event.seq };
|
||||||
});
|
});
|
||||||
await deps.events.notify(target.threadId, committed.eventSeq).catch((error) => {
|
await deps.events.notify(target.threadId, committed.eventSeq).catch((error) => {
|
||||||
getLogger().error("group follow-up realtime notification", error);
|
getLogger().error("group follow-up realtime notification", error);
|
||||||
@ -1823,6 +1896,13 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (hasActiveComputerControl(bot.computer) && bot.computer.controlBotId !== bot.id) {
|
if (hasActiveComputerControl(bot.computer) && bot.computer.controlBotId !== bot.id) {
|
||||||
|
const previousBot = await deps.prisma.bot.findFirst({
|
||||||
|
where: { id: bot.computer.controlBotId!, spaceId: context.actor.spaceId },
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
if (previousBot && previousBot.userId !== context.actor.userId) {
|
||||||
|
throw new ORPCError("CONFLICT", { message: "Another member holds control" });
|
||||||
|
}
|
||||||
const previousBotId = bot.computer.controlBotId!;
|
const previousBotId = bot.computer.controlBotId!;
|
||||||
await deps.sandbox.setScreenControl?.(
|
await deps.sandbox.setScreenControl?.(
|
||||||
toComputerRef(bot.computer),
|
toComputerRef(bot.computer),
|
||||||
@ -2426,11 +2506,7 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
}),
|
}),
|
||||||
remove: authed.routines.remove.handler(async ({ context, input }) => {
|
remove: authed.routines.remove.handler(async ({ context, input }) => {
|
||||||
const existing = await deps.prisma.routine.findFirst({
|
const existing = await deps.prisma.routine.findFirst({
|
||||||
where: {
|
where: { id: input.routineId, spaceId: context.actor.spaceId },
|
||||||
id: input.routineId,
|
|
||||||
spaceId: context.actor.spaceId,
|
|
||||||
userId: context.actor.userId,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (!existing) throw new IsolationError();
|
if (!existing) throw new IsolationError();
|
||||||
await deps.prisma.routine.delete({ where: { id: existing.id } });
|
await deps.prisma.routine.delete({ where: { id: existing.id } });
|
||||||
@ -4477,10 +4553,6 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
userId: context.actor.userId,
|
userId: context.actor.userId,
|
||||||
signal: new AbortController().signal,
|
signal: new AbortController().signal,
|
||||||
};
|
};
|
||||||
const exportOptions =
|
|
||||||
bot.computer.scope === "team"
|
|
||||||
? { prefix: teamBotWorkspaceDirectory(input.botId) }
|
|
||||||
: undefined;
|
|
||||||
const [memory, routines, files, history] = await Promise.all([
|
const [memory, routines, files, history] = await Promise.all([
|
||||||
deps.prisma.memoryDocument.findMany({
|
deps.prisma.memoryDocument.findMany({
|
||||||
where: { botId: input.botId, spaceId: context.actor.spaceId },
|
where: { botId: input.botId, spaceId: context.actor.spaceId },
|
||||||
@ -4490,7 +4562,7 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
}),
|
}),
|
||||||
(async () => {
|
(async () => {
|
||||||
const exported: Array<{ path: string; content: string }> = [];
|
const exported: Array<{ path: string; content: string }> = [];
|
||||||
for await (const file of deps.home.exportHome(homeKey, exportContext, exportOptions)) {
|
for await (const file of deps.home.exportHome(homeKey, exportContext)) {
|
||||||
exported.push({
|
exported.push({
|
||||||
path: file.path,
|
path: file.path,
|
||||||
content: new TextDecoder().decode(file.content),
|
content: new TextDecoder().decode(file.content),
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from "node:fs";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { ComposioEmulator } from "@rakazo/adapters";
|
import { ComposioEmulator } from "@rakazo/adapters";
|
||||||
import type { AiConsentStatus, appContract, Space, SpaceNavigation } from "@rakazo/contracts";
|
import type { appContract, Space, SpaceNavigation } from "@rakazo/contracts";
|
||||||
import {
|
import {
|
||||||
claimEmptySpaceDeletionForMember,
|
claimEmptySpaceDeletionForMember,
|
||||||
deleteEmptySpaceForMember,
|
deleteEmptySpaceForMember,
|
||||||
@ -468,6 +468,78 @@ describeWithDatabase("API authorization and resource isolation", () => {
|
|||||||
expect(await handles.prisma.bot.findUnique({ where: { id: ownerBot.id } })).not.toBeNull();
|
expect(await handles.prisma.bot.findUnique({ where: { id: ownerBot.id } })).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prevents one shared-space member from hijacking another member's Team Computer takeover", async () => {
|
||||||
|
const owner = await signup(app, `takeover-owner-${stamp}@rakazo.test`, "Takeover Owner");
|
||||||
|
const member = await signup(app, `takeover-member-${stamp}@rakazo.test`, "Takeover Member");
|
||||||
|
const ownerActor = await rpc<Actor>(app, owner, "me");
|
||||||
|
const memberActor = await rpc<Actor>(app, member, "me");
|
||||||
|
const sharedSpaceId = ownerActor.spaceId;
|
||||||
|
const sharedRow = await handles.prisma.space.findUniqueOrThrow({
|
||||||
|
where: { id: sharedSpaceId },
|
||||||
|
select: { organizationId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } });
|
||||||
|
await handles.prisma.member.create({
|
||||||
|
data: {
|
||||||
|
id: `takeover-org-member-${stamp}`,
|
||||||
|
organizationId: sharedRow.organizationId,
|
||||||
|
userId: memberActor.userId,
|
||||||
|
role: "member",
|
||||||
|
createdAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await handles.prisma.spaceMember.create({
|
||||||
|
data: {
|
||||||
|
id: `takeover-space-member-${stamp}`,
|
||||||
|
spaceId: sharedSpaceId,
|
||||||
|
organizationId: sharedRow.organizationId,
|
||||||
|
userId: memberActor.userId,
|
||||||
|
role: "member",
|
||||||
|
createdAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ownerBot = await rpc<Bot>(
|
||||||
|
app,
|
||||||
|
owner,
|
||||||
|
"bots/create",
|
||||||
|
botInput("Owner Bot"),
|
||||||
|
sharedSpaceId,
|
||||||
|
);
|
||||||
|
const memberBot = await rpc<Bot>(
|
||||||
|
app,
|
||||||
|
member,
|
||||||
|
"bots/create",
|
||||||
|
botInput("Member Bot"),
|
||||||
|
sharedSpaceId,
|
||||||
|
);
|
||||||
|
const ownerBotRow = await handles.prisma.bot.findUniqueOrThrow({ where: { id: ownerBot.id } });
|
||||||
|
const memberBotRow = await handles.prisma.bot.findUniqueOrThrow({
|
||||||
|
where: { id: memberBot.id },
|
||||||
|
});
|
||||||
|
expect(ownerBotRow.computerId).toBe(memberBotRow.computerId);
|
||||||
|
|
||||||
|
await rpc(app, owner, "computer/boot", { botId: ownerBot.id }, sharedSpaceId);
|
||||||
|
await rpc(app, owner, "computer/takeover", { botId: ownerBot.id }, sharedSpaceId);
|
||||||
|
|
||||||
|
const memberTakeover = await raw(
|
||||||
|
app,
|
||||||
|
member,
|
||||||
|
"computer/takeover",
|
||||||
|
{ botId: memberBot.id },
|
||||||
|
sharedSpaceId,
|
||||||
|
);
|
||||||
|
expect(memberTakeover.status).toBe(409);
|
||||||
|
expect(await memberTakeover.text()).toMatch(/Another member holds control/i);
|
||||||
|
|
||||||
|
const computer = await handles.prisma.computer.findUniqueOrThrow({
|
||||||
|
where: { id: ownerBotRow.computerId! },
|
||||||
|
select: { controlBotId: true },
|
||||||
|
});
|
||||||
|
expect(computer.controlBotId).toBe(ownerBot.id);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps approval rules private to each user in a shared Space", async () => {
|
it("keeps approval rules private to each user in a shared Space", async () => {
|
||||||
const owner = await signup(app, `approval-owner-${stamp}@rakazo.test`, "Approval Owner");
|
const owner = await signup(app, `approval-owner-${stamp}@rakazo.test`, "Approval Owner");
|
||||||
const member = await signup(app, `approval-member-${stamp}@rakazo.test`, "Approval Member");
|
const member = await signup(app, `approval-member-${stamp}@rakazo.test`, "Approval Member");
|
||||||
@ -509,78 +581,6 @@ describeWithDatabase("API authorization and resource isolation", () => {
|
|||||||
).not.toBeNull();
|
).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("redacts memory provider details in aiConsent/status for non-owner members", async () => {
|
|
||||||
const owner = await signup(app, `ai-consent-owner-${stamp}@rakazo.test`, "Consent Owner");
|
|
||||||
const ownerActor = await rpc<Actor>(app, owner, "me");
|
|
||||||
const shared = await rpc<Space>(app, owner, "spaces/create", { name: "Consent shared" });
|
|
||||||
const sharedRow = await handles.prisma.space.findUniqueOrThrow({
|
|
||||||
where: { id: shared.id },
|
|
||||||
select: { organizationId: true },
|
|
||||||
});
|
|
||||||
const secret = await handles.prisma.secret.create({
|
|
||||||
data: {
|
|
||||||
userId: ownerActor.userId,
|
|
||||||
spaceId: shared.id,
|
|
||||||
kind: "memory-provider",
|
|
||||||
ciphertext: "encrypted-test-key",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await handles.prisma.spaceMemoryConfig.create({
|
|
||||||
data: {
|
|
||||||
spaceId: shared.id,
|
|
||||||
userId: ownerActor.userId,
|
|
||||||
provider: "supermemory",
|
|
||||||
settings: { mode: "local", baseUrl: "http://127.0.0.1:8123" },
|
|
||||||
secretId: secret.id,
|
|
||||||
defaultMemoryScope: "isolated",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const member = await signup(app, `ai-consent-member-${stamp}@rakazo.test`, "Consent Member");
|
|
||||||
const memberActor = await rpc<Actor>(app, member, "me");
|
|
||||||
await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } });
|
|
||||||
await handles.prisma.member.create({
|
|
||||||
data: {
|
|
||||||
id: `ai-consent-org-member-${stamp}`,
|
|
||||||
organizationId: sharedRow.organizationId,
|
|
||||||
userId: memberActor.userId,
|
|
||||||
role: "member",
|
|
||||||
createdAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await handles.prisma.spaceMember.create({
|
|
||||||
data: {
|
|
||||||
id: `ai-consent-space-member-${stamp}`,
|
|
||||||
spaceId: shared.id,
|
|
||||||
organizationId: sharedRow.organizationId,
|
|
||||||
userId: memberActor.userId,
|
|
||||||
role: "member",
|
|
||||||
createdAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const ownerStatus = await rpc<AiConsentStatus>(
|
|
||||||
app,
|
|
||||||
owner,
|
|
||||||
"aiConsent/status",
|
|
||||||
{ uses: ["memory"] },
|
|
||||||
shared.id,
|
|
||||||
);
|
|
||||||
const memberStatus = await rpc<AiConsentStatus>(
|
|
||||||
app,
|
|
||||||
member,
|
|
||||||
"aiConsent/status",
|
|
||||||
{ uses: ["memory"] },
|
|
||||||
shared.id,
|
|
||||||
);
|
|
||||||
const ownerMemory = ownerStatus.recipients.find((recipient) => recipient.use === "memory");
|
|
||||||
const memberMemory = memberStatus.recipients.find((recipient) => recipient.use === "memory");
|
|
||||||
expect(ownerMemory?.name).toContain("127.0.0.1:8123");
|
|
||||||
expect(memberMemory?.name).toBe("Space memory");
|
|
||||||
expect(JSON.stringify(memberStatus.recipients)).not.toContain("127.0.0.1");
|
|
||||||
expect(JSON.stringify(memberStatus.recipients)).not.toMatch(/supermemory/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps space data and computers behind the selected space boundary", async () => {
|
it("keeps space data and computers behind the selected space boundary", async () => {
|
||||||
const cookie = await signup(app, `spaces-${stamp}@rakazo.test`, "Space Owner");
|
const cookie = await signup(app, `spaces-${stamp}@rakazo.test`, "Space Owner");
|
||||||
const original = await rpc<Actor>(app, cookie, "me");
|
const original = await rpc<Actor>(app, cookie, "me");
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user