Files
fruix/docs/reports/phase2-freebsd-privilege-drop.md

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.c
  • tests/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, mode 0600
  • job 2 result file: UID/GID 35002:35002, mode 0600

Observed concurrency metadata:

  • configured hold time per job: 2 seconds
  • measured elapsed wall-clock time: 2 seconds

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:

  • setgroups
  • setgid
  • setuid

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.