diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 5338bb4..b8f1235 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, @@ -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>; 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({ diff --git a/packages/adapters/src/computer-support.test.ts b/packages/adapters/src/computer-support.test.ts index 197d1e7..4d96ee2 100644 --- a/packages/adapters/src/computer-support.test.ts +++ b/packages/adapters/src/computer-support.test.ts @@ -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", diff --git a/packages/adapters/src/computer-support.ts b/packages/adapters/src/computer-support.ts index 5c75d7b..e6e058b 100644 --- a/packages/adapters/src/computer-support.ts +++ b/packages/adapters/src/computer-support.ts @@ -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//…`, 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, +): void { + const targetBotId = teamWorkspaceTargetBotId(storedPath); + if (!targetBotId || allowedBotIds.has(targetBotId)) return; + throw new Error(TEAM_WORKSPACE_ESCAPE); +} + +export function assertTeamShellCommandAllowed( + command: string, + allowedBotIds: ReadonlySet, +): 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( + entries: readonly T[], + allowedBotIds: ReadonlySet, +): T[] { + return entries.filter((entry) => { + const targetBotId = teamWorkspaceTargetBotId(entry.path); + return !targetBotId || allowedBotIds.has(targetBotId); + }); +} + export function resolveBotWorkspacePath( scope: ComputerMode, botId: string, diff --git a/packages/adapters/src/executor.ts b/packages/adapters/src/executor.ts index a41fd88..f55e5ee 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, @@ -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, diff --git a/packages/adapters/src/thread-artifacts.ts b/packages/adapters/src/thread-artifacts.ts index 1322481..b9bf27d 100644 --- a/packages/adapters/src/thread-artifacts.ts +++ b/packages/adapters/src/thread-artifacts.ts @@ -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; markWorkspaceDirty?: () => void; }, ): Promise { @@ -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, diff --git a/packages/db/src/computers.ts b/packages/db/src/computers.ts index 0610c71..fd5965a 100644 --- a/packages/db/src/computers.ts +++ b/packages/db/src/computers.ts @@ -21,8 +21,21 @@ export function computerHomeKey(mode: ComputerMode, spaceId: string, botId?: str } type ComputerDb = Pick; +type BotDb = Pick; type ExecutionLeaseDb = Pick; +export async function listSpaceUserBotIds( + prisma: BotDb, + spaceId: string, + userId: string, +): Promise> { + 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, diff --git a/packages/testkit/src/authorization.test.ts b/packages/testkit/src/authorization.test.ts index 2e88d60..b2c2184 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 } 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(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("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); }