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 { describe, expect, it } from "vitest";
import { isAllowedDesktopPath, normalizeDesktopWorkspacePath } from "./desktop-sandbox-paths.js";
import { afterAll, describe, expect, it } from "vitest";
import {
isAllowedDesktopPath,
normalizeDesktopWorkspacePath,
resolveDesktopExecuteCwd,
} from "./desktop-sandbox-paths.js";
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", () => {
const roots = ["C:\\Users\\Owner\\Rakazo\\bot"];

View File

@ -10,6 +10,30 @@ export function normalizeDesktopWorkspacePath(value: string, platform = process.
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(
target: string,
roots: string[],

View File

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

View File

@ -70,23 +70,26 @@ describe("host-aware sandbox", () => {
expect(sandbox.pageBrowser).toBeUndefined();
});
it("lets this-mac cwd run under a host root", async () => {
const desktop = new DesktopSandboxProvider({ hostRoots: [hostRoot] });
it("refuses shell cwd outside the bot computer home", async () => {
const desktop = new DesktopSandboxProvider();
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(
computer,
{ argv: ["echo", "ok"], cwd: hostRoot },
{ argv: ["echo", "nope"], cwd: hostRoot },
ctx,
)) {
if (event.type === "stderr") stderr += event.data;
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);
});
it("still refuses paths outside home and host roots", async () => {
const desktop = new DesktopSandboxProvider({ hostRoots: [hostRoot] });
it("still refuses paths outside the bot computer home", async () => {
const desktop = new DesktopSandboxProvider();
const computer = await desktop.provision({ botId: "deny", homePath: "/tmp/deny" }, ctx);
let stderr = "";
let code = 0;
@ -103,6 +106,21 @@ describe("host-aware sandbox", () => {
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 () => {
const isolated = new FakeSandboxProvider();
const host = new DesktopSandboxProvider();

View File

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