From 836e352141d2bf89e3b6f68c7cfc49e7e1b18b9b Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Fri, 11 Sep 2026 09:05:08 -0600 Subject: [PATCH] fix(release): restore runner ownership in offline builds --- docs/pylon-publication.md | 2 + scripts/build-pylon-prime-agent-release.mjs | 2 + scripts/lib/pylon-release-identity.mjs | 29 ++++++++++ scripts/pylon-prime-agent-release.test.mjs | 61 +++++++++++++++++++++ 4 files changed, 94 insertions(+) create mode 100644 scripts/lib/pylon-release-identity.mjs diff --git a/docs/pylon-publication.md b/docs/pylon-publication.md index 553ba74c478..07af3679058 100644 --- a/docs/pylon-publication.md +++ b/docs/pylon-publication.md @@ -69,6 +69,8 @@ A missing protected-environment variable, omitted secret, unavailable installati `.github/workflows/pylon-preview-release.yml` runs only for an exact canonical push to `refs/heads/pylon`. It uses Node `22.23.2` and npm `11.10.1`, packs twice with build networking disabled, compares all subjects byte for byte, and installs the first pack on Ubuntu Linux and macOS. Ubuntu is the supported gate for Linux and WSL2; native Windows publication support is deferred. +The offline build enters its network namespace through `sudo unshare`. Before writing its receipt or spawning build commands, the release entrypoint restores the invoking non-root `SUDO_UID` and `SUDO_GID`, clearing supplementary groups first. It verifies the resulting real and effective identities; malformed sudo identities or failed credential changes stop the build. The build and its children keep the isolated network namespace, while output directories and artifacts belong to the runner that prepares the preview manifest afterward. Ordinary non-sudo builds retain their caller identity. + The preview identity is: ```text diff --git a/scripts/build-pylon-prime-agent-release.mjs b/scripts/build-pylon-prime-agent-release.mjs index 58867c00962..e447b5e14be 100644 --- a/scripts/build-pylon-prime-agent-release.mjs +++ b/scripts/build-pylon-prime-agent-release.mjs @@ -3,6 +3,7 @@ import { rmSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { restoreSudoBuildIdentity } from "./lib/pylon-release-identity.mjs"; import { assertCleanSource, assertPinnedToolchain, @@ -54,6 +55,7 @@ function buildOffline(environment) { } try { + restoreSudoBuildIdentity(); const args = parseArgs(process.argv.slice(2)); rmSync(join(root, PYLON_RELEASE_BUILD_RECEIPT), { force: true }); assertCleanSource(root, { rejectIgnoredInputs: true }); diff --git a/scripts/lib/pylon-release-identity.mjs b/scripts/lib/pylon-release-identity.mjs new file mode 100644 index 00000000000..cf26a13a974 --- /dev/null +++ b/scripts/lib/pylon-release-identity.mjs @@ -0,0 +1,29 @@ +function sudoIdentity(value, name) { + if (typeof value !== "string" || !/^[1-9][0-9]*$/.test(value)) { + throw new Error(`Sudo release builds require a non-root ${name}.`); + } + const id = Number(value); + if (!Number.isSafeInteger(id) || id >= 0xffffffff) { + throw new Error(`Sudo release builds require a valid ${name}.`); + } + return id; +} + +// The caller creates the isolated network namespace before starting this entrypoint. +// Restore its file ownership before any build writes or subprocesses can run. +export function restoreSudoBuildIdentity(credentials = process, environment = process.env) { + if (credentials.getuid?.() !== 0) return; + if (environment.SUDO_UID === undefined && environment.SUDO_GID === undefined) return; + const uid = sudoIdentity(environment.SUDO_UID, "SUDO_UID"); + const gid = sudoIdentity(environment.SUDO_GID, "SUDO_GID"); + credentials.setgroups([]); + credentials.setgid(gid); + credentials.setuid(uid); + if ( + credentials.getuid() !== uid || credentials.geteuid() !== uid || + credentials.getgid() !== gid || credentials.getegid() !== gid || + credentials.getgroups().some((group) => group !== gid) + ) { + throw new Error("Sudo release build did not restore the invoking identity."); + } +} diff --git a/scripts/pylon-prime-agent-release.test.mjs b/scripts/pylon-prime-agent-release.test.mjs index 732ec89106f..c505b3cfbf6 100644 --- a/scripts/pylon-prime-agent-release.test.mjs +++ b/scripts/pylon-prime-agent-release.test.mjs @@ -3,6 +3,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { test } from "node:test"; +import { restoreSudoBuildIdentity } from "./lib/pylon-release-identity.mjs"; import { assertImmutableReleaseUrl, createInternalShrinkwrap, @@ -41,6 +42,66 @@ const version = "0.8.1"; const toolchain = { node: PYLON_RELEASE_NODE_VERSION, npm: PYLON_RELEASE_NPM_VERSION }; const lockfileSha256 = "a".repeat(64); +function buildCredentials() { + let uid = 0; + let gid = 0; + let groups = [0, 27]; + const changes = []; + return { + changes, + getuid: () => uid, + geteuid: () => uid, + getgid: () => gid, + getegid: () => gid, + getgroups: () => [...groups, gid], + setgroups: (value) => { changes.push("groups"); groups = value; }, + setgid: (value) => { changes.push("gid"); gid = value; }, + setuid: (value) => { changes.push("uid"); uid = value; }, + }; +} + +test("sudo release builds restore the invoking owner without supplementary privileges", () => { + const credentials = buildCredentials(); + restoreSudoBuildIdentity(credentials, { SUDO_UID: "1001", SUDO_GID: "1002" }); + assert.equal(credentials.getuid(), 1001); + assert.equal(credentials.getgid(), 1002); + assert.deepEqual(credentials.getgroups(), [1002]); + assert.deepEqual(credentials.changes, ["groups", "gid", "uid"]); +}); + +test("malformed sudo identities fail before changing any credentials", () => { + for (const bad of [undefined, "", "0", "-1", "01", "1.0", "1e3", " 1000", "4294967295", "9007199254740992"]) { + for (const field of ["SUDO_UID", "SUDO_GID"]) { + const credentials = buildCredentials(); + assert.throws(() => restoreSudoBuildIdentity(credentials, { + SUDO_UID: "1000", SUDO_GID: "1000", [field]: bad, + }), /Sudo release builds require/); + assert.deepEqual(credentials.changes, []); + } + } +}); + +test("ordinary release invocations retain their existing identity", () => { + const directRoot = buildCredentials(); + restoreSudoBuildIdentity(directRoot, {}); + assert.deepEqual(directRoot.changes, []); + const user = { ...buildCredentials(), getuid: () => 1000 }; + restoreSudoBuildIdentity(user, { SUDO_UID: "invalid", SUDO_GID: "invalid" }); + assert.deepEqual(user.changes, []); + assert.doesNotThrow(() => restoreSudoBuildIdentity({}, {})); +}); + +test("failed sudo identity restoration is terminal and preserves the original error", () => { + for (const operation of ["setgroups", "setgid", "setuid"]) { + const credentials = buildCredentials(); + const error = Object.assign(new Error("denied"), { code: "EPERM" }); + credentials[operation] = () => { throw error; }; + assert.throws(() => restoreSudoBuildIdentity(credentials, { SUDO_UID: "1000", SUDO_GID: "1000" }), (actual) => actual === error); + } + const privileged = { ...buildCredentials(), geteuid: () => 0 }; + assert.throws(() => restoreSudoBuildIdentity(privileged, { SUDO_UID: "1000", SUDO_GID: "1000" }), /did not restore/); +}); + function fakeArtifacts() { return PYLON_RELEASE_PACKAGES.map((releasePackage, index) => ({ package: releasePackage.packageName,