ADR 0266: record the command allow list, the judge's final lists, and the store gap

The re-review closed the command verb to reads, moved enrolment to the
controller's terminal, widened what the node-engine judges, and found the
store's reader able to read every secret; say each, and that the boundary is
not in force until the store is fixed.
This commit is contained in:
jochen
2026-10-09 10:15:47 +02:00
parent dbc3ec40e0
commit e215dd95c4
@@ -103,10 +103,11 @@ else's. The judgement says when that stops being true.
**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 subcommands for a caller, refuses `node account`, `node agent-account` and
every other `node` subcommand that sets something; only reading forms pass. Otherwise an agent could clear
the fact, or name itself the operator account, and the next send would write it a sudo rule. The controller
`--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.
@@ -135,19 +136,27 @@ The node-engine judges a `user` with `root: never` on every look, by reading onl
the C locale. It finds a way to root when the account:
1. has uid 0;
2. is in `root`, `wheel`, `sudo`, `admin`, `docker`, `disk`, `lxd`, `incus-admin` or `libvirt`;
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` grants;
6. can write a container runtime's socket (`/run/docker.sock`, `/var/run/docker.sock`,
`/run/podman/podman.sock`, `/run/containerd/containerd.sock`), by owner, group, other or POSIX ACL;
7. can read a secret the declaration places for another account, by owner, group, other or POSIX ACL. The
`/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;
8. could run a setuid- or setgid-root program on the root filesystem that no installed package owns. The
engine searches for these at most hourly and gives the search two minutes.
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 at most hourly and gives the search two
minutes;
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):
@@ -155,10 +164,14 @@ the C locale. It finds a way to root when the account:
- 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 a symbolic link, §7);
- file capabilities;
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 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
@@ -229,19 +242,38 @@ the operator's shell after it ends (the `TIOCSTI` call).
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 could set the agent account (§1);
- 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 stop, close and go, `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. Under a home it now refuses a
symbolic link in the path and changes owner and mode on the opened file;
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, after a check of the path; the
catalogue's own check refuses either under `${machine:agent-home}`, so no module opens 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.
**The boundary does not hold yet.** On 2026-10-08 review found that the store's read-only login, which the
mesh MCP server's `mesh-store.query` uses, can read every database, including every sealed secret and the key
that signs every node's declaration. Its fix is tracked in an issue of its own. Until it lands, any caller of
the mesh MCP server, the agent account included, can forge what a node applies as root, and this record's
boundary is not in force.
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, and a merge needs a person anyway.
@@ -273,8 +305,9 @@ works on branches and pull requests, and a merge needs a person anyway.
| 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` |
| No verb sets the agent account or the operator account | controller tests driving the `command` verb with `node account` and `node agent-account`, refused; reading forms pass |
| Root follows no symbolic 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 |
| `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` |
| 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 |