fix(desktop): deny shell cwd outside bot computer home

Remove hostRoots from this-mac desktop sandbox so shell execution stays
inside the bot computer workspace, matching container path rules.

Add resolveDesktopExecuteCwd to map virtual /home/rakazo paths onto the
local home and reject host-absolute cwd requests via containment checks.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-09-22 03:02:09 +00:00 committed by Ingo Wagner
parent ed7ba490b1
commit a91fc1cb98
5 changed files with 97 additions and 30 deletions

View File

@ -1,8 +1,44 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, expect, it } from "vitest"; import { afterAll, describe, expect, it } from "vitest";
import { isAllowedDesktopPath, normalizeDesktopWorkspacePath } from "./desktop-sandbox-paths.js"; import {
isAllowedDesktopPath,
normalizeDesktopWorkspacePath,
resolveDesktopExecuteCwd,
} from "./desktop-sandbox-paths.js";
describe("desktop sandbox path rules", () => { describe("desktop sandbox path rules", () => {
const home = mkdtempSync(path.join(tmpdir(), "rakazo-desktop-home-"));
afterAll(() => {
rmSync(home, { recursive: true, force: true });
});
it("resolves shell cwd inside the computer home", () => {
expect(resolveDesktopExecuteCwd(undefined, home)).toBe(home);
expect(resolveDesktopExecuteCwd(".", home)).toBe(home);
expect(resolveDesktopExecuteCwd("/home/rakazo", home)).toBe(home);
expect(resolveDesktopExecuteCwd("notes/result.txt", home)).toBe(
path.join(home, "notes/result.txt"),
);
expect(resolveDesktopExecuteCwd("/home/rakazo/notes/result.txt", home)).toBe(
path.join(home, "notes/result.txt"),
);
});
it("denies host-absolute shell cwd outside the computer home", () => {
const outside = path.join(tmpdir(), "outside");
const cwd = resolveDesktopExecuteCwd(outside, home);
expect(isAllowedDesktopPath(cwd, [home])).toBe(false);
expect(isAllowedDesktopPath(resolveDesktopExecuteCwd("/etc", home), [home])).toBe(false);
});
it("rejects traversal in shell cwd requests", () => {
expect(() => resolveDesktopExecuteCwd("../outside", home)).toThrow(/escapes/i);
expect(() => resolveDesktopExecuteCwd("/home/rakazo/../outside", home)).toThrow(/escapes/i);
});
it("compares Windows roots case-insensitively without accepting siblings or other drives", () => { it("compares Windows roots case-insensitively without accepting siblings or other drives", () => {
const roots = ["C:\\Users\\Owner\\Rakazo\\bot"]; const roots = ["C:\\Users\\Owner\\Rakazo\\bot"];

View File

@ -10,6 +10,30 @@ export function normalizeDesktopWorkspacePath(value: string, platform = process.
return normalizeWorkspacePath(value); return normalizeWorkspacePath(value);
} }
/** Map shell cwd requests onto the desktop computer home, matching container rules. */
export function resolveDesktopExecuteCwd(requestCwd: string | undefined, home: string): string {
if (
!requestCwd ||
requestCwd === "." ||
requestCwd === "/" ||
requestCwd === "/home/rakazo" ||
requestCwd === "/home/user"
) {
return home;
}
if (requestCwd.startsWith("/home/rakazo/")) {
return path.join(
home,
normalizeDesktopWorkspacePath(requestCwd.slice("/home/rakazo/".length)),
);
}
if (path.isAbsolute(requestCwd)) {
// Host-absolute paths must not be rebased into the workspace; containment rejects them.
return path.resolve(requestCwd);
}
return path.join(home, normalizeDesktopWorkspacePath(requestCwd));
}
export function isAllowedDesktopPath( export function isAllowedDesktopPath(
target: string, target: string,
roots: string[], roots: string[],

View File

@ -35,7 +35,11 @@ import {
normalizeWorkspacePath, normalizeWorkspacePath,
placeholderObservation, placeholderObservation,
} from "./computer-support.js"; } from "./computer-support.js";
import { isAllowedDesktopPath, normalizeDesktopWorkspacePath } from "./desktop-sandbox-paths.js"; import {
isAllowedDesktopPath,
normalizeDesktopWorkspacePath,
resolveDesktopExecuteCwd,
} from "./desktop-sandbox-paths.js";
import { import {
createExclusiveChildViaDirectoryFdWin32, createExclusiveChildViaDirectoryFdWin32,
mkdirChildViaDirectoryFdWin32, mkdirChildViaDirectoryFdWin32,
@ -61,7 +65,7 @@ interface DesktopBox {
export class DesktopSandboxProvider implements SandboxProvider { export class DesktopSandboxProvider implements SandboxProvider {
readonly boxes = new Map<string, DesktopBox>(); readonly boxes = new Map<string, DesktopBox>();
constructor(private readonly opts: { root?: string; hostRoots?: string[] } = {}) {} constructor(private readonly opts: { root?: string } = {}) {}
describe() { describe() {
return { return {
@ -128,8 +132,8 @@ export class DesktopSandboxProvider implements SandboxProvider {
yield { type: "exit", code: 1 }; yield { type: "exit", code: 1 };
return; return;
} }
const cwd = resolveExecuteCwd(request.cwd, box.home); const cwd = resolveDesktopExecuteCwd(request.cwd, box.home);
if (!isAllowedDesktopPath(cwd, this.allowedRoots(box.home))) { if (!isAllowedDesktopPath(cwd, [box.home])) {
yield { type: "stderr", data: "path is outside this computer's home" }; yield { type: "stderr", data: "path is outside this computer's home" };
yield { type: "exit", code: 1 }; yield { type: "exit", code: 1 };
return; return;
@ -302,9 +306,6 @@ export class DesktopSandboxProvider implements SandboxProvider {
return box; return box;
} }
private allowedRoots(home: string) {
return [home, ...(this.opts.hostRoots ?? [])];
}
} }
async function localWorkspaceTarget(home: string, relative: string, mustExist: boolean) { async function localWorkspaceTarget(home: string, relative: string, mustExist: boolean) {
@ -683,11 +684,6 @@ async function* walkDesktopWorkspace(home: string, directory: string): AsyncIter
} }
} }
function resolveExecuteCwd(requestCwd: string | undefined, home: string) {
if (!requestCwd || requestCwd === "/home/rakazo" || requestCwd === "/home/user") return home;
return path.resolve(home, requestCwd);
}
function runCommand( function runCommand(
argv: string[], argv: string[],
cwd: string, cwd: string,

View File

@ -70,23 +70,26 @@ describe("host-aware sandbox", () => {
expect(sandbox.pageBrowser).toBeUndefined(); expect(sandbox.pageBrowser).toBeUndefined();
}); });
it("lets this-mac cwd run under a host root", async () => { it("refuses shell cwd outside the bot computer home", async () => {
const desktop = new DesktopSandboxProvider({ hostRoots: [hostRoot] }); const desktop = new DesktopSandboxProvider();
const computer = await desktop.provision({ botId: "host", homePath: "/tmp/host-home" }, ctx); const computer = await desktop.provision({ botId: "host", homePath: "/tmp/host-home" }, ctx);
let code = 1; let stderr = "";
let code = 0;
for await (const event of desktop.execute( for await (const event of desktop.execute(
computer, computer,
{ argv: ["echo", "ok"], cwd: hostRoot }, { argv: ["echo", "nope"], cwd: hostRoot },
ctx, ctx,
)) { )) {
if (event.type === "stderr") stderr += event.data;
if (event.type === "exit") code = event.code; if (event.type === "exit") code = event.code;
} }
expect(code).toBe(0); expect(code).toBe(1);
expect(stderr).toMatch(/outside this computer's home/i);
await desktop.destroy(computer, ctx); await desktop.destroy(computer, ctx);
}); });
it("still refuses paths outside home and host roots", async () => { it("still refuses paths outside the bot computer home", async () => {
const desktop = new DesktopSandboxProvider({ hostRoots: [hostRoot] }); const desktop = new DesktopSandboxProvider();
const computer = await desktop.provision({ botId: "deny", homePath: "/tmp/deny" }, ctx); const computer = await desktop.provision({ botId: "deny", homePath: "/tmp/deny" }, ctx);
let stderr = ""; let stderr = "";
let code = 0; let code = 0;
@ -103,6 +106,21 @@ describe("host-aware sandbox", () => {
await desktop.destroy(computer, ctx); await desktop.destroy(computer, ctx);
}); });
it("keeps shell cwd inside the bot computer home", async () => {
const desktop = new DesktopSandboxProvider();
const computer = await desktop.provision({ botId: "inside", homePath: "/tmp/inside" }, ctx);
let code = 1;
for await (const event of desktop.execute(
computer,
{ argv: ["echo", "ok"], cwd: "projects/site" },
ctx,
)) {
if (event.type === "exit") code = event.code;
}
expect(code).toBe(0);
await desktop.destroy(computer, ctx);
});
it("provisions on the host provider when enabled", async () => { it("provisions on the host provider when enabled", async () => {
const isolated = new FakeSandboxProvider(); const isolated = new FakeSandboxProvider();
const host = new DesktopSandboxProvider(); const host = new DesktopSandboxProvider();

View File

@ -1,4 +1,3 @@
import { homedir } from "node:os";
import type { import type {
AdapterContext, AdapterContext,
CommandRequest, CommandRequest,
@ -25,19 +24,13 @@ export function createRunSandbox(
opts: SandboxProviderOptions & { prisma?: PrismaClient }, opts: SandboxProviderOptions & { prisma?: PrismaClient },
): SandboxProvider { ): SandboxProvider {
if (kind === "desktop") { if (kind === "desktop") {
return new DesktopSandboxProvider({ return new DesktopSandboxProvider({ root: opts.dataDir });
root: opts.dataDir,
hostRoots: [homedir()],
});
} }
const primary = createSandboxProvider(kind, opts); const primary = createSandboxProvider(kind, opts);
if (kind !== "docker" || !opts.prisma) return primary; if (kind !== "docker" || !opts.prisma) return primary;
return new HostAwareSandbox( return new HostAwareSandbox(
primary, primary,
new DesktopSandboxProvider({ new DesktopSandboxProvider({ root: opts.dataDir }),
root: opts.dataDir,
hostRoots: [homedir()],
}),
async () => { async () => {
const settings = await opts.prisma!.deploymentSettings.findUnique({ const settings = await opts.prisma!.deploymentSettings.findUnique({
where: { id: "default" }, where: { id: "default" },