Decision 0084
0084. A socket that is never made needs no firewall
Architecture decision record 0084: 0084. A socket that is never made needs no firewall
Status: accepted. Completes and corrects ADR-0011, and removes an operation from ADR-0004’s stem.
Context
ADR-0011 says network policy is enforced in the kernel rather
than in the process being confined, and that tacid-egress is the only way out. What it described
as the mechanism was nftables: rules installed by the stem, pinning the tacid, tacid-session
and sandbox uids to the proxy with meta skuid. Four documents repeated it — this record,
docs/architecture.md §8 and its process table, PLAN’s process table and §3.9.
None of it was built. The stem carried a typed nftables.profile operation; nothing sent it, and
nothing wrote the .nft files it would have loaded. What actually confined the daemon was
systemd’s IPAddressDeny=any with IPAddressAllow=localhost — a cgroup eBPF program, kernel
enforcement as promised, but address-based. localhost is every local port, so a compromised
tacidd could open a socket to any other listener on the machine and use it as a relay. The
documents said so, in the present tense, once they were corrected.
Building the missing rules meant answering three questions, and answering them changed the shape of the answer entirely.
The session cannot be pinned by uid. tacid-session@ runs with User = "%i" — it is the
person. A meta skuid rule for it would either name a system uid that does not exist or pin that
person’s whole account, browser included. Sessions and sandboxes would have to be matched by
cgroup.
A cgroup rule cannot be checked offline, and can barely be written. nft resolves a
socket cgroupv2 path to an inode when it parses the rule, so a ruleset naming a cgroup that
does not exist yet is a parse error, not a rule that matches nothing. It cannot be validated in a
build sandbox the way this machine’s compositor configuration and udev rules are, and on a running
machine it can only be loaded after systemd has created the cgroup — an ordering dependency on a
template unit that starts on demand.
Then the reading that settled it. tacid-egress is the only crate in this tree that opens a
TCP connection. The model client reaches its provider over the proxy’s Unix socket. The update
client fetches its manifests the same way. The keeper, the stem, the sessions and the scribes are
Unix sockets. The local mind is a Unix socket — tacid-infer has a test asserting its server is
never given a --port, which is not an accident but the design. And the executors and scribes could
not create an IP socket already: the scribe is AF_UNIX alone, and the executor AF_UNIX and
AF_NETLINK — the second not for any network but because bubblewrap brings up the loopback of
its own empty namespace through a netlink socket.
So the daemon did not need a narrower route to the network. It needed none.
Decision
tacidd gets RestrictAddressFamilies = [ "AF_UNIX" ], and the stem’s nftables.profile
operation is removed.
RestrictAddressFamilies is a seccomp filter on socket(2). The daemon does not get a socket it
may not use — it does not get the socket. There is no port to narrow to, no relay to reach, and
nothing to keep in step with a list of listeners. It is strictly stronger than the rules four
documents described, and it is one line rather than a generated ruleset, a load ordering, a cgroup
that has to exist first, and a file the most privileged process on the machine reads and obeys.
IPAddressDeny = "any" stays beside it. It is redundant — a process with no IP socket sends no IP
packet — and it is a second mechanism in a different part of the kernel for the promise this whole
design rests on.
The stem loses the operation because nothing sends it and nothing can. That matters more than
tidiness: nft -f <path> is the stem reading a file and obeying it, which is the one shape
ADR-0004 rules out everywhere else — the stem takes typed operations and
never run(string), and a ruleset read from a path is run(string) with extra steps. Anything
that could write that file would have had root’s firewall. Removing it removes the most dangerous
operation in the set, and it was there for a feature that turned out to be unnecessary.
The airgap does not depend on any of this and never did. It is a flag in the keeper’s own
directory that the proxy reads, and it works; NftablesProfile::Airgap was a second mechanism for
one thing.
Consequences
- The daemon cannot open an IP socket. Not to the internet, not to another listener on the same
machine, not to
127.0.0.1.tacid-egressis the only process in this system that can, which is what “the only way out” was always supposed to mean. - The stem’s surface is one operation smaller, and the operation removed was the only one that took its instructions from a file.
- A future need for a real firewall — inbound rules beyond
networking.firewall, or per-interface policy — is a new decision with a new record, not a resurrection of this one. It would have to answer the cgroup-parsing problem above, which this design no longer has to. - The claim is checked on a booted machine rather than argued: the desktop leg asserts that a
process running as
tacidcannot create an IP socket while the proxy’s Unix socket still answers. The negative half is the half that matters, and it is the half that would have caught a ruleset that pinned nothing. - If some future part of the daemon does need IP — nothing in it does today — it will fail loudly
at
socket(2)rather than quietly reaching a local listener.
Alternatives rejected
- Build the nftables rules as described. Weaker (a filtered socket is still a socket), larger (a generated ruleset, a load unit, an ordering dependency), unverifiable in a build sandbox for the cgroup half, and it would have kept a file-reading operation on the most privileged process for no gain.
- Keep the operation for later. An operation nothing sends is a capability granted for nothing. It can be added back with a record that says why, which is cheaper than carrying it unused and having somebody assume it is enforcing something.
PrivateNetwork = trueon the daemon. Equivalent in effect and heavier: a network namespace of its own, and a Unix socket in/run/tacidworks either way.RestrictAddressFamiliessays the same thing in the vocabulary the rest of these units already use.