5.7 KiB
Phase 2.2: FreeBSD privilege dropping and build-user isolation prototype
Date: 2026-04-01
Summary
This step adds a concrete FreeBSD privilege-dropping prototype that complements the Phase 2.1 jail-isolation work.
Added files:
tests/daemon/freebsd-build-user-helper.ctests/daemon/run-freebsd-privilege-drop-prototype.sh
The prototype demonstrates that a root-launched daemon-side process can:
- enter a FreeBSD jail build environment
- drop permanently to a designated numeric build UID/GID
- lose the ability to regain root with
setuid(0) - write only to its own build directory
- fail to read a peer build user's files
- fail to access a host path that is not mounted into the jail
- fail to create files in a protected root-owned directory
- run two builds concurrently under different build identities
Prototype design
Build-user model
For this prototype, build users are represented by dedicated numeric IDs rather than pre-created named accounts:
- job 1: UID/GID
35001 - job 2: UID/GID
35002
This is enough to validate the essential daemon-side mechanics:
- root can allocate a build identity
- root can prepare directories owned by that identity
- the build process can drop into that identity before executing untrusted work
For a real FreeBSD Guix daemon, these numeric identities would likely come from a reserved build-user pool, whether or not they are backed by visible passwd entries.
Isolation structure
Each job gets its own jail and its own writable work directory. Inside each jail, the helper sees:
/lib/libexec/tools/freebsd-build-user-helper/work(owned by that job's UID/GID)/peer(mounted from the other job's work directory, but inaccessible because of permissions)/protected(root-owned and not writable)
A host-side sentinel file is left outside the jail entirely and is referenced as /outside-sentinel; the helper confirms it is not visible.
Concurrency check
Both jailed helper processes are started in parallel and intentionally sleep for two seconds after their checks. The observed elapsed wall-clock time was also two seconds, not four, showing that the prototype was running the two build-user jobs concurrently rather than serially.
Run command
METADATA_OUT=/tmp/freebsd-privdrop-metadata.txt \
./tests/daemon/run-freebsd-privilege-drop-prototype.sh
Observed results
Observed helper output for job 1:
job=job1
target.uid=35001
target.gid=35001
post_drop.euid=35001
post_drop.egid=35001
drop.regain_root=Operation not permitted
access.allowed_read=job1-allowed
access.own_output_uid=35001
access.own_output_gid=35001
access.peer_file=Permission denied
access.hidden_file=No such file or directory
access.protected_create=Permission denied
drop.chown_root=Operation not permitted
Observed helper output for job 2 was identical in structure, with UID/GID 35002.
Observed host-side result file ownership:
- job 1 result file: UID/GID
35001:35001, mode0600 - job 2 result file: UID/GID
35002:35002, mode0600
Observed concurrency metadata:
- configured hold time per job:
2seconds - measured elapsed wall-clock time:
2seconds
Metadata was captured in:
/tmp/freebsd-privdrop-metadata.txt
What this demonstrates for the FreeBSD port
1. Permanent privilege dropping works in the required direction
The helper was started as root by the harness, then used:
setgroupssetgidsetuid
After the drop, setuid(0) failed with Operation not permitted, which is the basic property Guix needs from a build-worker launch path.
2. Build-user ownership boundaries behave as expected
Each job could write its own result file, and the file ended up owned by the dropped build UID/GID rather than root. Each job then failed to read the peer job's file due to permission checks.
3. Jail filesystem containment and credential isolation compose correctly
Two distinct denial modes were observed:
- peer work directory mounted but inaccessible:
Permission denied - host path not mounted into the jail:
No such file or directory
This is exactly the combined model the FreeBSD daemon design needs:
- jail layout restricts visibility
- build-user credentials restrict access where visibility alone is not enough
4. Protected daemon-owned paths remain protected after the drop
The dropped build user could not create files under the root-owned /protected directory and could not chown its output back to root. This is the essential privilege-separation behavior needed to keep store/daemon-owned paths outside build control.
Mapping to Guix-daemon needs
| Guix-daemon requirement | Prototype result |
|---|---|
| launch build as root-controlled setup step | yes |
| drop to dedicated build UID/GID before untrusted work | yes |
| prevent build from regaining root | yes |
| keep per-build writable areas separate | yes |
| prevent one build from reading another build's files | yes |
| combine credential isolation with jail filesystem containment | yes |
| support concurrent builds under distinct identities | yes |
Conclusion
Phase 2.2 is satisfied on the current prototype track:
- a concrete FreeBSD privilege-dropping implementation now exists in C
- it integrates cleanly with the jail-first model established in Phase 2.1
- it demonstrates the core properties required for a future FreeBSD Guix daemon:
- root-controlled setup
- permanent drop to build credentials
- cross-build isolation
- blocked access to protected daemon-owned paths
- concurrent build execution under separate identities
This reduces the remaining Phase 2 uncertainty mainly to store management and lifecycle, not to whether FreeBSD can enforce the basic build-user security model.