Decision 0035
A person's files are read as them
Architecture decision record 0035: A person's files are read as them
Context
The daemon runs as its own user with ProtectHome=true, and that is right: it is the least
trusted process on the machine — the one that talks to a language model and parses what comes
back — and a person’s files are not its to hold. It also meant that on a real machine files.read ~/notes.txt could never work, and that the plan’s tacid-session, “a per-user executor, started
in the user’s session as a socket-activated user unit”, had been a row in a table for as long as
the project existed.
Two things about that row did not survive being built. A user unit needs a login session or
lingering, and is found through the user’s runtime directory, which is 0700 and closed to the
daemon; on a headless machine, or before anybody logs in, there is no session to start it in. And
“per-user principals” needs deciding what the executor’s answers are worth: a person’s own file,
read for them by a process that is them, is still content from outside the machine’s kernel.
A third thing turned up while wiring it. The keeper had been told the owner’s home was /home,
because nothing had ever told it otherwise, so ~/.ssh in the policy’s protected paths had
protected /home/.ssh — a path that belongs to nobody — on every installed machine.
Decision
Each person declared on the machine gets an executor, tacid-session, started by the system as
that person, on a socket the system made for them.
- The units are system units, templated:
tacid-session@<name>.socketlistens on/run/tacid/sessions/<name>.sockfrom boot, andtacid-session@<name>.servicestarts as that user the first time something connects. No login, no lingering, no display. The instances are declared for every normal user in the configuration, so a person exists to the agent as soon as they exist to the machine. - The directory is root’s. Only the system puts sockets in it, so the name of a socket is the
proof of who answers on it — the daemon does not check the credentials of what it connected
to, because nothing else could have bound
dom.sockthere. - The executor checks the credentials of what connected to it. It answers the daemon’s uid and the person’s own, and refuses everything else before looking at the method: an executor that answered any local user would be a way for one person to read another’s files through a process that runs as them.
- It reads only its person’s home, and applies the policy again from its side — the same normalisation, the same protected paths, and where a link really leads — so a daemon that was talked out of its own check would still not get the key. Its unit makes the home read-only: the executor that can read everything a person owns can change none of it.
- What is not text is opened by a parser in a sandbox, by the executor, as the person, with the
namespaces a sandbox is made of and no network. The daemon’s
files.opentool exists for files in a person’s home and nothing else, because a parser runs only in somebody’s executor. - Everything an executor returns is
Trust::Untrusted, on the wire and in the turn. A person’s file is data; the turn that read it ends tainted, and what it can do is capped accordingly. - The genome names the owner:
tacid.owner.name, written by the installer beside the owner key. The keeper’sTACID_HOMEis that person’s home, so~in the policy means what a person reading the policy would think it means.
The rules mind learned two shapes to go with this — read <path> and open <path>, with the path
kept in the case it was typed — and one behaviour it had been missing: a tool’s answer is the
answer, rather than an occasion to call the tool again until the daemon’s round limit stops it.
Consequences
- A machine reads a person’s files without ever letting the agent’s uid near them, and the lifecycle test proves it on a booted machine: the daemon’s uid cannot read the home, the agent reads it through the executor that runs as the person, a protected file stays protected by the executor’s own check, and an archive is listed by a parser in a sandbox. Five seam tests hold the route, the second gate and the label in place without a machine.
- The first archive ever opened on a booted machine was refused: the executor’s unit allowed
only
AF_UNIX, and the sandbox brings up the loopback of its own empty network namespace through a netlink socket. Netlink is not the network; the unit allows it, and the namespace it configures still has no route out. - Voice, desktop portals and the session bus — the rest of the plan’s row — still need a login session and will be a second per-person component, in the user’s own systemd, when there is a desktop to attach them to. The executor is what they will ask for files.
- Writing into a person’s home is not an operation the executor has. When it is, it will be a transaction with a snapshot, not a tool.
- The list of people is the list of normal users in the configuration; a person added at runtime has no executor until the next generation declares them. That is the correct direction to be wrong in.
Alternatives rejected
- A user unit in the person’s session. Needs a session; hidden from the daemon by the runtime directory’s mode; absent on every headless machine and before every login.
- The executor connecting to the daemon and registering itself. Sound, and it would have put a reverse-direction request stream into the daemon’s socket loop for the sake of avoiding a directory the system already owns.
- A shared, sticky, world-writable directory of sockets. A local user could occupy another’s name before their executor started; the daemon would have had to check the peer’s credentials on every connection, and the failure would have been a denial of service by anybody with a shell.
- Reading homes as the daemon, with the home made readable to its group. Every home, readable by the process that talks to the model. No.