diff --git a/packages/adapters/src/desktop-sandbox-paths.test.ts b/packages/adapters/src/desktop-sandbox-paths.test.ts index 9b04a62..bfa7cb6 100644 --- a/packages/adapters/src/desktop-sandbox-paths.test.ts +++ b/packages/adapters/src/desktop-sandbox-paths.test.ts @@ -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"]; diff --git a/packages/adapters/src/desktop-sandbox-paths.ts b/packages/adapters/src/desktop-sandbox-paths.ts index 7ff0043..d999857 100644 --- a/packages/adapters/src/desktop-sandbox-paths.ts +++ b/packages/adapters/src/desktop-sandbox-paths.ts @@ -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[], diff --git a/packages/adapters/src/desktop-sandbox.ts b/packages/adapters/src/desktop-sandbox.ts index 6f19664..dc1d781 100644 --- a/packages/adapters/src/desktop-sandbox.ts +++ b/packages/adapters/src/desktop-sandbox.ts @@ -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(); - 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, diff --git a/packages/adapters/src/host-aware-sandbox.test.ts b/packages/adapters/src/host-aware-sandbox.test.ts index faada5a..9b06012 100644 --- a/packages/adapters/src/host-aware-sandbox.test.ts +++ b/packages/adapters/src/host-aware-sandbox.test.ts @@ -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(); diff --git a/packages/adapters/src/host-aware-sandbox.ts b/packages/adapters/src/host-aware-sandbox.ts index 943a32c..08574e1 100644 --- a/packages/adapters/src/host-aware-sandbox.ts +++ b/packages/adapters/src/host-aware-sandbox.ts @@ -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" },