Files
bot-agents/src/isolation.test.ts
T

113 lines
3.9 KiB
TypeScript

import { spawnSync } from "node:child_process";
import type { SpawnSyncReturns } from "node:child_process";
import { expect, test } from "vite-plus/test";
import { chmod, chown, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
import process from "node:process";
import { isNotFound } from "./errors.ts";
import { isRunnableProfileProcess, killProfileProcesses, readProfileFile } from "./isolation.ts";
import type { RuntimeProfile } from "./runtime.ts";
import { currentUid, IS_ROOT, IS_WINDOWS, makeTempDir } from "#testing";
const profile = (uid: number, home: string): RuntimeProfile => ({
index: uid - 20_000,
type: "claude",
model: "test",
uid,
gid: uid,
home,
tokenFile: null,
});
// Runs a command as the profile's user from the root directory. Without `env`
// it inherits this process's environment.
function runAs(
selected: RuntimeProfile,
command: string,
args: string[],
env?: Record<string, string>,
): SpawnSyncReturns<string> {
return spawnSync(command, args, {
cwd: "/",
uid: selected.uid,
gid: selected.gid,
encoding: "utf8",
...(env !== undefined && { env }),
});
}
test("zombie process entries are already quiescent", () => {
const status = "State:\tZ (zombie)\nUid:\t20010\t20010\t20010\t20010\n";
expect(isRunnableProfileProcess(status, 20_010)).toBe(false);
});
test.skipIf(IS_WINDOWS)("refuses to read a profile result symlink as root", async () => {
const root = await makeTempDir();
try {
const secret = `${root}/reviewer-token`;
const result = `${root}/result.md`;
await writeFile(secret, "reviewer-secret", { mode: 0o600 });
await symlink(secret, result);
await expect(() => readProfileFile(profile(currentUid(), root), result)).rejects.toThrow(
"regular private file",
);
} finally {
await rm(root, { recursive: true });
}
});
test.skipIf(!IS_ROOT)("a profile process can read only its own private files", async () => {
const root = await makeTempDir();
await chmod(root, 0o711);
try {
const homeA = `${root}/a`;
const homeB = `${root}/b`;
await mkdir(homeA, { mode: 0o700 });
await mkdir(homeB, { mode: 0o700 });
await chown(homeA, 20_000, 20_000);
await chown(homeB, 20_001, 20_001);
await writeFile(`${homeA}/token`, "own", { mode: 0o600 });
await chown(`${homeA}/token`, 20_000, 20_000);
await writeFile(`${homeB}/token`, "sibling", { mode: 0o600 });
await chown(`${homeB}/token`, 20_001, 20_001);
await writeFile(`${root}/reviewer`, "root", { mode: 0o600 });
const selected = profile(20_000, homeA);
const own = runAs(selected, "/bin/cat", [`${homeA}/token`]);
const sibling = runAs(selected, "/bin/cat", [`${homeB}/token`]);
const reviewer = runAs(selected, "/bin/cat", [`${root}/reviewer`]);
expect(own.stdout).toBe("own");
expect(own.status).toBe(0);
expect(sibling.status).not.toBe(0);
expect(reviewer.status).not.toBe(0);
} finally {
await rm(root, { recursive: true });
}
});
test.skipIf(!IS_ROOT)("cleanup leaves no runnable daemonized process", async () => {
const selected = profile(20_010, "/tmp");
const launched = runAs(selected, "/bin/sh", ["-c", "sleep 60 >/dev/null 2>&1 & echo $!"]);
const pid = Number(launched.stdout.trim());
expect(Number.isInteger(pid)).toBe(true);
await killProfileProcesses(selected.uid);
const status = await readFile(`/proc/${pid}/status`, "utf8").catch((error: unknown) => {
if (isNotFound(error)) return null;
throw error;
});
expect(status === null || !isRunnableProfileProcess(status, selected.uid)).toBe(true);
});
test.skipIf(!IS_ROOT)("the agent process cannot read its coordinator environment", () => {
const selected = profile(20_020, "/tmp");
const result = runAs(selected, "/usr/bin/cat", [`/proc/${process.pid}/environ`], {
PARENT_SECRET: "must-not-cross-boundary",
});
expect(result.status).not.toBe(0);
});