Skip to content

[Homebrew/Security] Isolate tap recipes in a closed build root - #1112

Closed
brandonpayton wants to merge 18 commits into
mainfrom
packaging/schema3-stacked-a0-qk044
Closed

[Homebrew/Security] Isolate tap recipes in a closed build root#1112
brandonpayton wants to merge 18 commits into
mainfrom
packaging/schema3-stacked-a0-qk044

Conversation

@brandonpayton

@brandonpayton brandonpayton commented Jul 27, 2026

Copy link
Copy Markdown
Member

Why

A Homebrew Formula can come from Kandelo's core tap or from an independent
third-party tap. Its build recipe is therefore input from another repository,
not trusted code that should inherit access to the GitHub Actions runner.

Before this change, Kandelo authenticated the selected recipe and ran the
Formula build under an unprivileged user, but the recipe still saw too much of
the runner's filesystem and process environment. A malicious or simply broken
recipe could inspect unrelated host files, reach privileged service sockets, or
write outside its declared build result. That is not a safe foundation for
third-party taps.

This change gives each recipe an empty filesystem root and projects only the
exact inputs that recipe declared. The privileged supervisor remains small and
reviewed; the recipe cannot turn its own instructions into broader host access.

What changed

  • Run tap-owned recipes under a separate, short-lived recipe user.
  • Start each recipe in an empty systemd RootDirectory.
  • Project only the authenticated recipe, source, platform tools, target
    sysroot, dependency outputs, and exact Node/LLVM Nix closures it needs.
  • Keep writable work and output directories private to that one invocation,
    then seal and validate the result before publication.
  • Retire both temporary users and all of their processes after build and bottle
    verification, including partial-setup failure paths.
  • Pass the recipe identity only at the two Formula execution commands.
    scripts/dev-shell.sh is a global package-cache input, so storing this
    run-scoped value there would needlessly invalidate every package.

Security boundary

The Linux canary verifies that an untrusted recipe cannot read host secrets,
escape through /proc/1/root, talk to systemd or D-Bus, start nested units, or
consume a poisoned /opt tool. It also verifies that declared inputs remain
available and the sealed output succeeds.

The root supervisor receives no package-publisher credentials. Credentialed
upload and tap finalization continue only after the isolated build has ended
and its output has been independently checked.

Contract impact

This changes Homebrew build and publication isolation only. It does not change
the Kandelo kernel, POSIX behavior, ABI, Node.js guest behavior, Chromium guest
behavior, or existing bottle bytes. No ABI bump or package rebuild is caused
by this PR itself.

The PR is now temporarily stacked on #1116 so its Linux evidence can run while
the mostly-lazy shell cutover finishes. After #1116 merges, this branch becomes
a direct descendant of main; I will verify the exact landed ancestry before
merge. The implementation commits form one publication boundary; schema-3
recipes must not be published from an intermediate commit.

Validation

Local validation on the exact stacked tree:

  • test-homebrew-publish-workflow.sh passed, including the real Homebrew
    lifecycle, recipe-runner abuse cases, immutable-release recovery, VFS release,
    shell closure, and publisher trust checks.
  • 28/28 homebrew_tier2_preflight Rust tests passed.
  • 19/19 portable recipe-runner tests passed; two Linux-only cases were skipped
    on macOS.
  • A focused Ubuntu 24.04 /run check passed with a nested projection root
    reporting link count 3; the portable directory seal accepted it while still
    rejecting a hard-linked projected file and a writable projected directory.
  • The committed program-package projection remained current.
  • Ruby and Bash syntax, git diff --check, and actionlint workflow-structure
    validation passed.
  • The current head was replayed onto [Homebrew/Cutover] Build the main shell from bottles and load optional tools on demand #1116 with a range-diff showing the nine
    implementation commits unchanged apart from the expected publisher
    self-hashes. On that exact combined tree, the publisher trust checker, the
    Formula-closure suite, 19 portable recipe-runner tests, and all 28 schema-3
    preflight tests passed through scripts/dev-shell.sh.

The first hosted attempt stopped before the live canary because its socket
boundary fixture constructed a 105-byte path while asserting that it was 104
bytes. Production uses a fixed 104-byte path and was unaffected. The current
head makes the live Linux test independent of checkout and temporary-directory
lengths, then verifies that a 107-byte filesystem socket binds and 108 bytes is
rejected.

The second hosted attempt passed that boundary and reached the live launcher
canary. It exposed a real cross-filesystem assumption: directory link counts
vary between the /tmp overlay and production /run tmpfs. The current head
defines a directory seal using real-directory type, exact root ownership,
read-only mode, and the complete descendant manifest. Regular files remain
single-link checked. Negative tests still reject a writable projected
directory and a hard-linked projected file, and the live canary verifies the
populated sealed-output root.

The third hosted attempt passed the socket and filesystem-link-count cases,
then showed that GNU install -d applies an explicit mode only to the final
path component: implicit ancestors such as crates/shared remained 0755.
The current head constructs the root-owned projection first and seals every
directory together. A forward audit also corrected the live preflight fixture
before another push: it now creates and retires the exact recipe identity and
passes the dev shell's protected Nix Node executable, rather than depending on
publisher steps that run later in another job.

