Merge remote-tracking branch 'origin/main' into decision/0272-mesh-cli-the-operators-command

This commit is contained in:
jochen
2026-10-09 13:15:36 +02:00
8 changed files with 853 additions and 2 deletions
+4
View File
@@ -214,6 +214,10 @@ and Health and repair. **Decided by** ADR 0100, 0126, 0176, 0181, 0207, 0221, 02
against this account's home and owned by it against this account's home and owned by it
([ADR 0181](../02-DECISIONS/0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md)). ([ADR 0181](../02-DECISIONS/0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md)).
Not a name a manifest carries. Not a name a manifest carries.
- **agent account** — the login every coding agent on a node runs as, when the node names one of its own;
stated on the node record at the controller's terminal, never by a verb or a setting. Empty means agents
run as the operator account. It cannot become root without a person, and the node-engine judges that on
every look ([ADR 0266](../02-DECISIONS/0266-on-the-control-node-agents-run-under-an-account-of-their-own-that-cannot-become-root-without-a-person.md)).
- **account group** — a group of the machine's group database that a module puts an account in, by - **account group** — a group of the machine's group database that a module puts an account in, by
declaring the account with that group and nothing else of it. Several modules may each add one to the declaring the account with that group and nothing else of it. Several modules may each add one to the
same account; the mesh takes back only an account group it added same account; the mesh takes back only an account group it added
@@ -0,0 +1,420 @@
---
topic: what runs on it
status: accepted
date: 2026-10-08
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md
---
# 266. On the control node, agents run under an account of their own that cannot become root without a person
## Context
Every coding agent on the mesh runs as the node's operator account
([ADR 0181](0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md)). The tool runner runs
as the same account ([ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md)),
and so does the `claude-code` module that configures the agent
([to-be 36](../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md)). The acting tools reach root through
that account's passwordless `sudo`, which the `sudo` module declares
([to-be 42](../03-DESIGN/01-to-be/42-the-machines-modules-in-order.md)).
On 2026-10-08 the `sudo` module's own check on the control node said what that account may do:
- two rules grant it every command as root without a password: the module's drop-in, and a line set by hand
in the machine's main sudoers file before the module existed;
- it is in the `docker` group, which runs a container with the machine's root filesystem mounted, so it is
root without sudo at all;
- the tool runner's bus credential is owned by it, mode 0600, and carries the grants of every module the
runner serves.
So any agent on the control node can become root without a person, and can read and use the runner's bus
credential. ADR 0259 (hq pull request 211, not yet merged)
§8, as revised after the security review of 2026-10-08, rests the router's approve warrants and a channel's
`verified-sender` on a self-check that finds **no account an agent runs as can become root without a
person**. On the control node that check fails today, and nothing measures it.
On 2026-10-08 the operator chose one of the three remedies ADR 0259 §8 offered: **on the control node, agents
run under an account of their own, without sudo. The operator's own account keeps its sudo.**
[Issue 333](../04-ISSUES/333-an-agent-on-the-control-node-can-become-root-without-a-person/00-report.md) records
the symptom.
## Considered Options
### Where the account is named
1. **A setting of the `claude-code` module.** Rejected. Any principal granted `mesh-controller.settings` may
write a setting, agents included (the same argument ADR 0259 §7 makes for the operator's identity). An
agent could name the operator's account again.
2. **A fixed name in the module's manifest.** Rejected. A definition names no installation
([ADR 0155](0155-a-definition-names-no-installation-and-how-that-is-checked.md)), and a fixed name would
apply on every node, including the operator's own machines, where the agent works in the operator's
desktop and files.
3. **A node fact, stated by the operator at the controller's terminal, beside the operator account.**
Chosen. No verb and no setting writes it, so no agent can change it.
### How the operator starts a session there
1. **`su` to the account.** Rejected. It needs a password for the account: a secret to keep, and an account
that a stolen password logs into.
2. **An ssh login as the account.** Rejected for now. It works from any terminal, but it opens a network
login into the account, needs a key placed for it, and is a second door into the account that the guard
cannot watch.
3. **`machinectl shell <account>@`.** It gives a full login session, and needs root through polkit, so it
goes through the operator's sudo anyway. Heavier than the agent needs; the operator may still type it.
4. **The operator's own sudo, `sudo -iu <account> claude`, as one command the module places
(`claude-agent`).** Chosen. The operator is the person whose sudo opens the account, and nothing else
can open it.
### Which account the tool runner runs as
1. **A service account of its own.** Rejected for now:
- the acting tools reach root through `sudo -n` as the runner's account, so a service account would need
the same passwordless sudo, and root would move rather than shrink;
- on the operator's machines the runner must be the operator's account to reach the desktop session
([to-be 38](../03-DESIGN/01-to-be/38-building-the-operators-machine.md));
- on the control node the operator's account is the person's and holds root anyway, so whatever can read
the credential as that account can become root regardless.
What the security review's first finding asks is that **an agent** cannot read the runner's credential.
An agent account of its own reaches that, and the node-engine checks it (below).
2. **Keep the runner as the operator's account.** Chosen. The credential still carries every co-hosted
module's grants. Narrowing it to one credential per bundle is separate work, and ADR 0259 rule 1 already
takes the trusted parties (the router and the verified channels) out of the runner onto accounts of
their own.
### How "cannot become root" is known
1. **Trust the configuration**: the `sudo` module's rule names only the operator's account. Rejected. One
machine showed three ways to root on one account, and two of them were not the module's.
2. **The node-engine judges the account on every look, and the controller's self-check reads the verdict.**
Chosen.
## Decision
**What this record does, and what it does not.** It moves agents off the account that holds root, and it
**detects** a way to root from the agent account; it does not **prevent** one. The node-engine judges the
ways listed in §3 and nothing else. A way it does not judge is a way it does not see, and §3 names those
too. Prevention is the account having no rights to start with: no sudo, no root group, no secret of anyone
else's. The judgement says when that stops being true.
### 1. A node may name an agent account
**A node may name an agent account: the login every coding agent on it runs as.** The operator states it at
the controller's terminal, `mesh-controller node agent-account <node> <account> [home]`, and clears it with
`--clear`. It is never a verb and never a setting. **No verb reaches it.** The controller's `command` verb,
which runs the controller's own command lines for a caller, is an allow list of commands that only read.
Everything else is refused through every verb, including a command added later, until someone judges it a
read. Otherwise an agent could clear the fact, or name itself the operator account, and the next send would
write it a sudo rule (§7 lists what `command` allows). The controller
refuses root, the node's operator account as the agent account and the agent account as the operator account,
and well-known service accounts by name. It cannot read the machine's user database, so the node-engine
refuses to take over an existing account below the first login uid (1000) as one that never becomes root. **Empty means agents run as the operator account**, as before.
The controller resolves three machine facts from it:
- `agent-account` is the agent account, or the operator account where none is named;
- `agent-home` is that account's home;
- `agent-root` is `never` only where the node names an agent account of its own, and empty otherwise.
The tool runner is given `MESH_AGENT_ACCOUNT` and `MESH_AGENT_HOME` beside the operator's.
**On the control node the agent account is `agent`.** The other nodes keep their agents as the operator for
now. The operator's machines do so because the agent works in the operator's desktop and files there, under
the risk ADR 0234 accepts. A server may follow: it is moved by stating the fact.
### 2. The account is declared and has no way to root
The `claude-code` module declares the account as a `user` resource named `${machine:agent-account}`, with
`root: ${machine:agent-root}`. The mesh creates the account with its home, its own group and no password.
No module puts it in a group. The `sudo` module's rule names only the operator account.
### 3. The node-engine judges `root: never` on every look
The node-engine judges a `user` with `root: never` on every look, by reading only, with every command in
the C locale. It finds a way to root when the account:
1. has uid 0;
2. is in a group that grants root by membership: `root`, `wheel`, `sudo`, `admin`, `docker`, `disk`, `lxd`,
`incus-admin` or `libvirt`;
3. or one of its groups has any sudo rule, as `sudo -l -U <account>` lists it, with or without a password
(the decision is no sudo);
4. or one of its groups has any doas rule (`/etc/doas.conf`, `/etc/opendoas.conf`). A machine without sudo
is not read as having no rules: doas and polkit are judged from their own rules;
5. or one of its groups is named by a polkit rule (`/etc/polkit-1/rules.d`, `/usr/share/polkit-1/rules.d`,
`/etc/polkit-1/localauthority`). That covers what `pkexec` and the service manager's own actions grant;
6. is covered by a polkit rule that grants every account: a `.rules` file that answers `polkit.Result.YES`
and names no user and no group, or a `.pkla` whose identity is every user or every group with a result
of yes;
7. can write a container runtime's socket (docker, podman, containerd), by owner, group, other or POSIX ACL;
8. can reach a container runtime's API on TCP port 2375 or 2376, which any local account reaches;
9. can read a secret the declaration places for another account, by owner, group, other or POSIX ACL. The
tool runner's credential is one of those;
10. could run a program no installed package owns that is setuid with owner root, or setgid with group root
whoever owns it, on any local filesystem mounted without `nosuid` (container and image layers, network
and pseudo filesystems excepted). The engine searches for these in the background, at most hourly, and
lets one search run for at most 15 minutes. Until the first search after the engine starts has finished,
the verdict is *not judged yet*. *Corrected 2026-10-09: this said the search gets two minutes; the engine's
bound on a search is 15 minutes, and it governs the whole search: mesh-host pull request 61 dropped a
two-minute limit that sat inside it. The package manager is asked about every path found in one call, and a
walk `find` ended with an error is incomplete unless every error was a file that vanished while it walked;*
11. lives on a machine whose kernel leaves hard or symbolic links to other accounts' files unprotected
(`fs.protected_hardlinks` or `fs.protected_symlinks` is 0).
**Not judged, so not seen** (the node-engine keeps the same list beside the judge):
- a root-run unit, timer, cron entry or script the account can write;
- a directory on root's `PATH`, or in `/etc/profile.d`, the account can write;
- root's or the operator's ssh keys or `authorized_keys` readable or writable by it;
- a file root writes or reads in a directory the account owns, other than below its home (below a home the
node-engine refuses links, §7);
- file capabilities on a program;
- a setuid program a package installed that has a flaw of its own;
- a terminal shared with a root process. `use_pty` (§7) covers sudo; it is not judged;
- a polkit rule that grants every account by logic the text does not show, or every account with an active
local session;
- a container runtime's API on another TCP port, or on a unix socket not named in 7;
- a setuid program on a filesystem not searched: a container or image layer, a network or FUSE filesystem.
Any way found makes the declaring module's account resource unhealthy, with the reason *can become root
without a person*, followed by every way found. **A question the machine did not answer is unknown, never
healthy**: a search that ran out of time, no package manager to ask, rules that could not be read. The verdict carries `root: never`, so the controller can tell this judgement from a group check.
An engine that judges it says so in its statement (contract 3). An older engine is not sent the field,
because it parses strictly and would refuse the whole declaration.
### 4. The controller's self-check says it, and ADR 0259 reads it
The self-check holds an invariant: every node that names an agent account has it judged, on its engine's
newest statement, unable to become root without a person. **A verdict heard more than 15 minutes ago is not a
judgement**: an engine states its health at least every 5 minutes, so three missed statements mean it has
stopped, and an agent that stops the engine must not freeze a healthy verdict. The age is measured on the
controller's own clock. Otherwise it raises the urgent condition
`agent-can-become-root`, which says what to change. `mesh-controller.node` shows the verdict and when it was
judged.
**This is the fact ADR 0259 §8 rule 2 reads.** On a node with an agent account of its own, a healthy verdict
means no account an agent runs as can become root without a person. The guard (§6) makes the agent account
the only one an agent works in there. On a node without an agent account, agents run as the operator
account, and the check holds only where that account cannot become root either. The rule and the code that
answers it are one function in the controller, which the router's check will call.
### 5. The agent is configured for its own account
The managed directory under `/etc` is unchanged and machine-wide. The licence token, the account the agent is
logged in as, an API-key licence and its key-helper, and the items registered at the home scope all go to
the agent account's home.
The tool runner cannot enter that home. So these files are written as the agent account, by a helper: the
`claude-code` module's own binary as the managed directory carries it, root's, started through the runner
account's sudo. **The escalation goes down only.** The runner's account may become the agent's, and the
agent's may become nothing.
When a node moves its agents, the licence follows at once. What the module placed in the operator's home
goes, as [ADR 0182](0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md) allows
for what the mesh placed. A login the person made there stays.
### 6. The guard refuses a session that runs as another account
The guard on the agent's shell
([ADR 0245](0245-a-verb-says-what-it-replaces-and-the-agent-is-guarded-from-working-round-the-mesh.md)) is
asked about every tool, not only the shell and file edits. On a node with an agent account it refuses every
tool call of a session that does not run as that account: the shell, edits, reads and the mesh's tools alike.
No override lifts it. An agent started as the operator out of habit can do nothing there, and is told to use
`claude-agent`.
Its record goes to the machine's journal, because the agent account cannot enter the module's state where
the record lives elsewhere. **The record is append-only, not authentic.** The agent account cannot remove a
line, and the module reads only the lines the journal says the guard's own binary wrote, so `logger` cannot
add one. But the agent account can run the guard's binary itself and add lines in its words. A line in the
record is the guard's words, not proof that a session was refused.
### 7. How a session starts, and what the agent may still do
The operator starts a session with `claude-agent`, which runs `claude` as the agent account through the
operator's own sudo. Work that needs root is the operator's, in their own shell.
The agent may do everything the mesh MCP server serves, with the tool runner's grants, as an agent does on
any node. It may not run anything as root, use the container runtime, enter the operator's home or read the
runner's credential.
`claude-agent` runs the agent through sudo, and the `sudo` module's drop-in sets `use_pty`, so the command
gets a terminal of its own. Otherwise the agent shares the operator's terminal and could push keystrokes into
the operator's shell after it ends (the `TIOCSTI` call).
**The tool runner acts for any caller of the mesh MCP server, the agent included, so every tool that touches
a path is a way round this record unless it is bounded.** Four were found in review, and each is closed
here:
- the controller's `command` verb ran any of the controller's command lines. It could set the agent account
(§1), set the operator key, issue a credential or hand out a secret. It is now an allow list of reads:
`status`, `version`, `help`, `seats`, `healers`, `hand-acts`, `durations`, `collection`, `images`,
`artifacts`, `data`, `builds`, `queue`, `plan`, `plans` but not its go, stop, close or retry, wherever the word stands in the line, `doctor` and its
probes and signals but not `doctor run`, `conditions` and its list, show and history but not silence,
`node list` and `node show`, `module list`, `settings show` and `settings preferences`, `retire list`,
`cleanup list`, `delivery plan` and `delivery walks`, `bus` alone, and `mirrors` without recording. Every
route refuses `operator`, `identity`, `broker`, `api`, `licence` and every `secret` subcommand but `rotate`,
which seals to the machine that uses the secret, never to a key the caller chooses. **The `token` verb is
refused too**, because it handed the joining token to its caller. Enrolling a machine is now done at the
controller's terminal, by the operator;
- `claude_code_add_api_key` read a caller's file as the operator, sealed it away and deleted it. It now takes
only a file under the agent's own home, reached through no symbolic link, read as the agent, at most 4 KiB,
holding an Anthropic API key. The licence manager refuses a key of another shape too;
- the node-engine, as root, changed the owner and mode of the agent's `~/.claude` by following its path. A
symbolic link put there by the agent would have pointed root at any file. Below a home it now refuses a
symbolic link in the path, opens each part without following a link (a directory as a directory), and
refuses an opened file of the wrong kind or with more than one hard link before it changes owner or mode
on it. It still unpacks an archive, and writes into an existing file, only after a check of the path, so
it refuses either below the home of any account the declaration says never becomes root, and the
catalogue's own check refuses either under `${machine:agent-home}`, so no module asks for that gap;
- the backup's restore wrote, as root, beside the live data. Beside a directory another account owns, such
as the agent's home, it now restores into its own module's state instead.
A tool that runs a caller's command as root, or reads, writes or removes a caller's path as the operator or
as root, is a defect of that tool, found by review of its manifest and code.
**Where the boundary stands, 2026-10-09.** *Added 2026-10-09, after review of the routes found since this
record was written; what was decided above is unchanged.* The tool runner acts for any caller of the mesh MCP
server, so the agent account's boundary is only as good as the tools that caller reaches. These routes were
found around it and are closed on the mesh's main line:
- **The store's reader** ([issue 337](../04-ISSUES/337-the-stores-reader-can-read-the-controllers-signing-key/00-report.md)):
`mesh-store.query` could read every sealed secret and the key that signs every node's declaration, so any
caller could forge what a node applies as root. The reader now reads only what a module declares readable,
never a secret. Live on the control node and the home server since 2026-10-08.
- **A module's places and accesses, and the keys a provider serves**
([issue 339](../04-ISSUES/339-a-verb-can-place-a-directory-at-the-machines-own/00-report.md)): a settings
call could have the node-engine, as root, hand a system directory to any account, or mount one into a
container. Those keys, every key a provider serves, every setting a trusted file asks for, and the whole
layer of a module with a trusted mergeable file are now set at the controller's terminal alone. The
node-engine refuses a placement or a mount at the machine's own trees, root's or another account's
directory below a home, and uses a directory it did not make as found. This record's own copy of that
engine guard was dropped for it; the one rule kept here is the archive and written-into file below an
agent's home (§7).
- **What every Claude Code session obeys**
([issue 340](../04-ISSUES/340-an-agent-can-plant-a-hook-in-every-agent-session-on-the-mesh/00-report.md)):
an agent could register a hook, managed settings, a permission rule or a tool server for every node, and
have it run as the operator at the operator's next session elsewhere. Those, and shared agent text, are now
set at the controller's terminal alone. The home scope is written only into the home of an agent account
of its own, on the node serving the call, never into the operator's home.
- **The login shell's `execute` on the control node**
([ADR 0268](0268-the-login-shells-execute-is-withheld-on-the-control-node-until-a-call-to-it-needs-a-persons-approval.md)):
it ran any command as the operator account. The control node withholds it; the setting that withholds it
is a trusted file's, so it is changed at the terminal alone.
**What still remains, said plainly.** None of these is closed by this record or its pull requests:
- **The login shell's `execute` on the other nodes**, and the desk's `node-terminal-emulator.open` and
`rofi_run`, run a caller's command as the operator account of their node, which has passwordless sudo
there. So an agent on the control node can still become root **on another node** through the mesh MCP
server. Whether the operator account on one of those nodes holds a key that logs into the control node
was not measured; where one does, this is also a way back to root on the control node. ADR 0268 §4 closes it
once a call to such a tool needs a person's approval (research 039); until then, withholding `execute` on
every node is the operator's choice, at the terminal.
- **Tool servers already on the bus** keep rendering until their own change lands (issue 340, "still
open"). Text written straight into the bus state needs a tool runner's credential, which the agent account
cannot read.
- **The tool runner on the control node is still the operator account** (Considered Options), so a tool
found tomorrow that reads, writes or runs a caller's path or command is a way round this record until it is
bounded. Each is a defect of that tool, found by review.
- **A build from another repository registered a module** (found in review, 2026-10-09). The controller's
`build` verb cloned any repository as given, and the outcome registered the module its `module.json` named,
from that repository: a module is registered by name and its source overwritten, and "only a commit on the
trunk is published" (ADR 0238) checked the trunk of the repository built. An agent that made a repository,
or forked the catalogue or the node-engine, and committed a `sudo` module granting itself a rule without a
password, had the next push make it root on every node. `rebuild`, `replay --register` and `assign` with
`build` reached the same take-in. Closed by mesh-controller pull request 174, once merged after pull request
164: through any verb, an outcome registers a module only from the repository the catalogue already builds
it from — the same repository by the forge's own id, so one deleted and made again under its name is
refused — and a new module only from a repository the catalogue already builds another from (a merge adding
one); and only from the mesh's forge, where that repository's trunk refuses direct pushes, requires a status
and lets no administrator merge past one. Anything else only when the build was asked at the controller's
terminal, for that repository and path; the serving controller marks itself and everything it starts, so
none of it reads as the terminal. Through a verb, a build of a repository the catalogue builds nothing from
is not asked at all, and a failed gate puts back only a build of the module's own repository.
- **The forge's tools could change the forge's protection** (found in review, 2026-10-09). `gitea_api` sent
any call with a token minted for the forge's administrator, `write:admin` included: an agent could drop a
branch's protection or its required checks, set the merge check's status, write to the default branch,
delete and recreate a repository the mesh builds from, or merge its own pull request past the checks; and
`gitea_branch_protection_set` could weaken a rule. Closed by mesh-catalog pull request 153: `gitea_api`
reads anything but the forge's administration and writes only what an author writes, never a pull
request's base; protection is only made stronger, a rule named for a branch included; a merge goes only into
a branch whose protection requires the checks, needs every required status a success, and merges only the
head that was checked; a tool deletes only a repository of its own user that no rule guards and that had no
commit in 30 days, named in full; and the token is minted without `write:admin`. The token still belongs to
the forge's administrator; a non-admin account for the tools is a follow-up.
- **An agent can merge its own pull request.** Every mesh repository's trunk requires its checks and no
approval (0 required approvals), and the forge's merge tool merges as the forge's administrator once the
checks pass. Nothing on the forge checks that the operator said yes: the operator's approval in chat is a
habit of the session, not a rule anything enforces. A merged module is what the next push sends, so an agent
that writes a change the checks pass can deliver it without a person. Whether to require an approval on the
forge is the operator's decision, not yet taken.
- **Every node-engine restart raises `agent-can-become-root`**, and the fix proposed for it must not open a
quiet of its own. Until the engine's first search for setuid programs finishes (§3 item 10), the verdict is
*not judged yet*, and the self-check (§4) raises the urgent condition on every restart. Mesh-controller pull
request 175 (merged after 174) keeps the condition quiet while that search is within its bound, counted from
when the controller first saw it waiting, kept in its store and forgotten at the next complete verdict —
never from the engine's own time, which starts again at every restart, so an agent that restarts the engine
in a loop does not keep it quiet. The account is still not confined, `node show` still says not judged, and
the condition is raised at once when the search fails or finds a way to root. Until that pull request is
merged, every restart raises the urgent condition.
- **The self-check judges this record's account only.** ADR 0259's own root probe (mesh-controller pull
request 154) asks the same question through the `sudo` module; it must call this record's judgement, one
function, rather than keep a second answer.
The agent account holds a forge key of its own, registered by the operator, so its pushes are its own. It
works on branches and pull requests; what it may merge is said under *What still remains*.
## Consequences
- The operator types `claude-agent` on the control node instead of `claude`. Typing `claude` by habit gives a
session that can do nothing and says why.
- The agent's memory and history start empty in the new home. Copying what the operator wants from their own
home is the person's act (ADR 0182).
- The agent clones into its own home and cannot write the operator's checkouts.
- **What got harder:** anything an agent did on the control node through sudo is now a verb, or the
operator's act in their own shell. A missing verb is built in the module that owns the area (ADR 0245),
never worked round.
- **Root that remains:** the operator account keeps its passwordless sudo, and its docker group, so whatever
runs as it can still become root: the tool runner and the operator's own shells. No agent runs as it.
- **Not judged, said plainly:** the ways §3 lists under *not judged*. Each is a way to root this record does not
see. None is known on the control node, and each is left for a later record.
- The tool runner's credential still carries every co-hosted module's grants. The agent cannot read it.
- ADR 0181 decided one account per node. This record adds a second, of a different kind: not a person's
login, but the one agents run as.
- ADR 0259 §8's choice is made. That record can cite this one when it is accepted. What it can rely on is a
detection, as said at the head of the Decision: a way to root §3 does not judge is not seen.
## How it is checked
| Rule | Checked by |
|---|---|
| The agent account is stated only at the controller's terminal, never by a verb or a setting | controller test: the `node` verb's command line is `node show` alone; `node agent-account` refuses root, the operator's account and a malformed name |
| The machine facts fall back to the operator account, and `agent-root` is `never` only for an account of its own | controller tests on machine-fact resolution and the tool runner's environment |
| An older engine is not sent `root` | controller test: a user resource's `root` is taken out for an engine whose statement says contract 2 or less |
| The node-engine judges each way §3 lists, before any session logic, in the C locale, and an unanswered question is never healthy | node-engine tests over a fake reader, for each way and for none, for doas without sudo, for a search that ran out of time; a parsing test of `sudo -l -U` |
| `command` runs only reads, and no verb sets an account, the operator key or a credential, hands out a secret or the joining token | controller tests: about 60 writing, issuing or revealing lines refused through `command` and the named verbs; about 33 reads pass |
| No archive and no file written into is placed under the agent's home | the catalogue's `checks/agenthome`, in its `merge-check.sh`; node-engine test: an archive or a written-into file below the home of a `root: never` account is refused, a whole file passes |
| Root follows no link below a home | node-engine tests: a linked `~/.claude`, a linked parent, a file that is a link, a link swapped in after the check, a hard-linked file, an opened file of the wrong kind |
| A restore beside a directory another account owns goes to the module's state | backup module test |
| `claude_code_add_api_key` takes only an API key from the agent's own home | `claude-code` tests: a path outside, `..`, a link, a file that is not a key; nothing removed, nothing repeated; licence manager test refusing a key of another shape |
| An agent account that can become root, or is not judged, raises `agent-can-become-root`; a verdict older than 15 minutes is not judged | controller self-check probe tests: pass, fail, not judged, old engine, at the bound and a second past it |
| An existing system account is not taken over as the agents' | node-engine test: a `root: never` user naming an account with uid below 1000 is refused, nothing changed |
| The agent's files are written as its account, and nothing under its home is touched as the runner's | `claude-code` tests: every operation under the agent's home goes through the helper, none outside it does; a helper that died is started again |
| The licence follows a move, and what the module placed in the operator's home goes unless a person made or changed it | `claude-code` tests: a binding applied is fetched again when the agent's home holds no token; an access-token-only file goes, a login stays, a changed home item stays |
| The guard is asked about every tool; a session not running as the agent account does nothing there, with no override; the record keeps only lines the guard's binary wrote | `claude-code` guard tests |
| End to end | on the control node, after the steps in to-be 36 §9: the self-check passes its new invariant, `claude-agent` starts a session that lists the mesh MCP server's tools and holds its licence, `sudo -n true` in it fails, and `claude` started as the operator is refused at its first command |
## References
- ADR 0259 (hq pull request 211, not yet merged) §8
(hq pull request 211, revised after the security review of 2026-10-08)
- [ADR 0181](0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md) (the operator account),
[ADR 0182](0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md) (what the mesh owns
in a home), [ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md) (the
tool runner as the operator account), [ADR 0245](0245-a-verb-says-what-it-replaces-and-the-agent-is-guarded-from-working-round-the-mesh.md)
(the guard), [ADR 0252](0252-a-module-puts-an-account-in-a-group-and-the-mesh-says-when-a-new-login-is-needed.md)
(the node-engine's account judge this extends)
- [To-be 36](../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md) §9, the design this amends
- [Issue 333](../04-ISSUES/333-an-agent-on-the-control-node-can-become-root-without-a-person/00-report.md)
- the `sudo` module's `sudo_check` on the control node, 2026-10-08
@@ -2,8 +2,9 @@
layer: to-be layer: to-be
status: in-progress status: in-progress
code: [mesh-catalog modules/claude-code] code: [mesh-catalog modules/claude-code]
updated: 2026-10-07 updated: 2026-10-09
decisions: decisions:
- 02-DECISIONS/0266-on-the-control-node-agents-run-under-an-account-of-their-own-that-cannot-become-root-without-a-person.md
- 02-DECISIONS/0243-the-agent-module-removes-a-home-item-it-did-not-place-only-on-the-persons-word-and-keeps-a-copy.md - 02-DECISIONS/0243-the-agent-module-removes-a-home-item-it-did-not-place-only-on-the-persons-word-and-keeps-a-copy.md
- 02-DECISIONS/0245-a-verb-says-what-it-replaces-and-the-agent-is-guarded-from-working-round-the-mesh.md - 02-DECISIONS/0245-a-verb-says-what-it-replaces-and-the-agent-is-guarded-from-working-round-the-mesh.md
- 02-DECISIONS/0216-the-agents-configuration-is-registered-through-its-module-at-three-scopes-and-served-as-one-plugin.md - 02-DECISIONS/0216-the-agents-configuration-is-registered-through-its-module-at-three-scopes-and-served-as-one-plugin.md
@@ -282,6 +283,86 @@ An item and its files are one value in the state, refused above 256 KiB.
own settings, and the module does not route around that refusal. A settings tool is called on the own settings, and the module does not route around that refusal. A settings tool is called on the
operator's word. operator's word.
## 9. The account the agent runs as
*Added 2026-10-08 by [ADR 0266](../../02-DECISIONS/0266-on-the-control-node-agents-run-under-an-account-of-their-own-that-cannot-become-root-without-a-person.md).*
Everywhere above, "the operator's home" and "the operator account" mean **the account the agent runs as**.
That is the operator account, unless the node names an **agent account** of its own. The control node does.
**The fact.** The operator states the agent account at the controller's terminal. No verb and no setting
writes it. The controller resolves the machine facts `agent-account`, `agent-home` and `agent-root`, and gives
the tool runner the account and its home. Where the node names none, the facts name the operator account and
`agent-root` is empty, so nothing changes there.
**What the module declares.**
- A `user` named by `agent-account`, with `root` set from `agent-root`. Where the account is the agent's own,
the mesh creates it (home, own group, no password) and the node-engine judges on every look that it cannot
become root without a person, by the ways ADR 0266 §3 lists. It detects those ways and no others; it
prevents nothing.
- `~/.claude` under that account's home, owned by it.
- `claude-agent`, a command that starts the agent as that account through the operator's own sudo, or
directly when already run as it.
**What the module's code does differently.**
- The credentials file, the account file, an API-key licence with its key-helper (in a directory of the
module's under the agent's `~/.claude`), and the home-scope items go to that account's home.
- The tool runner cannot enter that home, so the code touches it through a helper running as the agent
account. The helper is the module's own binary, root's, under the managed directory, started through the
runner account's sudo. The escalation goes down only.
- A binding counts as applied only while the agent account holds its token, so a move takes the licence with
it at once.
- Once the agent's home holds the licence, and not before, what the module placed in the operator's home
goes: the access-token-only credentials file, and the
home-scope items not changed by hand. A login the person made there stays.
**The guard.** The guard is asked about every tool. On such a node it refuses every tool call of a session not
running as the agent account, with no override. Its record goes to the machine's journal, append-only but
not authentic (ADR 0266 §6).
**An API key comes only from the agent's home.** `claude_code_add_api_key` takes a file under the agent
account's home, reached through no symbolic link, read as that account, holding an Anthropic API key, or it
refuses and removes nothing. A caller's path anywhere else would let any caller of the mesh MCP server carry
off the tool runner's credential.
**The self-check.** The controller holds an invariant: every node naming an agent account has it judged
unable to become root without a person. When it does not hold, it raises `agent-can-become-root`, urgent. ADR
0259 §8 reads the same judgement before the router issues an approve warrant.
**The operator's day on the control node.** The operator works in their own login, with their own sudo, and
types `claude-agent` to start an agent. The agent runs in its own home, with its own clones and its own forge
key. It reaches the mesh through the mesh MCP server as before, and asks the operator for anything that needs
root that no verb serves.
**Steps on the control node, in this order.** The guard refuses the operator's own Claude sessions there from
the send in step 3, so everything that needs one comes before it. The operator's own shell, and its sudo,
are never refused: the way back never needs Claude.
0. **Before any of it**, the routes round the boundary that ADR 0266 §7 names as closed are closed and
running: the store's reader (issue 337), places, accesses and served keys at the terminal (issue 339),
what every session obeys at the terminal (issue 340), and `execute` withheld on the control node
(ADR 0268). All four ran on 2026-10-09; the tool servers already on the bus stop rendering with their
own change. What ADR 0266 §7 says still remains is the operator's to weigh before step 3.
1. The node-engine, the controller and this module are built with ADR 0266 and sent to every node, in this
order: the node-engine first, until every engine says contract 3; then the controller, its node-engine
pin moved to the engine's merged commit; then the module's code; then its manifest, which the running
controller refuses until it knows the new machine facts. Nothing changes on any node.
2. Before naming the account, the operator finishes or ends every Claude session running as themselves on the
control node; from step 3 on they can do nothing.
3. The operator states the agent account at the controller's terminal. The next send creates the account,
places `~/.claude` and `claude-agent`, fetches the licence into the agent's home, and only then removes the
token the module placed in the operator's home. The guard there starts refusing sessions as the operator.
4. The operator starts `claude-agent`, checks that the session lists the mesh's tools and holds its licence,
and that `sudo -n true` fails in it.
5. The self-check's new invariant passes, and `mesh-controller.node` shows the account judged.
6. The operator generates a forge key in the agent's home and registers it.
**The way back**, if step 4 or 5 fails: the operator, in their own shell, clears the fact at the controller's
terminal. The next send names the operator account again: the guard lets the operator's sessions through,
and the licence is fetched into the operator's home. The agent account is kept (the node-engine never deletes
a login).
## How it is checked ## How it is checked
| Check | Defends | | Check | Defends |
@@ -297,11 +378,15 @@ operator's word.
| live: a skill registered at the mesh scope is offered as `nox-mesh:<name>` in a new session on each machine | ADR 0216 | | live: a skill registered at the mesh scope is offered as `nox-mesh:<name>` in a new session on each machine | ADR 0216 |
| the module's test: the "instead of" table is generated from the controller's answers, ordered and cut; the guard refuses ssh to a mesh machine by name, domain, internal name, address, alias and jump, and the local work-arounds for a mesh name, naming the tool — and lets git to the forge, ssh beyond the mesh and a command merely mentioning ssh through; the override is honoured only from the session's start and only when recorded | ADR 0245 | | the module's test: the "instead of" table is generated from the controller's answers, ordered and cut; the guard refuses ssh to a mesh machine by name, domain, internal name, address, alias and jump, and the local work-arounds for a mesh name, naming the tool — and lets git to the forge, ssh beyond the mesh and a command merely mentioning ssh through; the override is honoured only from the session's start and only when recorded | ADR 0245 |
| the mesh MCP server's provision resolves by co-location; a machine without the mesh MCP server refuses the module by name | ADR 0027, ADR 0152 | | the mesh MCP server's provision resolves by co-location; a machine without the mesh MCP server refuses the module by name | ADR 0027, ADR 0152 |
| the account the agent runs as: the facts fall back to the operator account; an agent account of its own is judged unable to become root, or raises `agent-can-become-root`; its files are written only through the helper; a session as another account is refused with no override | ADR 0266 |
| a new session on the assigned workstation lists the mesh MCP server's five tools under `mesh` ([ADR 0195](../../02-DECISIONS/0195-the-meshs-tools-are-found-by-address-not-announced-whole.md)) and answers "which node am I" from the instruction file | the exit of the build | | a new session on the assigned workstation lists the mesh MCP server's five tools under `mesh` ([ADR 0195](../../02-DECISIONS/0195-the-meshs-tools-are-found-by-address-not-announced-whole.md)) and answers "which node am I" from the instruction file | the exit of the build |
## What this does not settle ## What this does not settle
- **Several operator accounts on one node** (ADR 0181 decides one). - **Several operator accounts on one node** (ADR 0181 decides one). ADR 0266 adds a second account of
another kind, the one agents run as, which is no person's login.
- **An agent account on the operator's machines.** They keep their agents as the operator, because the
agent works in the operator's desktop and files there (§9).
- **A worker's own licence on a machine.** Every interactive session shares the node's one agent - **A worker's own licence on a machine.** Every interactive session shares the node's one agent
directory and its licence, however many run. A worker runs from a home of its own with an agent directory and its licence, however many run. A worker runs from a home of its own with an agent
directory in it, bound to its own licence through the manager (to-be 39 §5); that is for when workers directory in it, bound to its own licence through the manager (to-be 39 §5); that is for when workers
@@ -0,0 +1,56 @@
---
status: located
opened: 2026-10-08
located-in: [mesh-catalog modules/claude-code, mesh-catalog modules/sudo, mesh-controller internal/catalogue, mesh-host internal/accounts]
fixed-by:
amended-design: 03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md
---
# 333. An agent on the control node can become root without a person
## Symptom
On 2026-10-08 the `sudo` module's `sudo_check` on the control node answered, for the account every coding agent
there runs as:
- `passwordless: true`, decided by the module's drop-in, which grants the operator account every command as
root without a password;
- a second grant of the same, set by hand in the machine's main sudoers file before the module existed;
- the groups `docker` and the account's own. Membership of `docker` is root without sudo: a container started
with the machine's root filesystem mounted.
So every agent session on the control node can become root without a person. It runs as the same account as
the tool runner, so it can also read the runner's bus credential, which carries the grants of every module the
runner serves.
## Why it matters
ADR 0259 (hq pull request 211, not yet merged)
§8, revised after the security review of 2026-10-08 (hq pull request 211), lets the router issue an approve
warrant, and honours a channel's `verified-sender`, only on a machine where the self-check finds that no account
an agent runs as can become root without a person. The router and the first channel are placed on the control
node. There the condition fails, so an agent could publish a warrant, or answer for the operator, with the
runner's credential or as root.
## What the design let pass
- **Nothing measured it.** The `sudo` module reports what the operator account may do. No check asks which
account an agent runs as, or whether that account can become root.
- **One account did three jobs**: the person's login, the tool runner, and every agent. ADR 0181 decided one
account per node and left several open.
## Where it is located
| part | owner |
|---|---|
| which account the agent runs as, and where its files go | mesh-catalog `modules/claude-code` |
| the operator account's rule | mesh-catalog `modules/sudo` |
| a node naming a second account, and the self-check's invariant | mesh-controller `internal/inventory`, `internal/catalogue`, `cmd/mesh-controller` |
| judging whether an account can become root | the node-engine, `mesh-host` `internal/accounts` |
## The decision
[ADR 0266](../../02-DECISIONS/0266-on-the-control-node-agents-run-under-an-account-of-their-own-that-cannot-become-root-without-a-person.md):
on the control node, agents run under an account of their own. The node-engine judges on every look that the
account cannot become root without a person, and the self-check says it urgently when it can. The operator's
account keeps its sudo.
@@ -0,0 +1,64 @@
---
status: diagnosing
opened: 2026-10-09
located-in: []
fixed-by:
amended-design:
---
# 345. A calendar reminder shows as a transparent, empty window, and the mesh had no tool to look at it
## Symptom
On 2026-10-09 the operator reported that on the workstation, when an Outlook (Exchange) meeting is
due, the mail client's reminder window appears, but it is transparent and shows no content: no title,
no time, no Dismiss or Snooze. The mail client is Betterbird (a Thunderbird fork, installed from the
AUR), with the Exchange calendar through the Owl add-on. The workstation runs i3 and the `picom`
compositor (glx backend, vsync, fading). No module owned Betterbird.
The operator's view of done: *on the workstation, when an Outlook meeting is due, the reminder window
shows the meeting (title, time, Dismiss/Snooze) on a normal background, not a transparent empty window,
and the mesh has tools to see and fix Betterbird.*
## Reported history
The operator reports (2026-10-09) earlier trouble with picom on the workstations, never recorded: a chat
window (Telegram) froze, not redrawn, after a change of i3 workspace. `records_search` for picom finds
nothing about it. It is the same kind of failure as this one, a window the compositor shows without its
content, and it makes picom the first suspect here.
## Why it is an issue
Two gaps, and the symptom itself.
1. **A desktop application the operator uses every day ran outside the mesh.** Nothing could say which
window the reminder is, what the compositor makes of it, or which preferences the mail client runs
with. A change to it could only be made by hand in its profile, which the mesh's rules forbid and
which nothing would keep.
2. **The tools that look at a screen could not look at this window.** The window manager's list
(`node-display-session.windows`) leaves out every window it does not tile, the reminder among them,
and says nothing a compositor decides by (a window's depth, type, opacity). The screenshot tool of the
display server failed for one window with `X Error BadColor (invalid Colormap parameter)
X_QueryColors`, because it dumped the window by its id.
3. **The symptom's cause is not known yet.** A reminder that shows nothing is a missed meeting.
The diagnosis is in [01-diagnosis.md](01-diagnosis.md).
## Impact
- The operator cannot read or dismiss a meeting reminder: the window is there but empty.
- Until now, the next session that looked at it had to work around the mesh to see a window.
## What is done
- The display server's module gains a window list that includes unmanaged windows with their depth,
type, state, role and own opacity, and its screenshot of one window is cut from the screen as the
compositor shows it (catalogue pull request, linked from this record's pull request).
- A `betterbird` module: status, named preferences without credentials or accounts, a user.js declared
through the module's setting, its log, restart, check, and a reminder on demand from a throw-away
profile, so a fix can be proved without touching the operator's calendar.
- The `picom` module's backend and damage tracking become settings of each machine (defaults unchanged:
`glx`, `use-damage` true), so the candidate fixes can be set and proved without a hand edit.
What is not done: the cause, and so the fix. See the diagnosis.
@@ -0,0 +1,106 @@
# 345 — Diagnosis
All times 2026-10-09, local time of the workstation. "Measured" is what a tool answered; "guess" is
labelled as one.
## The record first
`records_search` for the symptom's words, `betterbird`, `thunderbird`, `picom transparent` and
`BadColor X_QueryColors xwd`: no hits. Nothing in the record covered this.
## What was measured
**The client.** `betterbird-bin` 153.1.0esr-1 (application 153.1.0, build 20260820183427), foreign to the
official repositories (from the AUR), on the workstation and on the laptop. On the workstation the main
process had run since 2026-10-04 16:05, in the login session's scope: started by hand from the launcher,
with no autostart entry and no line in i3's configuration. Its standard output is the login session's
pipe that nobody reads (the same pipe as the compositor's); its standard error is `/dev/null`. So nothing
it printed in five days was kept.
**The profile in use.** Betterbird holds the installation's default profile open (99 open files), named
by `[Install…] Default` in `profiles.ini`. Another profile is marked `Default=1`; it is not the one in
use. The profile has no `user.js`.
**Preferences that bear on how a window looks** (prefs.js, names and values only):
`browser.display.use_system_colors` true; `extensions.activeThemeID` the built-in compact dark theme;
`browser.theme.content-theme` and `toolbar-theme` 0; `mail.dark-reader.enabled` false. No `gfx.*`,
`layers.*`, `widget.*` or `alerts.*` preference is set, so the renderer and the notification path are
Betterbird's defaults. The one add-on the operator installed is Owl (Exchange). The reminder dialog's
saved place (`xulstore.json`) is 600×300 on the right-hand monitor.
**The renderer.** Both the long-running process and a fresh one load the GL libraries and run
WebRender's threads (`Renderer`, `WRRenderBackend`, `Compositor`): hardware WebRender, in-process.
The workstation did not suspend since boot, and the journal holds no GPU reset.
**The reminder window.** A reminder was raised without the operator's calendar: a second Betterbird with
`--no-remote` in a throw-away profile under the account's runtime directory, holding one local calendar
file with one event whose reminder was due 30 to 40 seconds after the start. Four runs:
| run | profile | window | depth | drawn |
|---|---|---|---|---|
| 1, 11:43 | Betterbird's defaults | class `Calendar` / `eu.betterbird.Betterbird`, role `AlarmWindow`, type `DIALOG`, 600×300, floating | 32 | not captured: it was snoozed from the desk at 11:44, before the image was taken (the calendar file recorded the snooze) |
| 2, 11:49 | the same; the snoozed reminder again | the same | 32 | **yes**: title, time, Snooze for, Dismiss, on a dark background |
| 3, 11:52 | with the operator's theme, `use_system_colors`, fonts and the dialog's saved place | the same | 32 | **yes** |
| 4, 11:54 | as 3, with `mozilla.widget.use-argb-visuals` false | the same | **still 32** | yes |
Each image was taken from the whole screen at the window's place and looked at. Every Betterbird window
on the workstation has depth 32 (an ARGB visual), the long-running one's mail window included.
**The graphics stack** (2026-10-09, read on the workstation; no mesh tool answers it yet, so it was read
with the login shell's command verb): an AMD Radeon RX 6950 XT (Navi 21) on the kernel's `amdgpu` driver,
the X server's `amdgpu` driver (`xf86-video-amdgpu` 25.0.0) on X.Org 1.21.1.24, Mesa 26.2.4 (`radeonsi`),
kernel 7.2.8, picom 13. picom runs with the `glx` backend, vsync, fading, and damage tracking at picom's
default (on).
**The screenshot tool's failure.** `xorg_screenshot` of the whole screen worked; of one window it
dumped the window by its id (`xwd -id`): that is the call reported failing with BadColor, and here it
answered BadWindow for a window that had just closed.
## What follows from it (guesses, each said as one)
- **Why "transparent" rather than black.** A window of depth 32 that its program never drew holds
zero alpha, and picom blends it with what is behind it: it looks transparent, with i3's frame around
it. That matches the report exactly. So the window is very likely **mapped but not painted** by
Betterbird. That is a guess from how compositing works; it was not seen, because the symptom did not
happen in four runs.
- **Not the preferences found.** The operator's theme and colour switch drew the reminder correctly in
a fresh process (run 3). The preference that would take away the alpha visual does not change the
depth in this version (run 4), so it is no lever.
- **Candidates not yet ruled out, the first suspect first:**
1. **picom.** The operator has seen picom leave a window stale after a workspace switch before (the
reported history). picom's `glx` backend with vsync and damage tracking on (its default) is a known
source of windows left unpainted or stale on some drivers: picom repaints only the regions it was
told changed, and a missed damage event leaves a window's area as it was, for a new window empty.
That fits a reminder that appears while the operator is on another workspace. Not yet seen
happening;
2. the long-running Betterbird: five days of uptime, with WebRender's per-window state recreated many
times (its thread names count to 15). A fresh process drew every reminder;
3. Owl's reminders, which the test cannot raise without the operator's account;
4. the moment it appears: a reminder raised while a full-screen window holds the focused workspace
(Slack and a game were full screen on the day), or on the right-hand monitor where it was saved.
## What would decide it
At the next real reminder that shows empty, before touching it:
1. `xorg_windows` with class `Calendar` on the workstation: the window's map state and depth.
2. `xorg_screenshot` with that window and `inline`: what the screen shows.
3. **`picom.picom_toggle` with `on` false: compositing off.** If the reminder's content appears, picom is
the cause. This needs no restart of Betterbird and loses nothing. `xorg_screenshot` again, then
`picom_toggle` with `on` true to put compositing back.
4. Only if the content does not appear with compositing off: `betterbird_status` (how long it has run),
then `betterbird_restart`. It refuses while a composer window is open, because the unsent message
would be lost; `discard_unsent` is given only on the operator's yes. Then `betterbird_reminder_test`
with `carry` of the theme shows whether a fresh process draws it.
**If picom is the cause**, the candidate fixes are the `picom` module's settings for that machine, never a
hand edit: `use-damage` false first (repaint the whole screen each frame), else `backend` `xrender`. Each
is proved on the workstation before the operator relies on it: set it, `picom_restart`, raise a reminder
with `betterbird_reminder_test` while on another workspace, switch back, and look with `xorg_screenshot`.
The reported chat-window freeze is checked the same way.
**If a fresh Betterbird draws it and picom is not the cause**, the fix is in Betterbird or its renderer, not
the mesh; a preference to try then (`gfx.webrender.software` true, declared through the `betterbird`
module's setting) is proved with the same test.
Until then this record stays `diagnosing`, and the acceptance sentence is not met.
@@ -0,0 +1,58 @@
---
status: located
opened: 2026-10-09
located-in: [mesh-controller (internal/catalogue/setting_into.go settingInto, internal/catalogue/bound_into_files.go plainly, internal/catalogue/terminal_keys.go), mesh-catalog (every file a module marks "trusted": false)]
fixed-by:
amended-design:
---
# 346. A setting a verb may set is pasted raw into its file, so an untrusted file is an injection point
**Severity: high.** Through a file whose format has a statement separator or quoting, any caller of the
settings verb can add a line of its own to a program's configuration.
## Symptom
A review on 2026-10-09 found this in a pull request (mesh-catalog #157, the `picom` module's `backend` and
`use-damage` settings, issue 345), before it merged. Nothing was run against a node.
The pull request marked `picom.conf` `"trusted": false`, so that a machine's backend could be set through
the `settings` verb ([issue 339](../339-a-verb-can-place-a-directory-at-the-machines-own/00-report.md)).
The file says `backend = "${setting:backend}";`. A value of
xrender"; write-pid-path = "<any path>
renders as `backend = "xrender"; write-pid-path = "<any path>";`, and picom then overwrites any file the
operator's account can write. The same tail adds `log-file`, a shader path, a window rule that hides
windows, or `backend = "dummy"`, which blanks the screen. The unquoted `use-damage = ${setting:use-damage};`
takes the same tail without the quote.
The pull request was changed to keep the file trusted (terminal-only). The defect is general.
## Why it is an issue
- The controller fills `${setting:…}` with the value **as it is** (`settingInto` → `plainly`): a string is
written verbatim. It refuses only a line break, a carriage return, another line end and a NUL
(`settingsHoldOneLine`, issue 339).
- A setting has a `kind` (`preference`) and a `default`, and **no type and no allowed values**. Nothing
says `backend` is one of `glx`, `xrender`, or that `use-damage` is a boolean.
- `"trusted": false` (issue 339) opens a file's settings to the `settings` verb. It was meant for "a file
nothing but its own person reads". But a file read only by the person's own program can still make that
program do anything its format allows. Whether a raw value is safe depends on the file's format, and
nothing judges the format.
So the rule "trusted: false where nothing trusts it" is not enough. A one-line value is safe only where
the format has no way to end a statement or a quoted string within a line.
## Impact
- Any caller of the `settings` verb (an agent session, a tool) can rewrite a desktop program's
configuration beyond the key it was given, as the operator's account.
- Two files were marked untrusted on main. The survey in [01-diagnosis.md](01-diagnosis.md) judges
both: the `xdg` module's `user-dirs.dirs` is open to the same injection, `dunst`'s `dunstrc` is not.
`user-dirs.dirs` is made trusted again for now (mesh-catalog #158).
## What is asked
The fix is proposed in the diagnosis and needs a decision record (settings get a type), then a controller
change. Until it ships, **no new file is marked `"trusted": false`.**
@@ -0,0 +1,58 @@
# 346 — Diagnosis
2026-10-09. Read from mesh-controller `4d60628` and mesh-catalog main `7339ce8`. Nothing was run against
a node.
## Where a value goes
1. The `settings` verb stores a layer of values per module and node. Before storing, and again when it
composes, `settingsHoldOneLine` refuses a line break, a carriage return, another line end or a NUL in any
string. Nothing else is judged.
2. `settingInto` (internal/catalogue/setting_into.go) replaces each `${setting:<key>}` in a file's content
with `plainly(value)` (internal/catalogue/bound_into_files.go). For a string that is the string itself; a
number is printed without trailing zeros; a boolean as `true`/`false`. No quoting and no escaping, for
any format.
3. `TerminalKeys` (internal/catalogue/terminal_keys.go) makes every key a file asks for terminal-only, unless
the file says `"trusted": false`. Then the verb may set it.
4. A module's `settings` entry has `kind`, `default` and `why`. There is no type, no range and no list of
allowed values, so neither the verb nor the composition can refuse `xrender"; …` for a backend.
## Every file marked "trusted": false on main
| module | file | the line | format | verdict |
|---|---|---|---|---|
| `dunst` | `~/.config/dunst/dunstrc` | `width = ${setting:width}` and `font = Inter ${setting:font-size}` | INI: one key per line, the value to the end of the line, no separator or quoting within a line | **not open** to adding a key: without a line break a value stays the value of its own key. A wrong value (`width = abc`) makes dunst fall back or refuse the file: an outage of notifications, not an injection |
| `xdg` | `~/.config/user-dirs.dirs` | `XDG_DESKTOP_DIR="${setting:desktop-dir}"`, and the same for `publicshare-dir` and `videos-dir` | shell assignments; the xdg-user-dirs format is defined to be sourced by a shell | **open.** A value of `$HOME/x"; <command>; X="` ends the quoted string and adds a command, and `$(<command>)` inside the quotes runs too, in every shell or script that sources the file. The programs measured on the laptop parse it instead (`xdg-user-dir` is a C program there, and GLib parses), and no managed file sources it; a script of the operator's, or a tool that follows the format's definition, would run the command. Even parsed, a value can point the desktop or public folder anywhere the account can write |
Mergeable JSON files ("any mergeable file takes any key", issue 340) are not pasted: values are merged as
JSON and encoded by the encoder. They are outside this defect.
**Done for now (2026-10-09):** `user-dirs.dirs` is marked trusted again (terminal-only) in mesh-catalog
#158, as `picom.conf` was kept in mesh-catalog #157. A test in each module fails if its file carries a
trusted marking, naming this issue. `dunstrc` stays as it is. This is the stop-gap, not the fix: the
fix below still stands.
## The fix proposed
A decision record, then a controller change. The coordinator schedules it; it is not built here.
1. **A setting declares a type** in the manifest: `bool`, `int` (with an optional range), `enum` (its
allowed values), or `string` with the **format it is written into**, for example `ini-value`,
`shell-double-quoted`, `libconfig-string`, `path`. A setting without a type is a string that only the
terminal may set.
2. **The controller validates at the verb**, before a layer is stored: a bool is a JSON boolean, an int an
integer in range, an enum one of its values, a string one line. A refusal names the key and the type.
Validated again at composition, for layers stored before.
3. **The controller quotes at render** with the format's own function: `shell-double-quoted` escapes
`"`, `\`, `$` and backquote; `libconfig-string` escapes `"` and `\`; `ini-value` refuses `;`, `#` and a
leading `[`, or quotes where the format allows. A bool, an int or an enum needs no quoting, because it
cannot hold one.
4. **`"trusted": false` requires every setting the file asks for to be typed**, checked by `module check`
in the catalogue's merge check, so an untyped string can never reach an untrusted file again.
5. `picom`'s `backend` becomes `enum [glx, xrender, egl]` and `use-damage` a `bool`; then `picom.conf` may
be untrusted. `xdg`'s three folders become `shell-double-quoted` paths under `$HOME/`.
**Until then, no new file is marked `"trusted": false`.** How that rule is checked: `module check` already
counts the files that ask for a setting without saying whether they are trusted; the catalogue's merge
check can refuse any newly added `"trusted": false` (a list that only shrinks, like `health-undeclared`).
That check is part of the fix, not built here.