evOo-local/packages/adapters/src/desktop-sandbox-write-containment.test.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

333 lines
12 KiB
TypeScript

import type * as NodeFs from "node:fs";
import type * as NodeFsPromises from "node:fs/promises";
import {
link,
mkdir,
mkdtemp,
readFile,
realpath,
rename,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
const lstatRace = vi.hoisted(() => ({
after: undefined as undefined | ((target: string) => Promise<void>),
afterRealpath: undefined as undefined | ((target: string) => Promise<void>),
}));
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof NodeFs>();
return {
...actual,
constants: { ...actual.constants, O_NOFOLLOW: 0 },
};
});
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof NodeFsPromises>();
return {
...actual,
lstat: async (target: string, options?: { bigint?: boolean }) => {
const result = await actual.lstat(target, options as never);
await lstatRace.after?.(target);
return result;
},
realpath: async (target: string) => {
const result = await actual.realpath(target);
await lstatRace.afterRealpath?.(target);
return result;
},
};
});
const { DesktopSandboxProvider } = await import("./desktop-sandbox.js");
const ctx = {
operationId: "operation",
traceId: "trace",
spaceId: "workspace",
userId: "user",
signal: new AbortController().signal,
};
const roots: string[] = [];
afterEach(async () => {
lstatRace.after = undefined;
lstatRace.afterRealpath = undefined;
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
async function fixture(botId: string) {
const root = await realpath(await mkdtemp(path.join(tmpdir(), "rakazo-desktop-containment-")));
roots.push(root);
const desktop = new DesktopSandboxProvider({ root });
const computer = await desktop.provision({ botId, homePath: "/unused" }, ctx);
return { root, desktop, computer };
}
async function pathExists(target: string) {
try {
await realpath(target);
return true;
} catch {
return false;
}
}
describe("desktop sandbox write containment without O_NOFOLLOW", () => {
it("rejects an existing final symlink without changing its outside target", async () => {
const { root, desktop, computer } = await fixture("static-link");
const outside = path.join(root, "outside.txt");
await writeFile(outside, "before");
await symlink(outside, path.join(computer.providerRef, "escape.txt"));
await expect(
desktop.writeFile(computer, {
path: "escape.txt",
content: new TextEncoder().encode("after"),
}),
).rejects.toThrow();
expect(await readFile(outside, "utf8")).toBe("before");
});
it("keeps writes contained when the final name is replaced after lstat", async () => {
const { root, desktop, computer } = await fixture("swap-link");
const target = path.join(computer.providerRef, "result.txt");
const displaced = path.join(computer.providerRef, "result-original.txt");
const outside = path.join(root, "outside.txt");
await writeFile(target, "inside-before");
await writeFile(outside, "outside-before");
let swapped = false;
lstatRace.after = async (inspected) => {
if (swapped || path.basename(inspected) !== "result.txt") return;
swapped = true;
await rename(target, displaced);
await symlink(outside, target);
};
let rejected = false;
try {
await desktop.writeFile(computer, {
path: "result.txt",
content: new TextEncoder().encode("after"),
});
} catch (error) {
expect(error).toHaveProperty("message", "Path escapes the computer workspace");
rejected = true;
}
expect(swapped).toBe(true);
expect(await readFile(outside, "utf8")).toBe("outside-before");
expect(await readFile(displaced, "utf8")).toBe(rejected ? "inside-before" : "after");
});
it("rejects a parent symlink that resolves outside the workspace", async () => {
const { root, desktop, computer } = await fixture("parent-link");
const outside = path.join(root, "outside-directory");
await mkdir(outside);
await symlink(outside, path.join(computer.providerRef, "escape-directory"), "junction");
await expect(
desktop.writeFile(computer, {
path: "escape-directory/outside.txt",
content: new TextEncoder().encode("after"),
}),
).rejects.toThrow();
await expect(readFile(path.join(outside, "outside.txt"), "utf8")).rejects.toMatchObject({
code: "ENOENT",
});
});
it("does not create outside directories through a parent symlink", async () => {
const { root, desktop, computer } = await fixture("parent-link-nested");
const outside = path.join(root, "outside-directory");
await mkdir(outside);
await symlink(outside, path.join(computer.providerRef, "escape-directory"), "junction");
await expect(
desktop.writeFile(computer, {
path: "escape-directory/nested/outside.txt",
content: new TextEncoder().encode("after"),
}),
).rejects.toThrow();
await expect(readFile(path.join(outside, "nested/outside.txt"), "utf8")).rejects.toMatchObject({
code: "ENOENT",
});
await expect(readFile(path.join(outside, "nested"), "utf8")).rejects.toMatchObject({
code: "ENOENT",
});
});
it("does not write outside when a validated parent is replaced before open", async () => {
const { root, desktop, computer } = await fixture("parent-swap");
const parent = path.join(computer.providerRef, "notes");
const displaced = path.join(computer.providerRef, "notes-original");
const outside = path.join(root, "outside-directory");
await mkdir(parent);
await mkdir(outside);
await writeFile(path.join(parent, "result.txt"), "inside-before");
await writeFile(path.join(outside, "result.txt"), "outside-before");
let swapped = false;
lstatRace.afterRealpath = async (inspected) => {
if (swapped || path.basename(inspected) !== "notes") return;
swapped = true;
await rename(parent, displaced);
await symlink(outside, parent, "junction");
};
await expect(
desktop.writeFile(computer, {
path: "notes/result.txt",
content: new TextEncoder().encode("after"),
}),
).rejects.toThrow();
expect(swapped).toBe(true);
expect(await readFile(path.join(outside, "result.txt"), "utf8")).toBe("outside-before");
expect(await readFile(path.join(displaced, "result.txt"), "utf8")).toBe("inside-before");
});
it("does not leave an outside file when exclusive create races through a swapped parent", async () => {
const { root, desktop, computer } = await fixture("parent-swap-create");
const parent = path.join(computer.providerRef, "notes");
const displaced = path.join(computer.providerRef, "notes-original");
const outside = path.join(root, "outside-directory");
await mkdir(parent);
await mkdir(outside);
let swapped = false;
lstatRace.afterRealpath = async (inspected) => {
if (swapped || path.basename(inspected) !== "notes") return;
swapped = true;
await rename(parent, displaced);
await symlink(outside, parent, "junction");
};
await expect(
desktop.writeFile(computer, {
path: "notes/result.txt",
content: new TextEncoder().encode("after"),
}),
).rejects.toThrow();
expect(swapped).toBe(true);
await expect(readFile(path.join(outside, "result.txt"), "utf8")).rejects.toMatchObject({
code: "ENOENT",
});
});
it("does not create outside directories when a parent is swapped during mkdir", async () => {
const { root, desktop, computer } = await fixture("mkdir-swap");
const parent = path.join(computer.providerRef, "notes");
const displaced = path.join(computer.providerRef, "notes-original");
const outside = path.join(root, "outside-directory");
await mkdir(parent);
await mkdir(outside);
let swapped = false;
lstatRace.afterRealpath = async (inspected) => {
if (swapped || path.basename(inspected) !== "notes") return;
swapped = true;
await rename(parent, displaced);
await symlink(outside, parent, "junction");
};
await expect(
desktop.writeFile(computer, {
path: "notes/nested/result.txt",
content: new TextEncoder().encode("after"),
}),
).rejects.toThrow();
expect(swapped).toBe(true);
await expect(readFile(path.join(outside, "nested/result.txt"), "utf8")).rejects.toMatchObject({
code: "ENOENT",
});
await expect(readFile(path.join(outside, "nested"), "utf8")).rejects.toMatchObject({
code: "ENOENT",
});
});
it("rejects when path containment and the opened inode diverge after a parent swap", async () => {
const { root, desktop, computer } = await fixture("inode-diverge");
const parent = path.join(computer.providerRef, "notes");
const displaced = path.join(computer.providerRef, "notes-original");
const outside = path.join(root, "outside-directory");
await mkdir(parent);
await mkdir(outside);
await writeFile(path.join(parent, "result.txt"), "inside-before");
await writeFile(path.join(outside, "result.txt"), "outside-before");
// Force pathname opens so containment must bind directory/file inodes (Windows path).
const platform = Object.getOwnPropertyDescriptor(process, "platform");
Object.defineProperty(process, "platform", { configurable: true, value: "win32" });
try {
let notesRealpaths = 0;
lstatRace.afterRealpath = async (inspected) => {
if (path.basename(inspected) !== "notes") return;
notesRealpaths += 1;
// After openContainedWorkspaceFile's parent realpath, point the pathname at an
// outside directory. Without binding the opened parent inode to a fresh
// contained realpath, later checks could validate a restored inside path
// while the handle still referred outside.
if (notesRealpaths !== 2) return;
await rename(parent, displaced);
await symlink(outside, parent, "junction");
};
lstatRace.after = async (inspected) => {
if (path.basename(inspected) !== "result.txt") return;
// If a child open happened, restore an inside pathname so realpath-only
// containment would incorrectly pass without an inode bind.
if (!(await pathExists(parent))) return;
await rm(parent, { force: true });
if (await pathExists(displaced)) await rename(displaced, parent);
};
await expect(
desktop.writeFile(computer, {
path: "notes/result.txt",
content: new TextEncoder().encode("after"),
}),
).rejects.toThrow();
expect(notesRealpaths).toBeGreaterThanOrEqual(2);
expect(await readFile(path.join(outside, "result.txt"), "utf8")).toBe("outside-before");
// Inside content lives under the displaced original parent after the race.
expect(await readFile(path.join(displaced, "result.txt"), "utf8")).toBe("inside-before");
} finally {
if (platform) Object.defineProperty(process, "platform", platform);
else Object.defineProperty(process, "platform", { configurable: true, value: "linux" });
}
});
it("rejects a hard link whose other name is outside the workspace", async () => {
const { root, desktop, computer } = await fixture("hard-link");
const outside = path.join(root, "outside.txt");
await writeFile(outside, "outside-before");
await link(outside, path.join(computer.providerRef, "escape.txt"));
await expect(
desktop.writeFile(computer, {
path: "escape.txt",
content: new TextEncoder().encode("after"),
}),
).rejects.toThrow();
expect(await readFile(outside, "utf8")).toBe("outside-before");
});
it("still creates and replaces ordinary workspace files", async () => {
const { desktop, computer } = await fixture("ordinary-files");
await desktop.writeFile(computer, {
path: "notes/result.txt",
content: new TextEncoder().encode("first"),
});
await desktop.writeFile(computer, {
path: "notes/result.txt",
content: new TextEncoder().encode("second"),
});
expect(await readFile(path.join(computer.providerRef, "notes/result.txt"), "utf8")).toBe(
"second",
);
});
});