TacidOS
DevelopersGitHub

Decision 0035

A person's files are read as them

Architecture decision record 0035: A person's files are read as them

Status
accepted
Date
2026-09-04

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>.socket listens on /run/tacid/sessions/<name>.sock from boot, and tacid-session@<name>.service starts 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.sock there.
  • 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.open tool 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’s TACID_HOME is 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.