Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/design/artifact-harness-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ worker must be regular files that the service UID cannot own or write. One
worker copy is staged per process and removed on exit, SIGINT, or SIGTERM.
Each `sudo` child has a 120-second timeout, and worker `readFile` accepts only
regular files.
The source-staging module prepares versioned, bounded byte snapshots for future
install support. It rejects links and special files, validates the complete
entry hierarchy and content hash, and carries fixed private modes rather than
source ownership or source paths. Source ancestors must be trusted and stable;
this module does not provide target confinement or enable writes. Target write,
command, recovery and ownership-migration support remain unavailable.

For a dedicated operator and service account, grant only the UID check and the
read worker invocation. Replace the example account, home, UID/GID, and Node
Expand Down
131 changes: 131 additions & 0 deletions src/transport/run-as-staging.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { createHash } from "node:crypto";
import { constants } from "node:fs";
import { lstat, open, readdir } from "node:fs/promises";
import { join, posix } from "node:path";
import { z } from "zod";
import { isIgnoredGeneratedEntry } from "../utils/fs.js";

// The service-side worker receives bytes, never caller-side source paths.
export const runAsStagingLimits = { bytes: 8 * 1024 * 1024, entries: 4096, depth: 64 } as const;
const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u);
const entrySchema = z.discriminatedUnion("kind", [
z.object({ path: z.string(), kind: z.literal("file"), mode: z.literal(0o600), content: z.string() }).strict(),
z.object({ path: z.string(), kind: z.literal("dir"), mode: z.literal(0o700) }).strict(),
]);
const payloadSchema = z.object({
version: z.literal(1), kind: z.enum(["file", "dir"]), hash: digestSchema,
entries: z.array(entrySchema).min(1).max(runAsStagingLimits.entries),
}).strict();
export type RunAsCopyPayload = z.infer<typeof payloadSchema>;

function assertEntryPath(path: string): void {
if (path === "") return;
if (path.includes("\0") || path.includes("\\") || posix.isAbsolute(path)
|| path.split("/").length > runAsStagingLimits.depth
|| path.split("/").some((part) => !part || part === "." || part === ".." || isIgnoredGeneratedEntry(part))) {
throw new Error("Invalid run-as staged entry path.");
}
}

function payloadHash(payload: RunAsCopyPayload): string {
const fileHash = (content: string) => createHash("sha256").update("file\0").update(Buffer.from(content, "base64")).digest("hex");
if (payload.kind === "file") {
const root = payload.entries[0]!;
if (root.kind !== "file") throw new Error("Run-as staged root kind mismatch.");
return fileHash(root.content);
}
const hash = createHash("sha256").update("dir\0");
// Match hashPath's sorted depth-first file traversal, including nested entries.
const visit = (directory: string): void => {
const children = payload.entries.filter((entry) => entry.path !== "" && posix.dirname(entry.path) === (directory || "."))
.sort((left, right) => posix.basename(left.path).localeCompare(posix.basename(right.path)));
for (const entry of children) {
if (entry.kind === "dir") visit(entry.path);
else hash.update(entry.path).update("\0").update(fileHash(entry.content)).update("\0");
}
};
visit("");
return hash.digest("hex");
}

/** Validate the complete payload before a future confined worker makes any target write. */
export function validateRunAsCopyPayload(value: unknown): RunAsCopyPayload {
// Bound encoded bytes as well as decoded content before schema parsing and hashing.
if (Buffer.byteLength(JSON.stringify(value) ?? "") > runAsStagingLimits.bytes * 2) {
throw new Error("Run-as staged payload exceeds its size limit.");
}
const payload = payloadSchema.parse(value);
const entries = new Map<string, RunAsCopyPayload["entries"][number]>();
let bytes = 0;
for (const entry of payload.entries) {
assertEntryPath(entry.path);
if (entries.has(entry.path)) throw new Error("Duplicate run-as staged entry.");
entries.set(entry.path, entry);
if (entry.kind === "file") {
const content = Buffer.from(entry.content, "base64");
if (content.toString("base64") !== entry.content) throw new Error("Invalid run-as staged base64 content.");
bytes += content.length;
if (bytes > runAsStagingLimits.bytes) throw new Error("Run-as staged bytes exceed their size limit.");
}
}
if (entries.get("")?.kind !== payload.kind || (payload.kind === "file" && entries.size !== 1)) {
throw new Error("Run-as staged root kind mismatch.");
}
for (const path of entries.keys()) {
if (path !== "" && entries.get(posix.dirname(path) === "." ? "" : posix.dirname(path))?.kind !== "dir") {
throw new Error("Run-as staged entry has no parent directory.");
}
}
if (payloadHash(payload) !== payload.hash) throw new Error("Run-as staged content hash mismatch.");
return payload;
}

