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,
|
||||
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),
|
||||
|
||||
@ -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");
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user