The fourth hosted attempt passed those projection and identity checks, then
found that Ubuntu exposes /usr/bin/python3 as a protected root-owned selector
for python3.12. The launcher previously allowed only regular executable files
at admitted host-tool paths. The current head accepts the general protected
version-selector form only when its canonical target stays in the same
root-owned, non-writable directory and has the exact name.NUMBER shape.
Existing ownership, mode, and build-user replacement checks still validate the
link and target. Focused tests accept both a synthetic protected selector and
Ubuntu's real selector while rejecting a cross-directory target and a
build-user-controlled selector; the frozen workflow trust checker requires the
same invariants.

The fifth hosted attempt passed the version-selector contract and showed that
the synthetic private Kandelo checkout did not contain the newly tracked
privileged recipe supervisor. The correction removes that checkout as a
separate supervisor-code authority. The real supervisor source is now a
required member of the manifest-sealed, root-owned platform projection. The
launcher authenticates its source state and digest, copies it to a new
root-owned executable inode, proves the source stayed unchanged, compares the
exact source and destination bytes before first root execution, and rechecks
the protected executable afterward. Linux tests reject mutable-checkout,
non-root-owned, changed-byte, symlinked, and missing sources; the frozen trust
checker requires that complete boundary.

The hosted Ubuntu publisher gate must additionally pass the live
systemd/empty-root escape canary before this PR is ready to merge.

Base automatically changed from homebrew/sealed-lazy-lifecycle-qk044 to main July 27, 2026 05:36
Bind the ordered runtime-support bottle cohort with one canonical provenance projection digest while keeping aggregate catalog authority separate from each immutable bottle build. Cover mixed producers and reject provenance, authority, unknown, and duplicate drift.
Separate frozen bottle-catalog identity from the later protected tap commit that owns release writes. Bind both identities through structured shell locks and a bounded handoff, freeze the complete reusable workflow contract, and prove public anonymous transport in Node and Chromium.
Bind the shell to the final ABI 42 tap catalog and Bash rebuild, seal the deterministic compressed VFS identity, and admit the exact-main shell package publication path.

The shell remains a small built-in base with reviewed software exposed through lazy bottle references. The public mirror and reused-producer provenance commits are kept as separate purpose-led commits beneath this cutover.

Validation: two anonymous compositions produced byte-identical 7f2f5c77ac35773a1a5b39daae13be51c7f32d8ed0813571f29019520f421e44 artifacts (5682907 bytes); artifact-lock verification, program-index checks, and scripts/test-homebrew-main-shell-closure.sh passed.
Recover the exact digest-bound bottle mirror anonymously when the candidate shell is a publication blocker, then expose it through the existing closed browser acceptance root. This closes the pre-merge publication cycle without changing the image's production URLs.\n\nCover ordinary and relocated CI workspaces, cleanup, environment propagation, and malformed blocker reports.
Browser staging previously inferred mirror need only from source-build blockers. Once a candidate shell archive existed in staging, that report became empty even though the immutable production URL could not exist before merge, so the browser exercised an unavailable lazy layer.

Authenticate and parse the canonical index through the Rust-owned package contract, bind the exact source, blocker report, and shell bytes into a versioned state record, validate the snapshots that enter the prepared workspace, and recover a closed mirror only when the exact shell identity is not canonical. Cover both resolved-unpublished and source-blocked paths end to end.
Let an exact tap commit carry a closed, checksummed build-recipe tree while Homebrew remains authoritative for source and dependency kegs. Preflight and runtime verification reject alternate registry authority, mutable or undeclared inputs, and unsafe outputs; Formula identity now controls cache reuse.
Run Formula-owned schema-3 recipes behind a peer-authenticated root supervisor under a distinct UID. Project only attested inputs and exact Nix closures, seal dependencies and results, and verify teardown and escape resistance.

Keep identity creation transactional and enforce the contract in publication trust checks, focused tests, and the Linux malicious-recipe canary.
Forward the isolated recipe user only at the Formula build and verification commands. Keeping this run-scoped identity out of dev-shell.sh preserves every existing package cache identity while retaining the closed-root execution boundary.
Treat deliberately absent legacy resolver paths as optional systemd masks while retaining fail-closed authority checks. Preserve failed audit units only long enough to expose bounded diagnostics, keep Brew on its protected canonical prefix path, and leave only dependency descendants sealed so Formula installs remain possible.
Refresh the protected build and verification step digests after replaying the sealed schema-3 supervisor onto the mostly-lazy shell workflow. This makes the combined reviewed workflow state explicit and causes later unreviewed changes to fail closed.
@github-actions

Copy link
Copy Markdown
Contributor

Phase B-1 matrix build status — pr-1112-staging

ABI v42. 6 built, 0 failed, 6 total.

Package Arch Status Sha
shell wasm32 built 032ed0b7
lamp wasm32 built 6a194a66
nginx-php-vfs wasm32 built 17b836a2
nginx-vfs wasm32 built 1aa4b69d
node-vfs wasm32 built 34e8638b
wordpress wasm32 built 9dd1e5ae

Auto-generated; replaced on each push. Raw data in the publish-status workflow artifact.

@brandonpayton

Copy link
Copy Markdown
Member Author

Superseded by #1121. The security implementation remains the same ten-commit logical stack, but #1121 replays it directly onto current main and explicitly excludes the unfinished #1116 shell cutover. Continuing there prevents a publisher-security merge from silently landing unrelated shell-image changes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant