From cfa06b68bc1c7d674542ada6172ab2ff6233e5cd Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 21:32:42 +0200 Subject: [PATCH] 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. --- ...hat-cannot-become-root-without-a-person.md | 71 ++++++++++++++----- 1 file changed, 52 insertions(+), 19 deletions(-) diff --git a/02-DECISIONS/0266-on-the-control-node-agents-run-under-an-account-of-their-own-that-cannot-become-root-without-a-person.md b/02-DECISIONS/0266-on-the-control-node-agents-run-under-an-account-of-their-own-that-cannot-become-root-without-a-person.md index 9b55c566..838422cc 100644 --- a/02-DECISIONS/0266-on-the-control-node-agents-run-under-an-account-of-their-own-that-cannot-become-root-without-a-person.md +++ b/02-DECISIONS/0266-on-the-control-node-agents-run-under-an-account-of-their-own-that-cannot-become-root-without-a-person.md @@ -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 [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 ` 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 |