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, 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,
@ -121,6 +123,7 @@ import {
IsolationError, IsolationError,
isProjectGoalStatus, isProjectGoalStatus,
issueMessagingLinkCode, issueMessagingLinkCode,
listSpaceUserBotIds,
lockOwnedGroup, lockOwnedGroup,
newestModelCredentialOrder, newestModelCredentialOrder,
newestVoiceCredentialOrder, newestVoiceCredentialOrder,
@ -2140,6 +2143,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({
@ -2151,7 +2168,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),
})); }));
@ -2162,6 +2182,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

@ -1,9 +1,13 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
assertTeamShellCommandAllowed,
assertTeamWorkspacePathAllowed,
displayBotWorkspacePath, displayBotWorkspacePath,
filterTeamWorkspaceEntries,
resolveBotWorkspaceCwd, resolveBotWorkspaceCwd,
resolveBotWorkspacePath, resolveBotWorkspacePath,
teamBotWorkspaceDirectory, teamBotWorkspaceDirectory,
teamWorkspaceTargetBotId,
} from "./computer-support.js"; } from "./computer-support.js";
describe("Team Computer bot folders", () => { describe("Team Computer bot folders", () => {
@ -43,6 +47,34 @@ describe("Team Computer bot folders", () => {
).toBe("bots/bot-1/notes/result.txt"); ).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", () => { it("leaves Private Computer paths unchanged", () => {
expect(resolveBotWorkspacePath("dedicated", "bot-1", "notes/result.txt")).toBe( expect(resolveBotWorkspacePath("dedicated", "bot-1", "notes/result.txt")).toBe(
"notes/result.txt", "notes/result.txt",

View File

@ -80,6 +80,49 @@ export function teamBotWorkspaceDirectory(botId: string): string {
return path.posix.join("bots", normalizeWorkspacePath(botId)); 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( export function resolveBotWorkspacePath(
scope: ComputerMode, scope: ComputerMode,
botId: string, botId: string,

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,
@ -1268,6 +1272,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);
@ -1284,6 +1297,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
context, context,
computer, computer,
computerMode, computerMode,
teamBotIds: teamBotIds ?? undefined,
markWorkspaceDirty: workspaceCheckpoint.markDirty, markWorkspaceDirty: workspaceCheckpoint.markDirty,
}, },
) )
@ -2094,12 +2108,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),
})), })),
@ -2107,7 +2124,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, {
@ -2151,7 +2168,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,
@ -2176,7 +2193,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 },
); );
@ -2198,7 +2215,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;
@ -2252,7 +2269,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, {
@ -2301,6 +2318,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,
@ -2338,7 +2359,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,
@ -3541,6 +3562,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
context, context,
computer, computer,
computerMode, computerMode,
teamBotIds: teamBotIds ?? undefined,
markWorkspaceDirty: workspaceCheckpoint.markDirty, markWorkspaceDirty: workspaceCheckpoint.markDirty,
}, },
) )
@ -3909,7 +3931,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

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

View File

@ -21,8 +21,21 @@ export function computerHomeKey(mode: ComputerMode, spaceId: string, botId?: str
} }
type ComputerDb = Pick<PrismaClient, "computer">; type ComputerDb = Pick<PrismaClient, "computer">;
type BotDb = Pick<PrismaClient, "bot">;
type ExecutionLeaseDb = Pick<PrismaClient, "computerExecutionLease">; 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. */ /** Expire leases as fencing tombstones so the next acquire increments fence. */
export async function expireComputerExecutionLeases( export async function expireComputerExecutionLeases(
prisma: ExecutionLeaseDb, 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 { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { ComposioEmulator } from "@rakazo/adapters"; 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(); 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 () => { 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 owner = await signup(app, `approval-owner-${stamp}@rakazo.test`, "Approval Owner");
const member = await signup(app, `approval-member-${stamp}@rakazo.test`, "Approval Member"); 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); 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);
} }