[Homebrew/Security] Isolate tap recipes in a closed build root - #1112
Closed
brandonpayton wants to merge 18 commits into
Closed
[Homebrew/Security] Isolate tap recipes in a closed build root#1112brandonpayton wants to merge 18 commits into
brandonpayton wants to merge 18 commits into
Conversation
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.
brandonpayton
force-pushed
the
packaging/schema3-stacked-a0-qk044
branch
from
July 27, 2026 22:01
33e3321 to
fd0ae3a
Compare
Contributor
Phase B-1 matrix build status —
|
| 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.
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
RootDirectory.sysroot, dependency outputs, and exact Node/LLVM Nix closures it needs.
then seal and validate the result before publication.
verification, including partial-setup failure paths.
scripts/dev-shell.shis a global package-cache input, so storing thisrun-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, orconsume a poisoned
/opttool. It also verifies that declared inputs remainavailable 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 beforemerge. 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.shpassed, including the real Homebrewlifecycle, recipe-runner abuse cases, immutable-release recovery, VFS release,
shell closure, and publisher trust checks.
homebrew_tier2_preflightRust tests passed.on macOS.
/runcheck passed with a nested projection rootreporting link count 3; the portable directory seal accepted it while still
rejecting a hard-linked projected file and a writable projected directory.
git diff --check, and actionlint workflow-structurevalidation passed.
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
/tmpoverlay and production/runtmpfs. The current headdefines 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 -dapplies an explicit mode only to the finalpath component: implicit ancestors such as
crates/sharedremained0755.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/python3as a protected root-owned selectorfor
python3.12. The launcher previously allowed only regular executable filesat 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.NUMBERshape.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.