ADR-0026: Fail-closed egress on macOS lives in a pf anchor under com.apple
- Status: accepted
- Date: 2026-08-26
Context
macOS has a tunnel (#152), names (#153) and a full-tunnel default route (#166). What it does not have is ADR-0011's lock: a device claiming a default route through meshp must refuse traffic when the tunnel drops rather than putting the user's real address back on the wire. Until it does, a macOS laptop cannot be given a full tunnel on any network whose policy requires failing closed.
The Linux lock is an nftables table meshp owns outright. macOS has pf, which is not the
same shape: one main ruleset, shared by everything on the machine, with rules organised into
anchors. An anchor's rules are only evaluated if the main ruleset references it, and the
main ruleset is exactly the kind of shared mutable state internal/resolved refuses to touch
— the rule that decided macOS gets a DNS implementation is "is there a real undo", and
editing a file the operating system also owns does not have one.
#153 answered that question for the resolver by finding a per-service key nobody else reads.
This record answers it for pf.
What macOS actually does
Established on macOS 26.5 with root, not inferred. The probe is not kept — what it proved is below, and re-running it is a morning's work if any of this is ever in doubt.
The main ruleset references exactly one anchor point. /etc/pf.conf as shipped contains
anchor "com.apple/*" and nothing else that a third party could load into. Apple's own
anchor file nests 200.AirDrop/* and 250.ApplicationFirewall/* beneath it.
pf is disabled until something enables it, and enabling is reference counted. pfctl -E
returns a token; pfctl -X <token> releases it, and pf stays on until the last holder lets
go. So meshp turning pf on cannot turn it off underneath the application firewall, and vice
versa.
An anchor nobody references is loaded and never evaluated. Rules put in a top-level
meshp anchor sat at Evaluations: 0 while the traffic they claimed to block flowed
normally. This is the finding that rules out the obvious approach: meshp cannot simply have
an anchor of its own.
An anchor nested under com.apple/* is evaluated. The same rule in com.apple/meshp
reached Evaluations: 33, Packets: 3 and dropped what it said it would.
A main-ruleset reload does not remove it. After pfctl -f /etc/pf.conf, the rule was
still in place and still nested. This was the finding expected to disqualify pf entirely:
a lock any other program could remove by reloading the ruleset — which pfctl's own warning
suggests happens — would not be a lock. It survives.
Both halves of it can be seen from userland. pfctl -s rules reports anchor "com.apple/*", and pfctl -a com.apple -s Anchors lists what is nested there. meshp can
therefore check that its rules are somewhere they will be evaluated, rather than assuming it.
Decision
The lock is a pf anchor at com.apple/meshp, loaded with pfctl -a com.apple/meshp -f -, removed with pfctl -a com.apple/meshp -F rules, and pf is enabled and released with
pfctl -E / -X so the reference count is respected.
/etc/pf.conf is never written, never parsed and never reloaded by meshp. Nothing of
Apple's is read, merged or rewritten. Removing meshp's anchor leaves the machine's packet
filtering exactly as it would have been.
Before the lock is trusted, it is verified. On every application meshp confirms that the
main ruleset still contains an anchor point covering com.apple/*. If it does not, the lock
is not installed and the route group is reported unhonoured — the same answer a Linux host
with no nftables gives.
The risk this accepts, and why the verification is the whole of it
com.apple is Apple's namespace and meshp has no business in it. It is used because it is
the only anchor point the shipped ruleset references, and the alternative — adding
anchor "meshp/*" to /etc/pf.conf — is editing a file a system update replaces, with no
way to put it back that does not involve having kept a copy.
The failure that matters is not that this is impolite. It is that a future macOS could rename
the anchor point, drop the wildcard, or stop loading com.apple from a file, and meshp's
rules would go on being loaded while quietly no longer being evaluated. A lock that has
silently stopped locking is worse than no lock, because the device goes on reporting that it
fails closed.
That is why the verification is not a nicety. It converts a silent fail-open into the state this system already knows how to express: a device that says it cannot enforce what was asked of it. ADR-0011 accepts support tickets from people whose network broke; it does not accept a device claiming a property it does not have.
Consequences
macOS reaches parity for full-tunnel egress, and a laptop can be given a fail-closed route group.
meshp becomes a pf user on a machine that may have others. The reference-counted enable
is what makes that safe, and it must be used rather than pfctl -e, which does not count.
A macOS update is now something to test after. The verification means a change in Apple's
anchor arrangement surfaces as devices reporting egress unapplied rather than as a leak,
but somebody still has to notice and fix it. This belongs with the sleep-and-roaming testing
in #160, which is the other thing about macOS that no CI runner can tell us.
The lock outlives the process, as on Linux. ADR-0011 requires it: a lock that vanished
when the daemon died would fail open exactly when it is needed. On macOS that now sits
alongside a tunnel that does die with the process (#152), so a killed daemon leaves a
machine refusing traffic with no tunnel to carry it — which is the safe direction and is
exactly what meshp doctor exists to explain. It will need the pfctl commands, taken from
the platform that installed them, as the routing half already does.
Alternatives considered
Add anchor "meshp/*" to /etc/pf.conf. What most third-party VPN software on macOS
does. It gives meshp a namespace of its own rather than squatting in Apple's, and the rules
are evaluated without depending on Apple's arrangement.
Rejected on the undo. The file is owned by the operating system and replaced by updates;
putting a line in it means either keeping a copy of somebody else's file to restore, or
leaving the line behind forever. internal/resolved refuses /etc/resolv.conf on precisely
this argument and it would be incoherent to accept it here. It also fails the verification
test in the other direction: a system update that replaced the file would remove meshp's
anchor point and meshp's ability to notice, since it would be looking for something it had
written itself.
Use NetworkExtension's includeAllNetworks. The platform-sanctioned way to make a VPN
refuse non-tunnel traffic, implemented by the system rather than by a firewall rule, and it
does not touch pf at all.
Rejected for this slice because it is not a change to the lock — it is a change to what meshp
is on macOS. includeAllNetworks belongs to a NEPacketTunnelProvider, which means an app
bundle, a Network Extension entitlement, and Apple's approval; ADR-0010 records that
entitlement as a multi-week lead time and is the reason the mobile clients wrap the official
libraries rather than porting the data plane. meshp on macOS today is a daemon that opens a
utun. Becoming a network extension is a distribution decision with its own record to write,
and it should not arrive disguised as a fail-closed feature.
If that decision is ever taken, this one is cleanly reversed: the pf anchor is removed and nothing else changes.
Do not fail closed on macOS. Honest, and what ships today: the group is reported unhonoured and nobody is misled. Rejected because the research found a mechanism with a real undo, which is the bar this project set for the resolver and cleared here. Declining after finding one would be choosing less capability for no property gained.
Rendered from docs/adr/0026-fail-closed-on-macos.md, which is where it is edited.