ADR-0028 — foldyard never elevates on the host: it renders, the operator applies, foldyard probes¶
- Status: Accepted (2026-09-18). Implemented (on
mainsince #24, released in 0.3.1) for the one host-side escalation the package had — the host wall ([machine].host_wall):hostwall.install/remove(asudo nft -f -on everyfy up) are gone;fy machine host-wallrenders and prints,fy upprobes. Amended 2026-09-24: renamed since —[machine].host_wallis now[machine] host_firewall(the host firewall) and the verb isfy machine host-firewall; the old names still work as aliases. - Sources: configuration.md (
[machine].host_wall), lima-wall-machine-integration.md §3, the 2026-09-18 runner experiment recorded there. Related: 0009 (the wall is enforcement, not cooperation), 0022 and 0023 (what the host may take from the checkout: nothing executable, nothing live), 0027.
Context¶
The host wall — nftables on the host matching the VM's own traffic by cgroup — is the one
foldyard feature that needs root on the host. Until this ADR it took it the usual way: fy up
ran sudo nft -f - with the ruleset on stdin, and the operator got sudo's password prompt.
Three things were wrong with that, in ascending order of importance.
1. It prompted on every fy up. The ruleset named Lima's forwarded SSH port and the
hostagent's loopback listener ports, all allocated per boot, and the host's resolvers, so it
was re-rendered and re-loaded every time. The documented remedy was a passwordless sudoers rule
for nft — "not yet written up as a recipe".
2. The prompt did not say what it was for. One line preceded it — (root: sudo nft) — not
the command, not that a password would be asked, and not the ruleset, which went over stdin and
could not be seen. fy machine rm prompted with no line at all. A missing sudo raised a
traceback out of machine start (a review found this the same day).
3. It taught the wrong habit. A tool that asks for elevation and shows nothing trains its users to type their password into whatever asks. foldyard's whole premise is the opposite — that the host keeps its posture because nothing in the checkout or the box can widen it (ADR-0022, ADR-0023). An operator conditioned to approve blind is a wider channel than any the config could open. As the maintainer put it: "I don't like tools that just prompt for elevation and I don't know what they are doing — it's teaching people the wrong habit of just accepting whatever messing with their system."
Two facts about the mechanism decide what the alternative can look like. They were established
on a GitHub ubuntu-24.04 runner on 2026-09-18 (a throwaway workflow; the results are in
lima-wall-machine-integration.md §3):
- The ruleset can be boot-stable. Loopback flows from the VM can be allowed OUT under a
conntrack mark and judged on the INPUT hook by the listening socket's cgroup
(
socket cgroupv2resolves the listener for a SYN) — so the hostagent's and QEMU's per-boot ports never need naming — and DNS can bedport 53to any resolver. What is left depends only on the VM name, its cgroup and the project's daemon band: the same text every boot, so "apply once" is a coherent thing to ask of an operator. - The table cannot be read back, and a table that is there may be inert.
nft listneeds root — there is no unprivileged read of nf_tables. Worse,socket cgroupv2compiles the path to a cgroup ID at load time; a cgroup destroyed and recreated (a host reboot, a stopped slice) gets a new ID that the loaded rule silently no longer matches. On the runner the recreated slice's probe reached the internet with the table still loaded: fail-open. So neither "foldyard loaded it" nor "the table exists" is knowledge; only enforcement observed from inside the matched cgroup is.
Decision¶
foldyard never elevates on the host. Where a feature needs root, foldyard renders the exact artefact and the exact commands, the operator applies them with the content in front of them, and foldyard probes the resulting capability — it never reads privileged state it could not have written, and never runs a command the operator has not seen. Concretely for the host wall:
- The VM runs under a persistent user slice foldyard owns (
fy-machine-<vm>.slice,WantedBy=default.target). A slice survives being emptied, so its cgroup ID — the one the operator's table binds to — is the same across every VM restart. It is the one user-level change the wall makes, and it follows the courtesies Linux expects of a tool writing into$HOME: the unit lives in$XDG_DATA_HOME/systemd/user/(systemd.unit(5): "units of packages that have been installed in the home directory" — not~/.config/systemd/user/, which is for units the operator wrote), it carries a# Generated by foldyardheader naming the verb that regenerates it and the line that removes it, and onlyfy machine host-wallcreates and enables it, saying so once when it does — the same shape as rootless Docker'sdockerd-rootless-setuptool.sh install, a setup verb, never a side effect of a run verb. A launch verb only checks that the slice is active and refuses before booting when it is not (a transient slice fromsystemd-run --slicewould be a new ID the table does not hold). foldyard never stops it. fy machine host-wallrenders the root-side files into the project's state dir and prints them in full — the nftables table and a system unitfy-host-wall-<vm>.service(After=+BindsTo=user@<uid>.service,WantedBy=user@<uid>.service,ExecStart=nft -f /etc/foldyard/host-wall-<vm>.nft,ExecStop=nft delete table …, no shell, no template) — and then the four commands that install them: twosudo install, adaemon-reload, oneenable --now.--uninstallprints the three root lines that remove them and the two user-level lines that remove the slice (last, since--nowstops it). foldyard runs none of them. The installed copies are root-owned, so nothing running as the operator can change what root loads afterwards. The unit is bound to the user manager's lifetime, which is the slice's: the table loads once the manager is up (its default target wants the slice) and is dropped when it stops — so it comes back, bound to the right ID, on every login and boot.- Every
fy up(andfy machine host-wall, and doctor'shost wallrow) PROBES. A child is run under the slice and asked to connect to a listener foldyard opened outside the slice on loopback (must be refused — the input hook's judgement), to TEST-NET-1 off-host (must be refused by the output hook; without the wall the SYN leaves and times out; a host with no route reports "unreachable", which proves nothing and refuses nothing), and to a listener on the project's band (must connect — the staleness half). Refusals are TCP resets, so every verdict is immediate. Not enforcing ⇒fy uprefuses, prints which half failed and where the install steps are. This is what turns the cgroup-ID fail-open into fail-closed. - The install is an install, not part of the VM's lifecycle.
fy machine stopandrmleave it alone (rmsays so and names--uninstall); the operator re-runs the steps after a change to the project's band, and once per host if the files were removed.
The principle generalises beyond the wall. It is the host-side counterpart of ADR-0023: that ADR keeps the checkout from executing on the host; this one keeps foldyard itself from acting as root there. Anything new that needs root on the host takes this shape — a rendered artefact, a printed command, a probe — or does not ship.
Alternatives considered¶
- A passwordless sudoers rule for
nft(the previous documented answer). Silent, but it is the blind approval made permanent, for every invocation ofnftby anything running as the operator — including the box's neighbours. Rejected. - Keep
--applybeside the printed steps for operators who would rather have foldyard run them. Two paths to keep fail-closed, and the convenient one is the blind one. Rejected: pure. - Detect the table with
sudo -n nft list(a read-only sudoers rule). Still a sudoers rule, and presence is not enforcement (the ID finding). The probe is stronger and needs nothing. - Persist the table via
/etc/nftables.conf. Loaded at boot, before any user manager exists, so the slice path fails to resolve and the load errors. The user-manager-bound unit is the ordering that works. - A hash of the VM name for the conntrack mark. Two projects' tables both hook INPUT and each judges only flows carrying its mark; a collision would have project A rejecting B's own plumbing, and nothing could detect or explain it. The mark is the project's proxy band base under foldyard's byte — unique per project on the host by the allocator's construction.
Consequences¶
- No
sudoin the package's host-side code; the hermetic suite has no host-root path to guard, and the wall e2e's fixture is the operator — it runs the printed commands verbatim, which is the whole contract under test. - The operator sees, once, exactly what root will do on their machine and where it lives.
- The probe runs on every
fy up(asystemd-run --scopeand three connects, immediate). A host without asystemd --usermanager cannot host the wall (it never could — the scope needed one);loginctl enable-lingeris the answer for a session without one. - Residual: the probe checks what it probes. A table that still refuses the out-of-slice
listener and still admits the band passes, whatever else it says; a band change is caught, a
render change that touches neither half is not until the operator re-runs the steps.
fy machine host-wallalways prints the current render. - WSL2 is unchanged: the stock kernel lacks
CONFIG_NFT_SOCKET, preflight refuseshost_wallthere, and the operator's ownnft -fwould say why.