/**
* Capture a bounded source snapshot as the caller, with no inherited modes or ownership.
* Source ancestors belong to the trusted caller staging area and must remain stable.
* This is byte validation, not a filesystem confinement primitive for target writes.
*/
export async function stageRunAsCopy(source: string, kind: "file" | "dir", expectedHash: string): Promise<RunAsCopyPayload> {
digestSchema.parse(expectedHash);
const entries: RunAsCopyPayload["entries"] = [];
let bytes = 0;
const visit = async (path: string, relative: string): Promise<void> => {
assertEntryPath(relative);
if (entries.length >= runAsStagingLimits.entries) throw new Error("Too many run-as staged entries.");
const before = await lstat(path);
if (before.isSymbolicLink() || (!before.isFile() && !before.isDirectory()) || (before.isFile() && before.nlink !== 1)) {
throw new Error("Run-as staging refuses links and special files.");
}
if (before.isDirectory()) {
entries.push({ path: relative, kind: "dir", mode: 0o700 });
for (const name of (await readdir(path)).sort((a, b) => a.localeCompare(b))) {
if (!isIgnoredGeneratedEntry(name)) await visit(join(path, name), relative ? `${relative}/${name}` : name);
}
} else {
// O_NOFOLLOW rejects replacement of the final component by a symlink.
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
try {
const opened = await file.stat();
if (!opened.isFile() || opened.nlink !== 1 || opened.dev !== before.dev || opened.ino !== before.ino) {
throw new Error("Run-as staging source changed while opening.");
}
if (opened.size > runAsStagingLimits.bytes - bytes) throw new Error("Run-as staged bytes exceed their size limit.");
// A bounded read also handles files that grow after fstat.
const buffer = Buffer.alloc(runAsStagingLimits.bytes - bytes + 1);
let length = 0;
while (length < buffer.length) {
const read = await file.read(buffer, length, buffer.length - length, null);
if (!read.bytesRead) break;
length += read.bytesRead;
}
bytes += length;
if (bytes > runAsStagingLimits.bytes) throw new Error("Run-as staged bytes exceed their size limit.");
entries.push({ path: relative, kind: "file", mode: 0o600, content: buffer.subarray(0, length).toString("base64") });
} finally {
await file.close();
}
}
};
await visit(source, "");
return validateRunAsCopyPayload({ version: 1, kind, hash: expectedHash, entries });
}
92 changes: 92 additions & 0 deletions test/run-as-staging.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { execFileSync } from "node:child_process";
import { link, mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { stageRunAsCopy, validateRunAsCopyPayload } from "../src/transport/run-as-staging.js";
import { hashPath } from "../src/utils/fs.js";

const roots: string[] = [];
afterEach(async () => { for (const root of roots) await rm(root, { recursive: true, force: true }); roots.length = 0; });
async function fixture() { const root = await mkdtemp(join(tmpdir(), "aw-staging-")); roots.push(root); return root; }

describe("run-as source staging", () => {
it("captures binary files, empty directories and deterministic hashes without source paths or modes", async () => {
const root = await fixture();
await mkdir(join(root, "a"));
await mkdir(join(root, "empty"));
await writeFile(join(root, "a", "z"), Buffer.from([0, 255, 13, 10]), { mode: 0o755 });
await writeFile(join(root, "a.txt"), "text");
const payload = await stageRunAsCopy(root, "dir", await hashPath(root));
expect(validateRunAsCopyPayload(JSON.parse(JSON.stringify(payload)))).toEqual(payload);
expect(JSON.stringify(payload)).not.toContain(root);
expect(payload.entries.find((entry) => entry.path === "a/z")).toEqual({ path: "a/z", kind: "file", mode: 0o600, content: "AP8NCg==" });
expect(payload.entries).toContainEqual({ path: "empty", kind: "dir", mode: 0o700 });
});

it("refuses stale source hashes, hardlinks, symlinks and wrong root kinds", async () => {
const root = await fixture();
const source = join(root, "source");
await writeFile(source, "old");
const hash = await hashPath(source);
await writeFile(source, "new");
await expect(stageRunAsCopy(source, "file", hash)).rejects.toThrow(/hash mismatch/);
await expect(stageRunAsCopy(source, "dir", await hashPath(source))).rejects.toThrow(/kind mismatch/);
await symlink(source, join(root, "symlink"));
await expect(stageRunAsCopy(join(root, "symlink"), "file", hash)).rejects.toThrow(/links/);
await link(source, join(root, "hardlink"));
await expect(stageRunAsCopy(source, "file", hash)).rejects.toThrow(/links/);
});

it("validates untrusted payload paths, hierarchy, bytes, modes and duplicate entries before writes", async () => {
const root = await fixture();
await writeFile(join(root, "file"), "data");
const payload = await stageRunAsCopy(root, "dir", await hashPath(root));
for (const path of ["../escape", "/absolute", "x\\y", "x/./y", "x//y", "x\0y", "missing/file"]) {
const changed = structuredClone(payload);
changed.entries[1]!.path = path;
expect(() => validateRunAsCopyPayload(changed)).toThrow();
}
expect(() => validateRunAsCopyPayload({ ...payload, source: "/caller/private" })).toThrow();
expect(() => validateRunAsCopyPayload({ ...payload, entries: [...payload.entries, payload.entries[1]] })).toThrow(/Duplicate/);
const changed = structuredClone(payload);
const file = changed.entries[1]!;
if (file.kind !== "file") throw new Error("fixture");
file.content = "ZGF0YQ==!";
expect(() => validateRunAsCopyPayload(changed)).toThrow(/base64/);
file.content = Buffer.from("tampered").toString("base64");
expect(() => validateRunAsCopyPayload(changed)).toThrow(/hash mismatch/);
expect(() => validateRunAsCopyPayload({ ...payload, entries: [{ ...payload.entries[0], mode: 0o777 }] })).toThrow();
});

it("enforces cumulative bytes, entry count, depth and generated-entry filtering", async () => {
const root = await fixture();
await writeFile(join(root, "a"), Buffer.alloc(4 * 1024 * 1024 + 1));
await writeFile(join(root, "b"), Buffer.alloc(4 * 1024 * 1024));
await expect(stageRunAsCopy(root, "dir", "a".repeat(64))).rejects.toThrow(/size limit/);
await rm(join(root, "a"));
await rm(join(root, "b"));
const deep = "a/".repeat(64) + "file";
const empty = await stageRunAsCopy(root, "dir", await hashPath(root));
expect(() => validateRunAsCopyPayload({ ...empty, entries: [...empty.entries, { path: deep, kind: "file", mode: 0o600, content: "" }] })).toThrow(/entry path/);
expect(() => validateRunAsCopyPayload({ ...empty, entries: Array.from({ length: 4097 }, () => empty.entries[0]) })).toThrow();
await mkdir(join(root, "node_modules"));
await symlink("/outside", join(root, "node_modules", "ignored"));
await writeFile(join(root, "ignored.pyc"), "ignored");
expect((await stageRunAsCopy(root, "dir", await hashPath(root))).entries).toEqual(empty.entries);
});

it("rejects special files without opening them", async () => {
const root = await fixture();
const fifo = join(root, "fifo");
execFileSync("mkfifo", [fifo]);
await expect(stageRunAsCopy(fifo, "file", "a".repeat(64))).rejects.toThrow(/special files/);
});

it("rejects an oversized file before reading it", async () => {
const root = await fixture();
const source = join(root, "large");
await writeFile(source, Buffer.alloc(8 * 1024 * 1024 + 1));
await expect(stageRunAsCopy(source, "file", "a".repeat(64))).rejects.toThrow(/size limit/);
});
});
Loading