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:
+52
-19
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user