From ff93b2c481d28aea0a86057aefd3f7e91008055a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 22 Sep 2026 08:14:56 +0000 Subject: [PATCH] fix(api): scope Team Computer read/list to member bot homes in shared Spaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Team mode stores one disk per Space. computer/readFile and computer/files only verified the caller owns the entry bot; an explicit bots//… path let one member read another member's workspace. Wire existing path guards into the API router and executor file/shell tools, and add a shared-Space authorization regression test. Co-authored-by: cynarAI --- apps/api/src/router.ts | 37 ++++++- packages/adapters/src/executor.ts | 40 ++++++-- packages/testkit/src/authorization.test.ts | 111 ++++++++++++++++++++- 3 files changed, 175 insertions(+), 13 deletions(-) diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 47dec1f..60c6989 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -32,6 +32,7 @@ import { acquireComputerExecutionLease, applyTeachingDesktopInput, archiveBot, + assertTeamWorkspacePathAllowed, buildMcpCredentialBlob, buildModelConnectPlaintext, ComputerBusyError, @@ -47,6 +48,7 @@ import { displayBotWorkspacePath, enqueueTakeoverContinuation, expireComputerControl, + filterTeamWorkspaceEntries, hasActiveComputerControl, isAutoReviewCheckerConfigured, isComputerScreenUnavailable, @@ -118,6 +120,7 @@ import { IsolationError, isProjectGoalStatus, issueMessagingLinkCode, + listSpaceUserBotIds, newestModelCredentialOrder, newestVoiceCredentialOrder, Prisma, @@ -2063,6 +2066,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>; if (computer.state === "running" && computer.providerRef) { await deps.prisma.computer.updateMany({ @@ -2074,7 +2091,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), })); @@ -2085,6 +2105,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({ diff --git a/packages/adapters/src/executor.ts b/packages/adapters/src/executor.ts index 2ee6b69..3121e7c 100644 --- a/packages/adapters/src/executor.ts +++ b/packages/adapters/src/executor.ts @@ -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, @@ -1265,6 +1269,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); @@ -1281,6 +1294,7 @@ export function createRunExecutor(deps: ExecutorDeps) { context, computer, computerMode, + teamBotIds: teamBotIds ?? undefined, markWorkspaceDirty: workspaceCheckpoint.markDirty, }, ) @@ -2091,12 +2105,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), })), @@ -2104,7 +2121,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, { @@ -2148,7 +2165,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, @@ -2173,7 +2190,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 }, ); @@ -2195,7 +2212,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; @@ -2249,7 +2266,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, { @@ -2298,6 +2315,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, @@ -2335,7 +2356,7 @@ export function createRunExecutor(deps: ExecutorDeps) { kind: "open", path: /^https?:\/\//i.test(requestedPath) ? requestedPath - : resolveBotWorkspacePath(computerMode, bot.id, requestedPath), + : resolveStoredWorkspacePath(requestedPath), }, ], observe: true, @@ -3538,6 +3559,7 @@ export function createRunExecutor(deps: ExecutorDeps) { context, computer, computerMode, + teamBotIds: teamBotIds ?? undefined, markWorkspaceDirty: workspaceCheckpoint.markDirty, }, ) @@ -3906,7 +3928,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, diff --git a/packages/testkit/src/authorization.test.ts b/packages/testkit/src/authorization.test.ts index 1891235..3ccc160 100644 --- a/packages/testkit/src/authorization.test.ts +++ b/packages/testkit/src/authorization.test.ts @@ -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, LocalAgentHomeStore, teamBotWorkspaceDirectory } from "@rakazo/adapters"; @@ -595,6 +595,105 @@ describeWithDatabase("API authorization and resource isolation", () => { ).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(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(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( + app, + owner, + "bots/create", + botInput("Owner Team Bot"), + shared.id, + ); + const ownerBotTwo = await rpc( + app, + owner, + "bots/create", + botInput("Owner Team Bot Two"), + shared.id, + ); + const memberBot = await rpc( + 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>( + 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("scopes Team Computer export to the exporting member's bot workspace in a shared Space", async () => { const owner = await signup(app, `export-owner-${stamp}@rakazo.test`, "Export Owner"); const member = await signup(app, `export-member-${stamp}@rakazo.test`, "Export Member"); @@ -1872,8 +1971,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); }