TacidOS
Developers
Documentation

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
Date
2026-09-08

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-egress is 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 tacid cannot 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 = true on the daemon. Equivalent in effect and heavier: a network namespace of its own, and a Unix socket in /run/tacid works either way. RestrictAddressFamilies says the same thing in the vocabulary the rest of these units already use.