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