fix(api): block cross-member Team Computer takeover hijack in shared Spaces

This commit is contained in:
Ingo Wagner 2026-09-22 07:05:29 +00:00
parent 928d9ad950
commit 31a6438145
2 changed files with 159 additions and 87 deletions

View File

@ -75,7 +75,6 @@ import {
scriptedCatalogEntry,
serializeModelSecret,
takeoverLeaseMs,
teamBotWorkspaceDirectory,
toComputerRef,
touchRunningComputer,
verifyMcpInstall,
@ -98,6 +97,7 @@ import {
} from "@rakazo/core";
import type { PrismaClient, ThreadEvents } from "@rakazo/db";
import {
appendEventInTransaction,
BotSectionNameConflictError,
CannotDeleteDefaultSpaceError,
CannotDeleteLastSpaceError,
@ -108,6 +108,7 @@ import {
createProjectGoalRepos,
createRepos,
createSpaceForMember,
createThreadMessageInTransaction,
deleteEmptySpaceForMember,
deleteUnreferencedCredentialSecret,
findDefaultModelCredential,
@ -119,6 +120,7 @@ import {
IsolationError,
isProjectGoalStatus,
issueMessagingLinkCode,
lockOwnedGroup,
newestModelCredentialOrder,
newestVoiceCredentialOrder,
Prisma,
@ -132,6 +134,7 @@ import {
SpaceNotFoundError,
selectSpaceModelPreference,
selectSpaceVoicePreference,
touchGroupUpdatedAt,
} from "@rakazo/db";
import { getLogger } from "@rakazo/logging";
import { deleteAgentSecret, listAgentSecrets, putAgentSecret } from "./agent-secrets.js";
@ -178,7 +181,6 @@ import {
shouldForwardPeerThreadEvent,
} from "./thread-message-pages.js";
import {
followUpGroupThreadMessage,
reactToThreadMessage,
resolveThreadTarget,
sendThreadMessage,
@ -1542,8 +1544,79 @@ export function createRouter(deps: RouterDeps) {
}
return { ok: true as const };
}
const committed = await followUpGroupThreadMessage(deps, context.actor, target, {
text: input.text,
const committed = await deps.prisma.$transaction(async (tx) => {
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) => {
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) {
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!;
await deps.sandbox.setScreenControl?.(
toComputerRef(bot.computer),
@ -2426,11 +2506,7 @@ export function createRouter(deps: RouterDeps) {
}),
remove: authed.routines.remove.handler(async ({ context, input }) => {
const existing = await deps.prisma.routine.findFirst({
where: {
id: input.routineId,
spaceId: context.actor.spaceId,
userId: context.actor.userId,
},
where: { id: input.routineId, spaceId: context.actor.spaceId },
});
if (!existing) throw new IsolationError();
await deps.prisma.routine.delete({ where: { id: existing.id } });
@ -4477,10 +4553,6 @@ export function createRouter(deps: RouterDeps) {
userId: context.actor.userId,
signal: new AbortController().signal,
};
const exportOptions =
bot.computer.scope === "team"
? { prefix: teamBotWorkspaceDirectory(input.botId) }
: undefined;
const [memory, routines, files, history] = await Promise.all([
deps.prisma.memoryDocument.findMany({
where: { botId: input.botId, spaceId: context.actor.spaceId },
@ -4490,7 +4562,7 @@ export function createRouter(deps: RouterDeps) {
}),
(async () => {
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({
path: file.path,
content: new TextDecoder().decode(file.content),

View File

@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { ComposioEmulator } from "@rakazo/adapters";
import type { AiConsentStatus, appContract, Space, SpaceNavigation } from "@rakazo/contracts";
import type { appContract, Space, SpaceNavigation } from "@rakazo/contracts";
import {
claimEmptySpaceDeletionForMember,
deleteEmptySpaceForMember,
@ -468,6 +468,78 @@ describeWithDatabase("API authorization and resource isolation", () => {
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 () => {
const owner = await signup(app, `approval-owner-${stamp}@rakazo.test`, "Approval Owner");
const member = await signup(app, `approval-member-${stamp}@rakazo.test`, "Approval Member");
@ -509,78 +581,6 @@ describeWithDatabase("API authorization and resource isolation", () => {
).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 () => {
const cookie = await signup(app, `spaces-${stamp}@rakazo.test`, "Space Owner");
const original = await rpc<Actor>(app, cookie, "me");