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:
parent
ed7ba490b1
commit
a91fc1cb98
@ -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"];
|
||||||
|
|
||||||
|
|||||||
@ -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[],
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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" },
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user