TacidOS
Developers
Documentation

Decision 0049

49. A tool result is filtered, and a path list is a boundary

Architecture decision record 0049: 49. A tool result is filtered, and a path list is a boundary

Status
accepted
Date
2026-09-05

Context

Two of Tacid’s written promises were not kept by its code.

The first. SECURITY.md says “a scrubber runs on every tool result, memory write and audit line”. Two of the three were true. The scrubber ran inside the egress proxy, inside the journal and inside the memory store, and did not run on the path that matters most: what a tool hands back to the model. A model shown a credential can repeat it — to a provider on the next request, into a memory, into the text of the next tool call — and no instruction in a system prompt is a control over that. So a file with an API key in it, read by files.read, went to the model whole.

The second. The policy has filesystem_read and filesystem_write: lists of the places the agent may read and write. They are options in the NixOS module, they are shown by tacid policy show, and nothing consulted them. Only the protected deny-list was enforced. An owner reading their own policy would conclude that the agent could read three directories, when in fact it could read every file on the machine that was not explicitly protected.

A third thing fell out of looking: source.write and source.propose never asked whether the self.modify capability allowed them to run. The baseline sets it to ask, so on a default machine the agent could rewrite Tacid’s own non-core source without the owner having agreed to that at all.

A list that is written down and never consulted is worse than no list. No list is honest about what it does not do; a list tells the owner they are protected when they are not.

Decision

Everything a tool returns is filtered before it leaves the toolbox. Toolbox::call scrubs the content of a successful result and the text of a refusal, because a refusal that quotes the argument it choked on is put in front of the model exactly like a result. The tool itself does not opt in: the private dispatch finds and runs the tool, and every way out of it goes through the filter, so a tool added next year is covered by construction rather than by review.

The filter is by shape, not by identity. The daemon holds no secret values — that is the point of the keeper brokering them and the egress proxy injecting them — so it cannot remove them by identity the way the proxy can. Toolbox::scrubbed_shapes() publishes what it catches, because a guarantee whose extent nobody can read is not one.

A path a tool is given is held against the list for the kind of access it is. Every tool that takes a path declares what it does with it: Access::Owner(FileAccess::Read), Access::Owner(FileAccess::Write), or Access::Source for a path inside Tacid’s own checkout, which is governed by the self-modification capability and the core partition rather than by the owner’s file lists. admitted_path asks three questions in the order that makes them answerable: the capability’s own decision, then the protected deny-list, then the allow-list for this access. It normalises first, because a deny-list consulted with ../ still in the string is a deny-list that can be walked around — and then, when the path exists, it follows the links and asks both lists again about where they actually lead. A link inside a place the policy allows, pointing at a file outside it, is a file outside it. That rule has applied to a person’s home since the executor was written, and it belongs on the daemon’s own path too: the agent may write the genome, and the genome is one of the places the policy allows it to read, so without this it could put a link there pointing anywhere the daemon’s user can reach. The refusal names the file rather than the name that led to it.

The capability’s decision is asked first because it is the coarsest. filesystem.read set to deny means no file is read, whatever any list says. Set to ask, it refuses too, and says why: consent belongs to the keeper and is given for a change, not for a read, so there is nowhere in a tool call to ask. A file is read now or not at all.

source.write and source.propose ask the self-modification capability first. The partition between core and the rest is a separate, later question: this one says whether the agent may change any of itself, that one says which parts are its to change.

facts.get takes a fact, not a path. It never named a place on the disk — its argument is one of an enumerated set of fact names — but an argument called path that is not a path invites a path, from the model reading the schema and from the next person to read the file.

Consequences

A machine whose owner has said nothing reads the owner’s home and the genome, and nothing else.

The default widened at the moment the list became real, and that is deliberate rather than a retreat. The narrower list — ~/projects, ~/.config, /etc/tacid — had been written while nothing consulted it, so no machine had ever met it. The first one that did could not read the notes its owner had just asked it about. A fence that appears only when you enforce it was never a decision anybody made, and the honest response to finding one is to decide, not to keep it because it was written down first. What keeps a home safe is the protected list — keys, password stores, cloud credentials, browser profiles, denied at every autonomy level — together with the rule that anything read from a file taints the turn that read it. Writing stays narrow, because writing is where the damage is.

Another person’s home is not on the list. It is theirs, and when Tacid reads it, it reads it as them, through their own executor, under their own policy.

Ten test harnesses had to name the directory they read, because they read temporary directories that no policy allowed. That is the enforcement being real. One of them, the taint test, was quietly passing for the wrong reason once the check landed — the read was refused, so the turn was never tainted and the thing it was meant to prove never happened. It now allows the read and proves the taint.

The scrubber’s shape list is what stands between a credential and a provider on the daemon’s path. It will miss shapes nobody has taught it. Where a value is known — a credential the keeper brokers — it is removed by identity at the egress proxy, which is a stronger guarantee and the reason secrets are meant to travel as handles in the first place.

What is deliberately not scrubbed is what the owner types. A person who pastes a key into a sentence has done something deliberate, and silently redacting their own words would break a paste they meant. The answer to a key in a chat is the secret broker, not a filter on the owner’s speech.

filesystem_write is enforced by the same helper and no tool uses it today, because nothing in the box writes into the owner’s filesystem. The box’s own test walks every tool, and a tool with a path in its schema that has not declared what it does with it fails there — so whoever adds the first writer is made to say so.

The same filter, one reader further out

A tool result is filtered before the model reads it. Then it is printed.

Scrubber asks whether text may leave the machine. It does not ask whether a terminal may be handed it, and those are different questions about the same bytes, because a terminal is an interpreter and not a display. An escape sequence in a build log moves the cursor, clears the screen, sets the window title, and on some emulators answers back down the input stream as though somebody had typed it. Everything else here treats a file, a journal, a build log and a model’s answer as data rather than instructions; handing them to a terminal unchanged makes them instructions again, to a reader nobody had written a filter for.

tacid_audit::visible is that filter. Control characters are shown rather than obeyed — \x1b for escape, \r for a carriage return — and newline and tab survive, because they are layout and a build log with its lines run together is a build log nobody can read. on_one_line writes out the newlines too, for the places that promise one line and where a newline is not layout but a way of pushing the rest of the display off the end of it.

It runs at every sink where somebody else’s words reach a terminal: the diff and the build log in a preview, every arm of tacid watch, the live tool line under tacid ask, and the daemon’s own one-line summary of a tool call — which a model composes and every surface draws.

Two of these are worth naming, because they are not hypothetical. Nix quotes the file it choked on, and a genome carries owner.nix, which is the owner’s and may hold anything. And a lone carriage return rewrites the line already printed: refused: no\rallowed: yes shows the second on a terminal while the journal still says the first, which is a machine lying to the person reading it about what it just did.