Merge pull request #64 from cynarAI/cursor/team-computer-read-authz-94bb

fix(api): scope Team Computer read/list to member bot homes in shared Spaces
This commit is contained in:
cursor[bot] 2026-09-22 08:59:24 +00:00 committed by GitHub
commit 291f23e917
3 changed files with 175 additions and 13 deletions

View File

@ -32,6 +32,7 @@ import {
acquireComputerExecutionLease, acquireComputerExecutionLease,
applyTeachingDesktopInput, applyTeachingDesktopInput,
archiveBot, archiveBot,
assertTeamWorkspacePathAllowed,
buildMcpCredentialBlob, buildMcpCredentialBlob,
buildModelConnectPlaintext, buildModelConnectPlaintext,
ComputerBusyError, ComputerBusyError,
@ -47,6 +48,7 @@ import {
displayBotWorkspacePath, displayBotWorkspacePath,
enqueueTakeoverContinuation, enqueueTakeoverContinuation,
expireComputerControl, expireComputerControl,
filterTeamWorkspaceEntries,
hasActiveComputerControl, hasActiveComputerControl,
isAutoReviewCheckerConfigured, isAutoReviewCheckerConfigured,
isComputerScreenUnavailable, isComputerScreenUnavailable,
@ -118,6 +120,7 @@ import {
IsolationError, IsolationError,
isProjectGoalStatus, isProjectGoalStatus,
issueMessagingLinkCode, issueMessagingLinkCode,
listSpaceUserBotIds,
newestModelCredentialOrder, newestModelCredentialOrder,
newestVoiceCredentialOrder, newestVoiceCredentialOrder,
Prisma, Prisma,
@ -2063,6 +2066,20 @@ export function createRouter(deps: RouterDeps) {
const computerMode = parseComputerMode(computer.scope); const computerMode = parseComputerMode(computer.scope);
const ctx = computerContext(context.actor, bot.id, "files"); const ctx = computerContext(context.actor, bot.id, "files");
const storedPath = resolveBotWorkspacePath(computerMode, bot.id, input.path); 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"]>>; let entries: Awaited<ReturnType<SandboxProvider["listFiles"]>>;
if (computer.state === "running" && computer.providerRef) { if (computer.state === "running" && computer.providerRef) {
await deps.prisma.computer.updateMany({ await deps.prisma.computer.updateMany({
@ -2074,7 +2091,10 @@ export function createRouter(deps: RouterDeps) {
} else { } else {
entries = await deps.home.list(computer.homeKey, storedPath, ctx); 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, ...entry,
path: displayBotWorkspacePath(computerMode, bot.id, input.path, entry.path), 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 computerMode = parseComputerMode(bot.computer.scope);
const ctx = computerContext(context.actor, bot.id, "read"); const ctx = computerContext(context.actor, bot.id, "read");
const storedPath = resolveBotWorkspacePath(computerMode, bot.id, input.path); 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; let content: string;
if (bot.computer.state === "running" && bot.computer.providerRef) { if (bot.computer.state === "running" && bot.computer.providerRef) {
await deps.prisma.computer.updateMany({ await deps.prisma.computer.updateMany({

View File

@ -92,6 +92,7 @@ import {
findModelCredential, findModelCredential,
InvalidSpaceNameError, InvalidSpaceNameError,
isTooManyDatabaseConnections, isTooManyDatabaseConnections,
listSpaceUserBotIds,
loadRunHistoryMessages, loadRunHistoryMessages,
type McpServer, type McpServer,
type Prisma, type Prisma,
@ -227,7 +228,10 @@ import {
} from "./computer-lifecycle.js"; } from "./computer-lifecycle.js";
import { withComputerScreenAvailability } from "./computer-screens.js"; import { withComputerScreenAvailability } from "./computer-screens.js";
import { import {
assertTeamShellCommandAllowed,
assertTeamWorkspacePathAllowed,
displayBotWorkspacePath, displayBotWorkspacePath,
filterTeamWorkspaceEntries,
resolveBotWorkspaceCwd, resolveBotWorkspaceCwd,
resolveBotWorkspacePath, resolveBotWorkspacePath,
teamBotWorkspaceDirectory, teamBotWorkspaceDirectory,
@ -1265,6 +1269,15 @@ export function createRunExecutor(deps: ExecutorDeps) {
if (!bot.computer) throw new Error("Bot has no computer"); if (!bot.computer) throw new Error("Bot has no computer");
const storedComputer = bot.computer; const storedComputer = bot.computer;
const computerMode = parseComputerMode(storedComputer.scope); 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"); const computer = await provisionComputer(deps, storedComputer.id, context, "bot");
screenRelease = { computer, context }; screenRelease = { computer, context };
scheduleComputerSleep(deps.jobs, storedComputer.id); scheduleComputerSleep(deps.jobs, storedComputer.id);
@ -1281,6 +1294,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
context, context,
computer, computer,
computerMode, computerMode,
teamBotIds: teamBotIds ?? undefined,
markWorkspaceDirty: workspaceCheckpoint.markDirty, markWorkspaceDirty: workspaceCheckpoint.markDirty,
}, },
) )
@ -2091,12 +2105,15 @@ export function createRunExecutor(deps: ExecutorDeps) {
const requestedPath = String(args.path ?? ""); const requestedPath = String(args.path ?? "");
const entries = await deps.sandbox.listFiles( const entries = await deps.sandbox.listFiles(
computer, computer,
resolveBotWorkspacePath(computerMode, bot.id, requestedPath), resolveStoredWorkspacePath(requestedPath),
context, context,
); );
const visibleEntries = teamBotIds
? filterTeamWorkspaceEntries(entries, teamBotIds)
: entries;
return { return {
path: requestedPath, path: requestedPath,
entries: entries.map((entry) => ({ entries: visibleEntries.map((entry) => ({
...entry, ...entry,
path: displayBotWorkspacePath(computerMode, bot.id, requestedPath, entry.path), path: displayBotWorkspacePath(computerMode, bot.id, requestedPath, entry.path),
})), })),
@ -2104,7 +2121,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
} }
if (name === "read_file") { if (name === "read_file") {
const filePath = String(args.path ?? ""); const filePath = String(args.path ?? "");
const storedPath = resolveBotWorkspacePath(computerMode, bot.id, filePath); const storedPath = resolveStoredWorkspacePath(filePath);
let bytes: Uint8Array; let bytes: Uint8Array;
try { try {
bytes = await deps.sandbox.readFile(computer, storedPath, context, { bytes = await deps.sandbox.readFile(computer, storedPath, context, {
@ -2148,7 +2165,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
await deps.sandbox.writeFile( await deps.sandbox.writeFile(
computer, computer,
{ {
path: resolveBotWorkspacePath(computerMode, bot.id, filePath), path: resolveStoredWorkspacePath(filePath),
content: new TextEncoder().encode(content), content: new TextEncoder().encode(content),
}, },
context, context,
@ -2173,7 +2190,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
if (!rows && dataPath) { if (!rows && dataPath) {
const bytes = await deps.sandbox.readFile( const bytes = await deps.sandbox.readFile(
computer, computer,
resolveBotWorkspacePath(computerMode, bot.id, dataPath), resolveStoredWorkspacePath(dataPath),
context, context,
{ maxBytes: ATTACHMENT_MAX_BYTES }, { maxBytes: ATTACHMENT_MAX_BYTES },
); );
@ -2195,7 +2212,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
workspaceCheckpoint.markDirty(); workspaceCheckpoint.markDirty();
await deps.sandbox.writeFile( await deps.sandbox.writeFile(
computer, computer,
{ path: resolveBotWorkspacePath(computerMode, bot.id, outPath), content: png }, { path: resolveStoredWorkspacePath(outPath), content: png },
context, context,
); );
let attached = false; let attached = false;
@ -2249,7 +2266,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
if (!deps.artifacts) { if (!deps.artifacts) {
return finish({ error: "artifact storage unavailable", path: filePath }); return finish({ error: "artifact storage unavailable", path: filePath });
} }
const storedPath = resolveBotWorkspacePath(computerMode, bot.id, filePath); const storedPath = resolveStoredWorkspacePath(filePath);
let bytes: Uint8Array; let bytes: Uint8Array;
try { try {
bytes = await deps.sandbox.readFile(computer, storedPath, context, { bytes = await deps.sandbox.readFile(computer, storedPath, context, {
@ -2298,6 +2315,10 @@ export function createRunExecutor(deps: ExecutorDeps) {
bot.id, bot.id,
args.cwd ? String(args.cwd) : undefined, args.cwd ? String(args.cwd) : undefined,
); );
if (teamBotIds) {
if (cwd) assertTeamWorkspacePathAllowed(cwd, teamBotIds);
assertTeamShellCommandAllowed(command, teamBotIds);
}
workspaceCheckpoint.markDirty(); workspaceCheckpoint.markDirty();
const result = await runSandboxCommand( const result = await runSandboxCommand(
deps.sandbox, deps.sandbox,
@ -2335,7 +2356,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
kind: "open", kind: "open",
path: /^https?:\/\//i.test(requestedPath) path: /^https?:\/\//i.test(requestedPath)
? requestedPath ? requestedPath
: resolveBotWorkspacePath(computerMode, bot.id, requestedPath), : resolveStoredWorkspacePath(requestedPath),
}, },
], ],
observe: true, observe: true,
@ -3538,6 +3559,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
context, context,
computer, computer,
computerMode, computerMode,
teamBotIds: teamBotIds ?? undefined,
markWorkspaceDirty: workspaceCheckpoint.markDirty, markWorkspaceDirty: workspaceCheckpoint.markDirty,
}, },
) )
@ -3906,7 +3928,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
await deps.sandbox.writeFile( await deps.sandbox.writeFile(
computer, computer,
{ {
path: resolveBotWorkspacePath(computerMode, bot.id, file.path), path: resolveStoredWorkspacePath(file.path),
content: new TextEncoder().encode(file.content), content: new TextEncoder().encode(file.content),
}, },
context, context,

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 { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { ComposioEmulator, LocalAgentHomeStore, teamBotWorkspaceDirectory } from "@rakazo/adapters"; import { ComposioEmulator, LocalAgentHomeStore, teamBotWorkspaceDirectory } from "@rakazo/adapters";
@ -595,6 +595,105 @@ describeWithDatabase("API authorization and resource isolation", () => {
).not.toBeNull(); ).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("scopes Team Computer export to the exporting member's bot workspace in a shared Space", async () => { 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 owner = await signup(app, `export-owner-${stamp}@rakazo.test`, "Export Owner");
const member = await signup(app, `export-member-${stamp}@rakazo.test`, "Export Member"); 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); expect(response.status, procedure).toBeGreaterThanOrEqual(400);
} }
async function expectForbidden(app: App, cookie: string, procedure: string, body: unknown) { async function expectForbidden(
const response = await raw(app, cookie, procedure, body); 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(response.status, procedure).toBe(403);
expect(await response.text(), procedure).toMatch(/forbidden/i); expect(await response.text(), procedure).toMatch(/forbidden/i);
} }