From fdecfbd872369983589d129886e5b248632e10a2 Mon Sep 17 00:00:00 2001 From: Yehonal <147092+Yehonal@users.noreply.github.com> Date: Tue, 20 Oct 2026 05:59:56 +0000 Subject: [PATCH] Prepare bounded byte snapshots for service-user installs --- docs/design/artifact-harness-compatibility.md | 6 + src/transport/run-as-staging.ts | 131 ++++++++++++++++++ test/run-as-staging.test.ts | 92 ++++++++++++ 3 files changed, 229 insertions(+) create mode 100644 src/transport/run-as-staging.ts create mode 100644 test/run-as-staging.test.ts diff --git a/docs/design/artifact-harness-compatibility.md b/docs/design/artifact-harness-compatibility.md index ec598ae..c9b85b2 100644 --- a/docs/design/artifact-harness-compatibility.md +++ b/docs/design/artifact-harness-compatibility.md @@ -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 diff --git a/src/transport/run-as-staging.ts b/src/transport/run-as-staging.ts new file mode 100644 index 0000000..a0b5bcd --- /dev/null +++ b/src/transport/run-as-staging.ts @@ -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; + +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(); + 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 { + digestSchema.parse(expectedHash); + const entries: RunAsCopyPayload["entries"] = []; + let bytes = 0; + const visit = async (path: string, relative: string): Promise => { + 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 }); +} diff --git a/test/run-as-staging.test.ts b/test/run-as-staging.test.ts new file mode 100644 index 0000000..f971d65 --- /dev/null +++ b/test/run-as-staging.test.ts @@ -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/); + }); +});