ADR 0266: say it detects rather than prevents, and bound what review found

The review found ways round the record: a verb that set the accounts, root
following a link below a home, a tool reading any path as the operator, a
restore written beside the agent's files, a stale verdict passing. Record each
bound, the exact ways judged and not judged, the guard on every tool and its
record's limits, and a rollout order the guard does not block.
This commit is contained in:
jochen
2026-10-09 10:15:47 +02:00
parent 537e5b733b
commit dbc3ec40e0
2 changed files with 115 additions and 42 deletions
@@ -93,12 +93,23 @@ the symptom.
## 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. The controller refuses root and the node's operator
account. **Empty means agents run as the operator account**, as before.
`--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
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:
@@ -120,27 +131,48 @@ No module puts it in a group. The `sudo` module's rule names only the operator a
### 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. It is a way to root when
the account:
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:
- has uid 0;
- is in a group that grants root by membership: `root`, `wheel`, `sudo`, `admin`, `docker`, `disk`, `lxd`,
`incus-admin` or `libvirt`;
- has any sudo rule, as `sudo -l -U <account>` lists it (any rule, not only one without a password: the
decision is no sudo);
- can read, by the file's owner, group and mode, any secret the declaration places for another account. The
tool runner's credential is one of those.
1. has uid 0;
2. is in `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
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.
**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 a symbolic link, §7);
- file capabilities;
- 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.
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. The verdict carries `root: never`, so the controller can tell this judgement from a group check.
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. Otherwise it raises the urgent condition
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.
@@ -167,14 +199,18 @@ for what the mesh placed. A login the person made there stays.
### 6. The guard refuses a session that runs as another account
On a node with an agent 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))
refuses every command and file edit of a session that does not run as that account, whatever it is. No
override lifts it. An agent started as the operator out of habit can do nothing there, and is told to use
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. The agent account may add to the journal but cannot change it, and
it cannot enter the module's state where the record lives elsewhere.
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
@@ -185,8 +221,26 @@ The agent may do everything the mesh MCP server serves, with the tool runner's g
any node. It may not run anything as root, use the container runtime, enter the operator's home or read the
runner's credential.
**A tool that runs a caller's command as root, or writes a caller's content into a file root reads, is a way
round this record and a defect of that tool**, found by review of its manifest and code.
`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 could set the agent account (§1);
- `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;
- 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 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.
@@ -203,13 +257,13 @@ works on branches and pull requests, and a merge needs a person anyway.
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:** a polkit rule granting the account an action, a setuid program other than
`sudo`, and a root-run file the account can write. Each is a way to root this check does not find. They
are left for a later record, and none is known on the control node.
- **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.
- 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
@@ -218,11 +272,16 @@ works on branches and pull requests, and a merge needs a person anyway.
| 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 uid 0, a root group, a sudo rule and a readable secret, before any session logic, and an unanswered question is never healthy | node-engine tests over a fake reader, for each way and for none; a parsing test of `sudo -l -U` |
| An agent account that can become root, or is not judged, raises `agent-can-become-root` | controller self-check probe tests: pass, fail, not judged, old engine |
| 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 |
| 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 |
| A session not running as the agent account does nothing there, with no override, and its record is in the journal | `claude-code` guard tests |
| 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
@@ -298,8 +298,8 @@ the tool runner the account and its home. Where the node names none, the facts n
- 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. That means no uid 0, no group that grants root, no sudo rule, and no secret
the mesh placed for another account readable by it.
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.
@@ -313,11 +313,18 @@ the tool runner the account and its home. Where the node names none, the facts n
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.
- What the module placed in the operator's home goes: the access-token-only credentials file, and the
- 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.** On such a node, the guard refuses every command and file edit of a session not running as the
agent account, with no override. Its record goes to the machine's journal.
**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
@@ -328,19 +335,26 @@ types `claude-agent` to start an agent. The agent runs in its own home, with its
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.**
**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.
1. The node-engine, the controller and this module are built with ADR 0266 and sent to every node. The
engines say contract 3, and nothing changes on any node.
2. The operator states the agent account on the control node at the controller's terminal. The next send
creates the account, places `~/.claude` and `claude-agent`, and moves the licence and the home-scope items.
The operator's own account and its sudo are untouched.
3. The self-check's new invariant passes, and `mesh-controller.node` shows the account judged.
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. Only then do they stop starting `claude` as themselves.
5. The operator generates a forge key in the agent's home and registers it.
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.
Clearing the fact undoes step 2's routing, and the account is kept (the node-engine never deletes a login).
**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