Invariants and threats
Security
The hard invariants, the threat model, and how to report a vulnerability.
Tacid gives an AI agent authority over the machine it runs on. That makes the security model the
product, not a feature. This document is the short version; the long version is
docs/architecture.md and the security section of PLAN.md.
Reporting a vulnerability
Report privately through GitHub Security Advisories on TacidOS/TacidOS (“Report a vulnerability”),
or to the security contact published at tacidos.com/security. We
acknowledge within 72 hours, keep you informed, coordinate disclosure with you, and credit you
unless you prefer otherwise. Vulnerabilities in upstream components (nixpkgs, the kernel, Hyprland,
inference engines) should go upstream first; tell us too so we can ship the fix through the channel.
In scope: every tacid-* component, the tacid.* NixOS modules, the installer, the update and
channel pipeline, the binary cache, the model registry, the prompt-injection defenses, and the
documented invariants below. A way to make the agent violate an invariant is a vulnerability even
if no code is “exploited”.
Trust zones
| Zone | Process | Trust | Notes |
|---|---|---|---|
| 0 | tacid-stem | root | Typed operations only. Accepts connections only from the keeper. No network. Refuses boot-, disk-encryption-, TPM- and Secure-Boot-key changes without owner-present confirmation. |
| 1 | tacid-keeper | own user, IPAddressDeny=any | Policy, reversibility classifier, taint, approvals, secrets broker, audit sink, health gate and rollback, snapshots. |
| 2 | tacid-egress | own user | The only path to the network for agent processes. Domain allowlist, credential injection, egress ledger, airgap. |
| 3 | tacidd | tacid | The LLM-facing daemon. Least-trusted daemon. Holds no secrets, has no root, reaches the network only through egress. |
| 3 | tacid-infer | own user, GPU | Supervises sandboxed inference subprocesses. Treated as hostile: model files are untrusted input. |
| 4 | tacid-session | the user | User-context tools; parsers of untrusted content run in sandboxes here. |
| 5 | clients | the user | tacid, tacid-shell. Pure renderers. Approvals go to the keeper, never through tacidd. |
| 6 | sandboxes | ephemeral | bubblewrap + Landlock + seccomp; microVMs for high-risk code. |
Hard invariants
These are never delegated to the model and cannot be changed by the agent. They are enforced in the
keeper and the stem, not in tacidd.
-
The model is not an authorization authority. A change the model composed carries the agent’s principal, whoever asked the question, so every invariant written against an agent holds it. The revision a machine follows and the sources it locks never come from a model at all.
-
Retrieved content (web pages, files, repositories, tool output, third-party MCP servers, anything a person plugged into this machine, voice) is not an authorization authority. Every context item carries a provenance label; taint propagates through tool output; a tainted turn cannot exceed autonomy level 1 for side effects.
-
Root is never represented as arbitrary shell. The stem exposes typed operations with strict schemas and no string interpolation. A host command naming a program that exists to become another user is refused wherever in the command it stands, not only first —
env sudo …is the same request assudo …— and a host command handed to an interpreter as text is refused for being unreadable: a command no rule can read is not one an autonomy level reaches (ADR-0066). -
A secret this machine knows about never enters model context. Protected paths are denied by default; secrets are
secret://handles brokered by the keeper and injected by the egress proxy or into tool environments; a scrubber runs on every tool result, memory write and audit line. On the daemon’s path the filter is by shape rather than by identity, because the daemon holds no secret values;Toolbox::scrubbed_shapes()publishes what it catches. Where a value is known the egress proxy removes it by identity, which is why a secret should travel as a handle. The guarantee is therefore about what the machine holds: a registered secret, a protected path, a credential shape in something the agent read.Which is a guarantee with an edge, and the edge is named here rather than left for somebody to find. A value with no registered identity and no shape the scrubber knows, sitting in a file the policy allows — a token written by hand into a note in
~/projects, with no prefix and no structure — is caught by none of the three. The agent may read it and it may reach a provider. Nothing here claims otherwise, and the answer is a protected path:protected_pathsin the owner’s policy is enforced by the executor that opens the file and by the tool that asked for it, andtacid policyprints the list beside the shapes the scrubber removes, so an owner can see what is covered rather than assume it.tacid secret addcloses that edge in the direction that matters. It reads a value from standard input — never an argument, which is in/procwhile the command runs and in the shell’s history after it — and registers it with the keeper, which keeps one copy in its scrubber and publishes the set on a tmpfs the egress proxy reads. The proxy refuses any request carrying one, in a header or in a body, and says which registration by fingerprint; it does not redact, because a request with a token cut out of it is one nobody asked for, sent anyway.tacid secret listshows fingerprints andtacid secret forgettakes one. Only the person whose machine it is may do either: the gate that lowers the airgap, not the one that raises it (ADR-0059).The boundary, said plainly: a registered value cannot leave this machine, and can still be shown on it. A tool result on the owner’s own screen, and a memory write, still filter by shape, because the daemon holds no secret values and that is what makes it the least-trusted process here rather than a second place secrets live. Closing that means the daemon asking the keeper to scrub text — a round trip on the hot path — and it is not built. Registrations also do not survive a reboot: the values are in tmpfs and in two processes’ memory, and writing them to a disk to save a person one command would be the thing this section exists to avoid.
It is also not a guarantee about what a person types. What the owner puts in a prompt is not filtered — a paste they meant is theirs to make — so an owner who pastes their own key into a question has sent it, and Tacid will not have stopped them.
-
The agent reads and writes only where the policy says.
filesystem_readandfilesystem_writeare enforced by the tool that takes the path, after the protected deny-list and after the capability’s own decision, and a tool that takes a path and does not declare what it does with it fails the toolbox’s own test. The default is the owner’s home and the genome for reading: what keeps a home safe is the protected list and the taint rule, not a fence in an arbitrary place, and the protected list is therefore the guarantee — keys, password stores, cloud and forge credentials, keyrings, browser profiles, and everything else a working machine keeps a live token in. Writing is~/projects, and it is a fence that refuses. The scribe is the only thing on the machine that writes in a person’s home (ADR-0055), and it resolves the path against that person’s own home and then holds it againstfilesystem_writerather than the wider read list — so a file the agent may read and the owner has not granted writing is refused, by the narrower of the two, before anything is opened. Tacid still writes nothing into a project on its own (ADR-0046); what it writes when asked goes through the classifier, a snapshot, and — where no snapshot reaches — the owner. -
The agent writes only the option roots it is granted —
tacid.packages,tacid.services,tacid.hardware,tacid.desktop,tacid.dev,tacid.user— and nothing else, its own autonomy level andtacid.owneramong the nothing else. An allow-list and not a deny list: the deny list saidtacid.agent, a module that was designed and never built, and lefttacid.owner— who the owner is and what their key is — out of both lists, so the one thing that decides who may approve anything was the one thing unprotected. A root added tomorrow is not the agent’s until somebody puts it on the list. Owner-only files are those whose last commit is signed by the owner key; the keeper verifies. -
Destructive storage, firmware, boot and git operations require exact target identity and confirmation unless a narrow owner policy says otherwise.
-
The audit journal is append-only and hash-chained, written by the keeper into a root-owned file the agent cannot modify. Each record commits to the one before it, so a line changed or removed from the middle breaks the chain and
tacid historysays where.Dropping records off the end is the case a chain cannot see by itself: what is left is a shorter chain in which every link holds, and nothing inside a file says how long the file should be. A TPM NV counter says it. The keeper moves it on at every conclusion and at every start and writes down where it stands, and a journal below the counter has had records removed after that seal. A counter and not a stored hash: an NV value is root’s to overwrite, while a counter can only be incremented, and a TPM sets a newly defined counter above every value any counter on it has ever held — so undefining the index does not start it over. There is no operation, at any privilege, that lowers it (ADR-0060).
tacid-keeper verifyreports the seal and exits non-zero when it is broken; the keeper says the same on its unit’s journal at every start, and writes it into the record as well, because nobody watches most starts and a note taken back out moves the counter again.Two orderings make that work. The keeper looks before it seals: sealing writes a record that agrees with the counter it has just moved, so a keeper that sealed first would find every journal intact, including one somebody had cut the end off a moment earlier. And a journal is anchored at birth — its first record is its seal, written before anything else goes in. One machine has one counter and keeps more than one journal: the live image that installs a machine seals to the same chip the machine will, so an installed machine’s fresh journal opens under a counter that already stands somewhere. Without an anchor that is indistinguishable from a journal somebody emptied, and a keeper that called it tampering would accuse every machine it installed. What stays open is a journal erased down to nothing, which is a birth again as far as a counter can tell — not a quiet state, since the chain head returns to genesis and the machine introduces itself a second time, but not one this counter notices.
What this does not stop is a root that rewrites the journal and moves the seal on. Nothing on a machine can stop a root that drives the machine’s own TPM.
What it does stop is an edit that leaves the journal behind the counter, which is what the ordinary ways of editing one without the TPM all do: a disk pulled and mounted elsewhere and truncated, a backup restored, a
/persistrolled back, an image copied. Each leaves a journal whose last surviving seal names a counter below where the chip stands, and that is a gap the chip will not close.What it does not stop, said exactly, is a surgical edit that leaves the last seal standing. The counter detects the removal of a seal, not the removal of a record. Somebody who can write the file can take a record out from before the last seal, re-chain what is left, and adjust the two claims that seal makes about where it sits — the journal’s length and the chain head it sealed — and the counter is untouched by all of it, so the state still reads as intact. Those two claims are checked against the record’s own position and predecessor now, which catches a journal that contradicts itself and raises a forger’s work by one step of the same kind; it is self-consistency and not tamper-proofing, and this paragraph is here rather than left to be worked out because a seal described as making a journal tamper-proof is worse than no seal.
What raises that ceiling is a fact the machine cannot reach, because every mechanism above is a claim the machine makes about itself, checked by the machine.
tacid-keeper witnessprints one line — the chain’s head and how many records it covers — for the owner to keep somewhere else;tacid-keeper witness '<line>'later asks whether the journal still carries what they saw, and the machine cannot fabricate the answer, since a record’s hash commits to every record before it. It is unsigned, because the owner took it and signing would authenticate the wrong end; it is not automatic, because a copy on a schedule is a copy the machine can write; and nothing here keeps one, because a witness this machine held is a witness it could edit (ADR-0072). It catches both cases above, including the one where the chip was driven along with the file. What it does not do is notice on its own: a witness held and never checked catches nothing, and nothing here reminds anybody.A machine with no TPM says “not sealed” in both places rather than letting a missing seal read as an intact one.
-
The health gate and automatic rollback cannot be disabled by the agent. What the gate can see is bounded and said plainly: it reads the kernel’s own accounting, so a unit is running when its cgroup holds a process, and a daemon that bound its socket and then wedged still reads as running. Nothing inside the machine closes that —
Keeper::gateruns inside the keeper after an activation, and a process probing its own socket is either a deadlock or a tautology — so the service manager does, withWatchdogSecon the keeper and the proxy, pinged from each daemon’s own runtime (ADR-0070). The stem carries none, because it holds its runtime for the length of an activation. A daemon whose runtime turns while its work is stuck still pings, and that is a health check rather than a watchdog, and is not built.This sentence was aspirational until 2026-09-07 and is now true. The gate is
tacid-doctorreadingtacid-facts, and both were on the agent-mutable side of the partition — so the code that judges an activation was code the agent could propose a change to, and the proposal would have been classified mutable and shown to an owner as an improvement to diagnostics. Both are core now, and no crate on the core list may name one on the mutable list in its manifest (ADR-0081). -
Rollback claims distinguish system state, data snapshots, and external devices; nothing implies data was restored when it was not.
-
Cloud egress is subject to explicit data classification; nothing leaves without the badge and the ledger. The ledger is written after the exchange — nothing can call bytes back — so what enforces this is what happens next: a proxy that could not write a line stops sending, says why on its journal, and stays stopped until somebody has looked and restarted it. One request gets away, which is the one whose record was lost; a machine that cannot say what it sent must not go on sending. The owner’s day budget is refused in the same process and from the same ledger:
tacid.policy.budgets.egressMiBPerDaymebibytes may cross the boundary in a rolling day — sent and received both, because a ceiling that counted only what left would not bound a loop that pulls, and because the stricter reading is the one already built. The count is read back from the ledger at startup so a restart does not begin the day again, and the request that crosses the line is the last one that goes through. Said this way because it used to say “may leave”, which was a promise about one number kept about another. The number itself is counted as the bytes go past rather than read off the far end’sContent-Length: a streamed answer — which is every model that replies token by token — declares no length at all and was counted as nothing, so the ceiling was blind to most of what it exists to bound; and a length that is declared is the far end’s claim about itself, which is the one party this process exists not to believe. The cost of counting at the end of an exchange is that a proxy killed mid-transfer leaves no line for it, where before it left one holding a guess — and a record that is missing is a better failure than a record that is wrong, because only one of them can be believed by mistake. -
External integrations (MCP servers, other agents) act under their own principal and policy and cannot escalate.
-
The owner can always reach a tty and boot the previous generation.
Threat model
Threats we design for, with the mitigation each maps to:
| # | Threat | Mitigation |
|---|---|---|
| 1 | A web page prompt-injects the agent | Provenance labels, taint cap, retrieved content is not authority |
| 2 | A README instructs the agent to exfiltrate SSH keys | Protected paths, egress allowlist, taint cap |
| 3 | A cloud model returns a dangerous command | Typed tools, sandboxed exec, reversibility classifier, policy |
| 4 | The agent hallucinates a destructive target | Exact target identity required |
| 5 | A third-party MCP server is compromised | Own principal, untrusted by default, policy gate |
| 6 | A package source is compromised | Pinned inputs, signed channels, signed cache, provenance |
| 7 | A model file carries malicious metadata or templates | Inference sandboxed, no network, templates ignored, signed registry |
| 8 | The owner grants an overly broad persistent permission | Budgets, level-4 expiry, visible indicator, audit |
| 9 | The privileged stem is exploited | Tiny surface, memory-safe, fuzzed protocol, keeper-only socket |
| 10 | The audit log leaks a secret | Scrubber on every audit line, handles not values |
| 11 | A firmware flash targets the wrong device | Exact target identity, confirmation |
| 12 | A generated udev rule grants excessive device access | Curated udev profiles, review of raw rules |
| 13 | A network tool sends private files unexpectedly | Egress proxy, data classes, ledger, badge |
| 14 | The rollback UI implies data was restored | Three-way rollback claims |
| 15 | The installer damages a foreign partition layout | Disko plans previewed; Apple layouts never touched |
| 16 | The agent bricks boot or security configuration | Boot-class activation, boot counting, stem refuses key changes without owner presence |
| 17 | A tool interpolates untrusted strings into a shell | No shell interpolation anywhere in the stem or tools; argv only |
| 18 | Memory becomes a persistence vector for injection | Memory carries provenance; untrusted-derived memory is never loaded as instructions |
| 19 | A compromised tacidd forges an approval | Approvals are bound to a transaction id and delivered client-to-keeper |
| 20 | A leaked signing key updates every edge machine | TUF roles: offline root, short-lived online keys, revocation by manifest |
| 21 | A compromised tacidd rewrites the owner’s files and rebuilds | The four are root-owned in a root-owned genome; the keeper refuses a generation whose constitution is not the one the owner signed |
| 22 | A channel withholds its timestamp to turn freshness off | A machine whose keyring names those roles refuses; the fallback is decided from its own keyring, never from what a server answers |
What a person may choose to contribute
Nothing about this machine is sent anywhere by the machine. There is no telemetry, there are no accounts, and no report is made without somebody making it.
One thing an owner may opt into is a tool-use trace: what was asked, which tools were called with
which arguments, and how the turn ended, so that a small model can be fine-tuned on Tacid’s own
tools. tacid.policy.keepTraces is off, and only the owner may write it. The rule the sanitizer
follows and what it cannot promise are in
docs/sanitizer.md, and the reasoning is
ADR-0074. Three
sentences of it belong here:
- Turns are kept unsanitized and never sent.
tacid trace exportwrites a file; a person hands it over. A machine that could send this on its own would be a machine whose privacy rested on its own good behaviour. - A turn that touched anything untrusted is never kept, whatever the policy says. That condition is not the owner’s to waive: a turn that read a hostile page carries that page’s words, and a corpus built out of those is one somebody wrote on purpose.
- A sanitizer cannot know that “the Bergmann account” is a name. It substitutes what this machine knows about itself — the account, the home, the hostname, the machine id — and the shapes no machine can enumerate, and it finds nothing else. The last check is a person reading the file, which is why the bundle is shown in full.
Key custody
- Channel root key: offline, on a hardware token held by the maintainer, used only to sign the root role and key rotations.
- Snapshot and timestamp keys: online in the continuous-integration signer with short expiry; rotated on schedule and on any suspicion.
- Binary cache signing key: in the continuous-integration signer; rotation invalidates nothing already installed and is announced through the channel manifest.
- Owner key: generated at install, stored in the TPM or a hardware token where available, otherwise on the encrypted disk; the recovery path is documented in the installer.
- Agent key: generated per machine, used to sign the agent’s own commits to the genome.
Out of scope for the first releases
An attacker with physical access and the disk PIN; a malicious maintainer; a malicious owner; a compromised upstream kernel; hardware side channels; and defending the owner from a model they explicitly chose to trust at autonomy level 4.