ADR 0266: on the control node agents run under an account of their own

The operator chose this remedy for ADR 0259 section 8: an agent there could
become root through the operator account's sudo and docker group, and read the
tool runner's credential. Record the node fact, the node-engine's judgement
the self-check reads, the guard, and how a session starts; issue 333 holds
the measured symptom and to-be 36 the design.
This commit is contained in:
jochen
2026-10-09 10:15:47 +02:00
parent d81ae38970
commit 537e5b733b
4 changed files with 366 additions and 2 deletions
+4
View File
@@ -214,6 +214,10 @@ and Health and repair. **Decided by** ADR 0100, 0126, 0176, 0181, 0207, 0221, 02
against this account's home and owned by it
([ADR 0181](../02-DECISIONS/0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md)).
Not a name a manifest carries.
- **agent account** — the login every coding agent on a node runs as, when the node names one of its own;
stated on the node record at the controller's terminal, never by a verb or a setting. Empty means agents
run as the operator account. It cannot become root without a person, and the node-engine judges that on
every look ([ADR 0266](../02-DECISIONS/0266-on-the-control-node-agents-run-under-an-account-of-their-own-that-cannot-become-root-without-a-person.md)).
- **account group** — a group of the machine's group database that a module puts an account in, by
declaring the account with that group and nothing else of it. Several modules may each add one to the
same account; the mesh takes back only an account group it added
@@ -0,0 +1,240 @@
---
topic: what runs on it
status: accepted
date: 2026-10-08
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md
---
# 266. On the control node, agents run under an account of their own that cannot become root without a person
## Context
Every coding agent on the mesh runs as the node's operator account
([ADR 0181](0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md)). The tool runner runs
as the same account ([ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md)),
and so does the `claude-code` module that configures the agent
([to-be 36](../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md)). The acting tools reach root through
that account's passwordless `sudo`, which the `sudo` module declares
([to-be 42](../03-DESIGN/01-to-be/42-the-machines-modules-in-order.md)).
On 2026-10-08 the `sudo` module's own check on the control node said what that account may do:
- two rules grant it every command as root without a password: the module's drop-in, and a line set by hand
in the machine's main sudoers file before the module existed;
- it is in the `docker` group, which runs a container with the machine's root filesystem mounted, so it is
root without sudo at all;
- the tool runner's bus credential is owned by it, mode 0600, and carries the grants of every module the
runner serves.
So any agent on the control node can become root without a person, and can read and use the runner's bus
credential. ADR 0259 (hq pull request 211, not yet merged)
§8, as revised after the security review of 2026-10-08, rests the router's approve warrants and a channel's
`verified-sender` on a self-check that finds **no account an agent runs as can become root without a
person**. On the control node that check fails today, and nothing measures it.
On 2026-10-08 the operator chose one of the three remedies ADR 0259 §8 offered: **on the control node, agents
run under an account of their own, without sudo. The operator's own account keeps its sudo.**
[Issue 333](../04-ISSUES/333-an-agent-on-the-control-node-can-become-root-without-a-person/00-report.md) records
the symptom.
## Considered Options
### Where the account is named
1. **A setting of the `claude-code` module.** Rejected. Any principal granted `mesh-controller.settings` may
write a setting, agents included (the same argument ADR 0259 §7 makes for the operator's identity). An
agent could name the operator's account again.
2. **A fixed name in the module's manifest.** Rejected. A definition names no installation
([ADR 0155](0155-a-definition-names-no-installation-and-how-that-is-checked.md)), and a fixed name would
apply on every node, including the operator's own machines, where the agent works in the operator's
desktop and files.
3. **A node fact, stated by the operator at the controller's terminal, beside the operator account.**
Chosen. No verb and no setting writes it, so no agent can change it.
### How the operator starts a session there
1. **`su` to the account.** Rejected. It needs a password for the account: a secret to keep, and an account
that a stolen password logs into.
2. **An ssh login as the account.** Rejected for now. It works from any terminal, but it opens a network
login into the account, needs a key placed for it, and is a second door into the account that the guard
cannot watch.
3. **`machinectl shell <account>@`.** It gives a full login session, and needs root through polkit, so it
goes through the operator's sudo anyway. Heavier than the agent needs; the operator may still type it.
4. **The operator's own sudo, `sudo -iu <account> claude`, as one command the module places
(`claude-agent`).** Chosen. The operator is the person whose sudo opens the account, and nothing else
can open it.
### Which account the tool runner runs as
1. **A service account of its own.** Rejected for now:
- the acting tools reach root through `sudo -n` as the runner's account, so a service account would need
the same passwordless sudo, and root would move rather than shrink;
- on the operator's machines the runner must be the operator's account to reach the desktop session
([to-be 38](../03-DESIGN/01-to-be/38-building-the-operators-machine.md));
- on the control node the operator's account is the person's and holds root anyway, so whatever can read
the credential as that account can become root regardless.
What the security review's first finding asks is that **an agent** cannot read the runner's credential.
An agent account of its own reaches that, and the node-engine checks it (below).
2. **Keep the runner as the operator's account.** Chosen. The credential still carries every co-hosted
module's grants. Narrowing it to one credential per bundle is separate work, and ADR 0259 rule 1 already
takes the trusted parties (the router and the verified channels) out of the runner onto accounts of
their own.
### How "cannot become root" is known
1. **Trust the configuration**: the `sudo` module's rule names only the operator's account. Rejected. One
machine showed three ways to root on one account, and two of them were not the module's.
2. **The node-engine judges the account on every look, and the controller's self-check reads the verdict.**
Chosen.
## Decision
### 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.
The controller resolves three machine facts from it:
- `agent-account` is the agent account, or the operator account where none is named;
- `agent-home` is that account's home;
- `agent-root` is `never` only where the node names an agent account of its own, and empty otherwise.
The tool runner is given `MESH_AGENT_ACCOUNT` and `MESH_AGENT_HOME` beside the operator's.
**On the control node the agent account is `agent`.** The other nodes keep their agents as the operator for
now. The operator's machines do so because the agent works in the operator's desktop and files there, under
the risk ADR 0234 accepts. A server may follow: it is moved by stating the fact.
### 2. The account is declared and has no way to root
The `claude-code` module declares the account as a `user` resource named `${machine:agent-account}`, with
`root: ${machine:agent-root}`. The mesh creates the account with its home, its own group and no password.
No module puts it in a group. The `sudo` module's rule names only the operator account.
### 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:
- 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.
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.
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
`agent-can-become-root`, which says what to change. `mesh-controller.node` shows the verdict and when it was
judged.
**This is the fact ADR 0259 §8 rule 2 reads.** On a node with an agent account of its own, a healthy verdict
means no account an agent runs as can become root without a person. The guard (§6) makes the agent account
the only one an agent works in there. On a node without an agent account, agents run as the operator
account, and the check holds only where that account cannot become root either. The rule and the code that
answers it are one function in the controller, which the router's check will call.
### 5. The agent is configured for its own account
The managed directory under `/etc` is unchanged and machine-wide. The licence token, the account the agent is
logged in as, an API-key licence and its key-helper, and the items registered at the home scope all go to
the agent account's home.
The tool runner cannot enter that home. So these files are written as the agent account, by a helper: the
`claude-code` module's own binary as the managed directory carries it, root's, started through the runner
account's sudo. **The escalation goes down only.** The runner's account may become the agent's, and the
agent's may become nothing.
When a node moves its agents, the licence follows at once. What the module placed in the operator's home
goes, as [ADR 0182](0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md) allows
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
`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.
### 7. How a session starts, and what the agent may still do
The operator starts a session with `claude-agent`, which runs `claude` as the agent account through the
operator's own sudo. Work that needs root is the operator's, in their own shell.
The agent may do everything the mesh MCP server serves, with the tool runner's grants, as an agent does on
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.
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.
## Consequences
- The operator types `claude-agent` on the control node instead of `claude`. Typing `claude` by habit gives a
session that can do nothing and says why.
- The agent's memory and history start empty in the new home. Copying what the operator wants from their own
home is the person's act (ADR 0182).
- The agent clones into its own home and cannot write the operator's checkouts.
- **What got harder:** anything an agent did on the control node through sudo is now a verb, or the
operator's act in their own shell. A missing verb is built in the module that owns the area (ADR 0245),
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.
- 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.
## How it is checked
| Rule | Checked by |
|---|---|
| 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 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 |
| 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
- ADR 0259 (hq pull request 211, not yet merged) §8
(hq pull request 211, revised after the security review of 2026-10-08)
- [ADR 0181](0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md) (the operator account),
[ADR 0182](0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md) (what the mesh owns
in a home), [ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md) (the
tool runner as the operator account), [ADR 0245](0245-a-verb-says-what-it-replaces-and-the-agent-is-guarded-from-working-round-the-mesh.md)
(the guard), [ADR 0252](0252-a-module-puts-an-account-in-a-group-and-the-mesh-says-when-a-new-login-is-needed.md)
(the node-engine's account judge this extends)
- [To-be 36](../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md) §9, the design this amends
- [Issue 333](../04-ISSUES/333-an-agent-on-the-control-node-can-become-root-without-a-person/00-report.md)
- the `sudo` module's `sudo_check` on the control node, 2026-10-08
@@ -2,8 +2,9 @@
layer: to-be
status: in-progress
code: [mesh-catalog modules/claude-code]
updated: 2026-10-07
updated: 2026-10-08
decisions:
- 02-DECISIONS/0266-on-the-control-node-agents-run-under-an-account-of-their-own-that-cannot-become-root-without-a-person.md
- 02-DECISIONS/0243-the-agent-module-removes-a-home-item-it-did-not-place-only-on-the-persons-word-and-keeps-a-copy.md
- 02-DECISIONS/0245-a-verb-says-what-it-replaces-and-the-agent-is-guarded-from-working-round-the-mesh.md
- 02-DECISIONS/0216-the-agents-configuration-is-registered-through-its-module-at-three-scopes-and-served-as-one-plugin.md
@@ -282,6 +283,65 @@ An item and its files are one value in the state, refused above 256 KiB.
own settings, and the module does not route around that refusal. A settings tool is called on the
operator's word.
## 9. The account the agent runs as
*Added 2026-10-08 by [ADR 0266](../../02-DECISIONS/0266-on-the-control-node-agents-run-under-an-account-of-their-own-that-cannot-become-root-without-a-person.md).*
Everywhere above, "the operator's home" and "the operator account" mean **the account the agent runs as**.
That is the operator account, unless the node names an **agent account** of its own. The control node does.
**The fact.** The operator states the agent account at the controller's terminal. No verb and no setting
writes it. The controller resolves the machine facts `agent-account`, `agent-home` and `agent-root`, and gives
the tool runner the account and its home. Where the node names none, the facts name the operator account and
`agent-root` is empty, so nothing changes there.
**What the module declares.**
- 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.
- `~/.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.
**What the module's code does differently.**
- The credentials file, the account file, an API-key licence with its key-helper (in a directory of the
module's under the agent's `~/.claude`), and the home-scope items go to that account's home.
- The tool runner cannot enter that home, so the code touches it through a helper running as the agent
account. The helper is the module's own binary, root's, under the managed directory, started through the
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
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 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
0259 §8 reads the same judgement before the router issues an approve warrant.
**The operator's day on the control node.** The operator works in their own login, with their own sudo, and
types `claude-agent` to start an agent. The agent runs in its own home, with its own clones and its own forge
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.**
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.
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.
Clearing the fact undoes step 2's routing, and the account is kept (the node-engine never deletes a login).
## How it is checked
| Check | Defends |
@@ -297,11 +357,15 @@ operator's word.
| live: a skill registered at the mesh scope is offered as `nox-mesh:<name>` in a new session on each machine | ADR 0216 |
| the module's test: the "instead of" table is generated from the controller's answers, ordered and cut; the guard refuses ssh to a mesh machine by name, domain, internal name, address, alias and jump, and the local work-arounds for a mesh name, naming the tool — and lets git to the forge, ssh beyond the mesh and a command merely mentioning ssh through; the override is honoured only from the session's start and only when recorded | ADR 0245 |
| the mesh MCP server's provision resolves by co-location; a machine without the mesh MCP server refuses the module by name | ADR 0027, ADR 0152 |
| the account the agent runs as: the facts fall back to the operator account; an agent account of its own is judged unable to become root, or raises `agent-can-become-root`; its files are written only through the helper; a session as another account is refused with no override | ADR 0266 |
| a new session on the assigned workstation lists the mesh MCP server's five tools under `mesh` ([ADR 0195](../../02-DECISIONS/0195-the-meshs-tools-are-found-by-address-not-announced-whole.md)) and answers "which node am I" from the instruction file | the exit of the build |
## What this does not settle
- **Several operator accounts on one node** (ADR 0181 decides one).
- **Several operator accounts on one node** (ADR 0181 decides one). ADR 0266 adds a second account of
another kind, the one agents run as, which is no person's login.
- **An agent account on the operator's machines.** They keep their agents as the operator, because the
agent works in the operator's desktop and files there (§9).
- **A worker's own licence on a machine.** Every interactive session shares the node's one agent
directory and its licence, however many run. A worker runs from a home of its own with an agent
directory in it, bound to its own licence through the manager (to-be 39 §5); that is for when workers
@@ -0,0 +1,56 @@
---
status: located
opened: 2026-10-08
located-in: [mesh-catalog modules/claude-code, mesh-catalog modules/sudo, mesh-controller internal/catalogue, mesh-host internal/accounts]
fixed-by:
amended-design: 03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md
---
# 333. An agent on the control node can become root without a person
## Symptom
On 2026-10-08 the `sudo` module's `sudo_check` on the control node answered, for the account every coding agent
there runs as:
- `passwordless: true`, decided by the module's drop-in, which grants the operator account every command as
root without a password;
- a second grant of the same, set by hand in the machine's main sudoers file before the module existed;
- the groups `docker` and the account's own. Membership of `docker` is root without sudo: a container started
with the machine's root filesystem mounted.
So every agent session on the control node can become root without a person. It runs as the same account as
the tool runner, so it can also read the runner's bus credential, which carries the grants of every module the
runner serves.
## Why it matters
ADR 0259 (hq pull request 211, not yet merged)
§8, revised after the security review of 2026-10-08 (hq pull request 211), lets the router issue an approve
warrant, and honours a channel's `verified-sender`, only on a machine where the self-check finds that no account
an agent runs as can become root without a person. The router and the first channel are placed on the control
node. There the condition fails, so an agent could publish a warrant, or answer for the operator, with the
runner's credential or as root.
## What the design let pass
- **Nothing measured it.** The `sudo` module reports what the operator account may do. No check asks which
account an agent runs as, or whether that account can become root.
- **One account did three jobs**: the person's login, the tool runner, and every agent. ADR 0181 decided one
account per node and left several open.
## Where it is located
| part | owner |
|---|---|
| which account the agent runs as, and where its files go | mesh-catalog `modules/claude-code` |
| the operator account's rule | mesh-catalog `modules/sudo` |
| a node naming a second account, and the self-check's invariant | mesh-controller `internal/inventory`, `internal/catalogue`, `cmd/mesh-controller` |
| judging whether an account can become root | the node-engine, `mesh-host` `internal/accounts` |
## The decision
[ADR 0266](../../02-DECISIONS/0266-on-the-control-node-agents-run-under-an-account-of-their-own-that-cannot-become-root-without-a-person.md):
on the control node, agents run under an account of their own. The node-engine judges on every look that the
account cannot become root without a person, and the self-check says it urgently when it can. The operator's
account keeps its sudo.