Merge remote-tracking branch 'origin/main' into decision/0272-mesh-cli-the-operators-command
This commit is contained in:
@@ -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
|
||||||
|
|||||||
+420
@@ -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
|
||||||
|
|||||||
+56
@@ -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.
|
||||||
Reference in New Issue
Block a user