fix(api): scope Team Computer bot homes per member in shared Spaces

Team mode shares one disk per Space. RPC and executor paths under
bots/<otherBotId>/ were reachable with any member-owned entry bot,
leaking another member's workspace files while same-user cross-bot
access stays intentional.

Add path guards, filter bot listings, regression tests.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-09-22 04:24:28 +00:00 committed by Ingo Wagner
parent 4defccae95
commit 8ae36763a7
7 changed files with 272 additions and 15 deletions

View File

@ -32,6 +32,7 @@ import {
acquireComputerExecutionLease,
applyTeachingDesktopInput,
archiveBot,
assertTeamWorkspacePathAllowed,
buildMcpCredentialBlob,
buildModelConnectPlaintext,
ComputerBusyError,
@ -47,6 +48,7 @@ import {
displayBotWorkspacePath,
enqueueTakeoverContinuation,
expireComputerControl,
filterTeamWorkspaceEntries,
hasActiveComputerControl,
isAutoReviewCheckerConfigured,
isComputerScreenUnavailable,
@ -121,6 +123,7 @@ import {
IsolationError,
isProjectGoalStatus,
issueMessagingLinkCode,
listSpaceUserBotIds,
lockOwnedGroup,
newestModelCredentialOrder,
newestVoiceCredentialOrder,
@ -2140,6 +2143,20 @@ export function createRouter(deps: RouterDeps) {
const computerMode = parseComputerMode(computer.scope);
const ctx = computerContext(context.actor, bot.id, "files");
const storedPath = resolveBotWorkspacePath(computerMode, bot.id, input.path);
const teamBotIds =
computerMode === "team"
? await listSpaceUserBotIds(deps.prisma, context.actor.spaceId, context.actor.userId)
: null;
if (teamBotIds) {
try {
assertTeamWorkspacePathAllowed(storedPath, teamBotIds);
} catch (error) {
if (error instanceof Error && error.message.includes("escapes")) {
throw new ORPCError("FORBIDDEN", { message: error.message });
}
throw error;
}
}
let entries: Awaited<ReturnType<SandboxProvider["listFiles"]>>;
if (computer.state === "running" && computer.providerRef) {
await deps.prisma.computer.updateMany({
@ -2151,7 +2168,10 @@ export function createRouter(deps: RouterDeps) {
} else {
entries = await deps.home.list(computer.homeKey, storedPath, ctx);
}
return entries.map((entry) => ({
const visibleEntries = teamBotIds
? filterTeamWorkspaceEntries(entries, teamBotIds)
: entries;
return visibleEntries.map((entry) => ({
...entry,
path: displayBotWorkspacePath(computerMode, bot.id, input.path, entry.path),
}));
@ -2162,6 +2182,21 @@ export function createRouter(deps: RouterDeps) {
const computerMode = parseComputerMode(bot.computer.scope);
const ctx = computerContext(context.actor, bot.id, "read");
const storedPath = resolveBotWorkspacePath(computerMode, bot.id, input.path);
if (computerMode === "team") {
const teamBotIds = await listSpaceUserBotIds(
deps.prisma,
context.actor.spaceId,
context.actor.userId,
);
try {
assertTeamWorkspacePathAllowed(storedPath, teamBotIds);
} catch (error) {
if (error instanceof Error && error.message.includes("escapes")) {
throw new ORPCError("FORBIDDEN", { message: error.message });
}
throw error;
}
}
let content: string;
if (bot.computer.state === "running" && bot.computer.providerRef) {
await deps.prisma.computer.updateMany({

View File

@ -1,9 +1,13 @@
import { describe, expect, it } from "vitest";
import {
assertTeamShellCommandAllowed,
assertTeamWorkspacePathAllowed,
displayBotWorkspacePath,
filterTeamWorkspaceEntries,
resolveBotWorkspaceCwd,
resolveBotWorkspacePath,
teamBotWorkspaceDirectory,
teamWorkspaceTargetBotId,
} from "./computer-support.js";
describe("Team Computer bot folders", () => {
@ -43,6 +47,34 @@ describe("Team Computer bot folders", () => {
).toBe("bots/bot-1/notes/result.txt");
});
it("scopes Team bot homes to the caller's bots in shared Spaces", () => {
const allowed = new Set(["bot-1", "bot-2"]);
expect(teamWorkspaceTargetBotId("bots/bot-2/notes/result.txt")).toBe("bot-2");
expect(teamWorkspaceTargetBotId("shared/brief.md")).toBeNull();
expect(() =>
assertTeamWorkspacePathAllowed("bots/bot-2/notes/result.txt", allowed),
).not.toThrow();
expect(() => assertTeamWorkspacePathAllowed("bots/bot-3/secret.txt", allowed)).toThrow(
/escapes the bot computer workspace/,
);
expect(() => assertTeamShellCommandAllowed("cat bots/bot-3/secret.txt", allowed)).toThrow(
/escapes the bot computer workspace/,
);
expect(
filterTeamWorkspaceEntries(
[
{ path: "bots/bot-1", name: "bot-1" },
{ path: "bots/bot-3", name: "bot-3" },
{ path: "shared", name: "shared" },
],
allowed,
),
).toEqual([
{ path: "bots/bot-1", name: "bot-1" },
{ path: "shared", name: "shared" },
]);
});
it("leaves Private Computer paths unchanged", () => {
expect(resolveBotWorkspacePath("dedicated", "bot-1", "notes/result.txt")).toBe(
"notes/result.txt",

View File

@ -80,6 +80,49 @@ export function teamBotWorkspaceDirectory(botId: string): string {
return path.posix.join("bots", normalizeWorkspacePath(botId));
}
const TEAM_WORKSPACE_ESCAPE = "Path escapes the bot computer workspace";
/** Bot home segment under `bots/<botId>/…`, or null for shared/root paths. */
export function teamWorkspaceTargetBotId(storedPath: string): string | null {
const normalized = normalizeWorkspacePath(storedPath);
if (normalized === "bots" || !normalized.startsWith("bots/")) return null;
const remainder = normalized.slice("bots/".length);
if (!remainder) return null;
const [botId] = remainder.split("/");
return botId || null;
}
export function assertTeamWorkspacePathAllowed(
storedPath: string,
allowedBotIds: ReadonlySet<string>,
): void {
const targetBotId = teamWorkspaceTargetBotId(storedPath);
if (!targetBotId || allowedBotIds.has(targetBotId)) return;
throw new Error(TEAM_WORKSPACE_ESCAPE);
}
export function assertTeamShellCommandAllowed(
command: string,
allowedBotIds: ReadonlySet<string>,
): void {
for (const match of command.matchAll(/\bbots\/([A-Za-z0-9_-]+)/g)) {
const botId = match[1];
if (botId && !allowedBotIds.has(botId)) {
throw new Error(TEAM_WORKSPACE_ESCAPE);
}
}
}
export function filterTeamWorkspaceEntries<T extends { path: string }>(
entries: readonly T[],
allowedBotIds: ReadonlySet<string>,
): T[] {
return entries.filter((entry) => {
const targetBotId = teamWorkspaceTargetBotId(entry.path);
return !targetBotId || allowedBotIds.has(targetBotId);
});
}
export function resolveBotWorkspacePath(
scope: ComputerMode,
botId: string,

View File

@ -92,6 +92,7 @@ import {
findModelCredential,
InvalidSpaceNameError,
isTooManyDatabaseConnections,
listSpaceUserBotIds,
loadRunHistoryMessages,
type McpServer,
type Prisma,
@ -227,7 +228,10 @@ import {
} from "./computer-lifecycle.js";
import { withComputerScreenAvailability } from "./computer-screens.js";
import {
assertTeamShellCommandAllowed,
assertTeamWorkspacePathAllowed,
displayBotWorkspacePath,
filterTeamWorkspaceEntries,
resolveBotWorkspaceCwd,
resolveBotWorkspacePath,
teamBotWorkspaceDirectory,
@ -1268,6 +1272,15 @@ export function createRunExecutor(deps: ExecutorDeps) {
if (!bot.computer) throw new Error("Bot has no computer");
const storedComputer = bot.computer;
const computerMode = parseComputerMode(storedComputer.scope);
const teamBotIds =
computerMode === "team"
? await listSpaceUserBotIds(deps.prisma, run.spaceId, run.userId)
: null;
const resolveStoredWorkspacePath = (requestedPath: string) => {
const storedPath = resolveBotWorkspacePath(computerMode, bot.id, requestedPath);
if (teamBotIds) assertTeamWorkspacePathAllowed(storedPath, teamBotIds);
return storedPath;
};
const computer = await provisionComputer(deps, storedComputer.id, context, "bot");
screenRelease = { computer, context };
scheduleComputerSleep(deps.jobs, storedComputer.id);
@ -1284,6 +1297,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
context,
computer,
computerMode,
teamBotIds: teamBotIds ?? undefined,
markWorkspaceDirty: workspaceCheckpoint.markDirty,
},
)
@ -2094,12 +2108,15 @@ export function createRunExecutor(deps: ExecutorDeps) {
const requestedPath = String(args.path ?? "");
const entries = await deps.sandbox.listFiles(
computer,
resolveBotWorkspacePath(computerMode, bot.id, requestedPath),
resolveStoredWorkspacePath(requestedPath),
context,
);
const visibleEntries = teamBotIds
? filterTeamWorkspaceEntries(entries, teamBotIds)
: entries;
return {
path: requestedPath,
entries: entries.map((entry) => ({
entries: visibleEntries.map((entry) => ({
...entry,
path: displayBotWorkspacePath(computerMode, bot.id, requestedPath, entry.path),
})),
@ -2107,7 +2124,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
}
if (name === "read_file") {
const filePath = String(args.path ?? "");
const storedPath = resolveBotWorkspacePath(computerMode, bot.id, filePath);
const storedPath = resolveStoredWorkspacePath(filePath);
let bytes: Uint8Array;
try {
bytes = await deps.sandbox.readFile(computer, storedPath, context, {
@ -2151,7 +2168,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
await deps.sandbox.writeFile(
computer,
{
path: resolveBotWorkspacePath(computerMode, bot.id, filePath),
path: resolveStoredWorkspacePath(filePath),
content: new TextEncoder().encode(content),
},
context,
@ -2176,7 +2193,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
if (!rows && dataPath) {
const bytes = await deps.sandbox.readFile(
computer,
resolveBotWorkspacePath(computerMode, bot.id, dataPath),
resolveStoredWorkspacePath(dataPath),
context,
{ maxBytes: ATTACHMENT_MAX_BYTES },
);
@ -2198,7 +2215,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
workspaceCheckpoint.markDirty();
await deps.sandbox.writeFile(
computer,
{ path: resolveBotWorkspacePath(computerMode, bot.id, outPath), content: png },
{ path: resolveStoredWorkspacePath(outPath), content: png },
context,
);
let attached = false;
@ -2252,7 +2269,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
if (!deps.artifacts) {
return finish({ error: "artifact storage unavailable", path: filePath });
}
const storedPath = resolveBotWorkspacePath(computerMode, bot.id, filePath);
const storedPath = resolveStoredWorkspacePath(filePath);
let bytes: Uint8Array;
try {
bytes = await deps.sandbox.readFile(computer, storedPath, context, {
@ -2301,6 +2318,10 @@ export function createRunExecutor(deps: ExecutorDeps) {
bot.id,
args.cwd ? String(args.cwd) : undefined,
);
if (teamBotIds) {
if (cwd) assertTeamWorkspacePathAllowed(cwd, teamBotIds);
assertTeamShellCommandAllowed(command, teamBotIds);
}
workspaceCheckpoint.markDirty();
const result = await runSandboxCommand(
deps.sandbox,
@ -2338,7 +2359,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
kind: "open",
path: /^https?:\/\//i.test(requestedPath)
? requestedPath
: resolveBotWorkspacePath(computerMode, bot.id, requestedPath),
: resolveStoredWorkspacePath(requestedPath),
},
],
observe: true,
@ -3541,6 +3562,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
context,
computer,
computerMode,
teamBotIds: teamBotIds ?? undefined,
markWorkspaceDirty: workspaceCheckpoint.markDirty,
},
)
@ -3909,7 +3931,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
await deps.sandbox.writeFile(
computer,
{
path: resolveBotWorkspacePath(computerMode, bot.id, file.path),
path: resolveStoredWorkspacePath(file.path),
content: new TextEncoder().encode(file.content),
},
context,

View File

@ -15,7 +15,7 @@ import {
validateAttachmentMimeType,
} from "@rakazo/core";
import type { PrismaClient } from "@rakazo/db";
import { resolveBotWorkspacePath } from "./computer-support.js";
import { assertTeamWorkspacePathAllowed, resolveBotWorkspacePath } from "./computer-support.js";
export type MaterializedThreadFile = {
name: string;
@ -101,6 +101,7 @@ export async function materializeCurrentTurnFiles(
context: AdapterContext & { botId: string };
computer: ComputerRef;
computerMode: ComputerMode;
teamBotIds?: ReadonlySet<string>;
markWorkspaceDirty?: () => void;
},
): Promise<MaterializedThreadFile[]> {
@ -127,11 +128,17 @@ export async function materializeCurrentTurnFiles(
throw new Error(`Attached file exceeds the 10 MiB limit: ${row.name}`);
}
const relativePath = `attachments/${row.id}${attachmentExtensionForMimeType(row.mimeType)}`;
const storedPath = resolveBotWorkspacePath(
input.computerMode,
input.context.botId,
relativePath,
);
if (input.teamBotIds) assertTeamWorkspacePathAllowed(storedPath, input.teamBotIds);
input.markWorkspaceDirty?.();
await deps.sandbox.writeFile(
input.computer,
{
path: resolveBotWorkspacePath(input.computerMode, input.context.botId, relativePath),
path: storedPath,
content: bytes,
},
input.context,

View File

@ -21,8 +21,21 @@ export function computerHomeKey(mode: ComputerMode, spaceId: string, botId?: str
}
type ComputerDb = Pick<PrismaClient, "computer">;
type BotDb = Pick<PrismaClient, "bot">;
type ExecutionLeaseDb = Pick<PrismaClient, "computerExecutionLease">;
export async function listSpaceUserBotIds(
prisma: BotDb,
spaceId: string,
userId: string,
): Promise<Set<string>> {
const rows = await prisma.bot.findMany({
where: { spaceId, userId, archivedAt: null },
select: { id: true },
});
return new Set(rows.map((row) => row.id));
}
/** Expire leases as fencing tombstones so the next acquire increments fence. */
export async function expireComputerExecutionLeases(
prisma: ExecutionLeaseDb,

View File

@ -1,4 +1,4 @@
import { mkdtempSync, rmSync } from "node:fs";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { ComposioEmulator } from "@rakazo/adapters";
@ -468,6 +468,105 @@ describeWithDatabase("API authorization and resource isolation", () => {
expect(await handles.prisma.bot.findUnique({ where: { id: ownerBot.id } })).not.toBeNull();
});
it("keeps Team Computer bot homes private to each member in a shared Space", async () => {
const owner = await signup(app, `team-home-owner-${stamp}@rakazo.test`, "Team Home Owner");
const member = await signup(app, `team-home-member-${stamp}@rakazo.test`, "Team Home Member");
const shared = await rpc<Space>(app, owner, "spaces/create", { name: "Shared Team Computer" });
const sharedRow = await handles.prisma.space.findUniqueOrThrow({
where: { id: shared.id },
select: { organizationId: true },
});
const memberActor = await rpc<Actor>(app, member, "me");
await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } });
await handles.prisma.member.create({
data: {
id: `team-home-org-member-${stamp}`,
organizationId: sharedRow.organizationId,
userId: memberActor.userId,
role: "member",
createdAt: new Date(),
},
});
await handles.prisma.spaceMember.create({
data: {
id: `team-home-space-member-${stamp}`,
spaceId: shared.id,
organizationId: sharedRow.organizationId,
userId: memberActor.userId,
role: "member",
createdAt: new Date(),
},
});
const ownerBot = await rpc<Bot>(
app,
owner,
"bots/create",
botInput("Owner Team Bot"),
shared.id,
);
const ownerBotTwo = await rpc<Bot>(
app,
owner,
"bots/create",
botInput("Owner Team Bot Two"),
shared.id,
);
const memberBot = await rpc<Bot>(
app,
member,
"bots/create",
botInput("Member Team Bot"),
shared.id,
);
const secret = "owner-only-workspace-secret";
const ownerNotesDir = path.join(
dataDir,
"homes",
`team-${shared.id}`,
"bots",
ownerBot.id,
"notes",
);
mkdirSync(ownerNotesDir, { recursive: true });
writeFileSync(path.join(ownerNotesDir, "secret.txt"), secret);
await expectForbidden(
app,
member,
"computer/readFile",
{ botId: memberBot.id, path: `bots/${ownerBot.id}/notes/secret.txt` },
shared.id,
);
const memberNotesDir = path.join(
dataDir,
"homes",
`team-${shared.id}`,
"bots",
memberBot.id,
"notes",
);
mkdirSync(memberNotesDir, { recursive: true });
writeFileSync(path.join(memberNotesDir, "mine.txt"), "member-only");
const botListing = await rpc<Array<{ path: string }>>(
app,
member,
"computer/files",
{ botId: memberBot.id, path: "bots" },
shared.id,
);
expect(botListing.map((entry) => entry.path)).toEqual([`bots/${memberBot.id}`]);
const ownerCrossBot = await rpc<{ content: string }>(
app,
owner,
"computer/readFile",
{ botId: ownerBotTwo.id, path: `bots/${ownerBot.id}/notes/secret.txt` },
shared.id,
);
expect(ownerCrossBot.content).toBe(secret);
});
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");
@ -1516,8 +1615,14 @@ async function expectDenied(
expect(response.status, procedure).toBeGreaterThanOrEqual(400);
}
async function expectForbidden(app: App, cookie: string, procedure: string, body: unknown) {
const response = await raw(app, cookie, procedure, body);
async function expectForbidden(
app: App,
cookie: string,
procedure: string,
body: unknown,
spaceId?: string,
) {
const response = await raw(app, cookie, procedure, body, spaceId);
expect(response.status, procedure).toBe(403);
expect(await response.text(), procedure).toMatch(/forbidden/i);
}