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
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.