From 537e5b733b59ec860138f87ba79809e2500e7f38 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 18:37:55 +0200 Subject: [PATCH 1/6] 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. --- 00-META/glossary.md | 4 + ...hat-cannot-become-root-without-a-person.md | 240 ++++++++++++++++++ .../36-the-operators-agent-on-a-machine.md | 68 ++++- .../00-report.md | 56 ++++ 4 files changed, 366 insertions(+), 2 deletions(-) create mode 100644 02-DECISIONS/0266-on-the-control-node-agents-run-under-an-account-of-their-own-that-cannot-become-root-without-a-person.md create mode 100644 04-ISSUES/333-an-agent-on-the-control-node-can-become-root-without-a-person/00-report.md diff --git a/00-META/glossary.md b/00-META/glossary.md index 9062b7fa..1ffaa22b 100644 --- a/00-META/glossary.md +++ b/00-META/glossary.md @@ -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 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 new file mode 100644 index 00000000..fd33dac4 --- /dev/null +++ b/02-DECISIONS/0266-on-the-control-node-agents-run-under-an-account-of-their-own-that-cannot-become-root-without-a-person.md @@ -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 @`.** 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 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 [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 ` 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 diff --git a/03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md b/03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md index d0e7f277..b10cb1bb 100644 --- a/03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md +++ b/03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md @@ -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:` 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 diff --git a/04-ISSUES/333-an-agent-on-the-control-node-can-become-root-without-a-person/00-report.md b/04-ISSUES/333-an-agent-on-the-control-node-can-become-root-without-a-person/00-report.md new file mode 100644 index 00000000..5be38fba --- /dev/null +++ b/04-ISSUES/333-an-agent-on-the-control-node-can-become-root-without-a-person/00-report.md @@ -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. From dbc3ec40e0e8fda8a57856a813f38714532fd88a Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 20:53:32 +0200 Subject: [PATCH 2/6] 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. --- ...hat-cannot-become-root-without-a-person.md | 117 +++++++++++++----- .../36-the-operators-agent-on-a-machine.md | 40 ++++-- 2 files changed, 115 insertions(+), 42 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 fd33dac4..9b55c566 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 @@ -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 [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 ` 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 ` 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 diff --git a/03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md b/03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md index b10cb1bb..736de870 100644 --- a/03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md +++ b/03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md @@ -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 From e215dd95c42df7b649d33cb26509d0537d6bfb62 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 21:32:42 +0200 Subject: [PATCH 3/6] 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 | From ee314a26cac02c0ca5608c600041084f88a707a4 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 11:03:27 +0200 Subject: [PATCH 4/6] ADR 0266: say where the boundary stands on 2026-10-09, and the rollout's order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The routes found around the agent account since the record was written are closed on main (the store's reader, places and served keys, what every session obeys, execute withheld on the control node); the record names them, drops the store gap it said kept the boundary from holding, and says plainly what still remains, first the login shell's execute on the other nodes. To-be 36 §9 gains the prerequisites and the order the parts go out in. --- ...hat-cannot-become-root-without-a-person.md | 61 ++++++++++++++++--- .../36-the-operators-agent-on-a-machine.md | 13 +++- 2 files changed, 62 insertions(+), 12 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 838422cc..a357c17c 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 @@ -245,7 +245,7 @@ here: - 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 + `artifacts`, `data`, `builds`, `queue`, `plan`, `plans` but not its go, stop, close or retry, wherever the word stands in the line, `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 @@ -260,19 +260,62 @@ here: 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; + on it. It still unpacks an archive, and writes into an existing file, only after a check of the path, so + it refuses either below the home of any account the declaration says never becomes root, and the + catalogue's own check refuses either under `${machine:agent-home}`, so no module asks for 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. +**Where the boundary stands, 2026-10-09.** *Added 2026-10-09, after review of the routes found since this +record was written; what was decided above is unchanged.* The tool runner acts for any caller of the mesh MCP +server, so the agent account's boundary is only as good as the tools that caller reaches. These routes were +found around it and are closed on the mesh's main line: + +- **The store's reader** ([issue 337](../04-ISSUES/337-the-stores-reader-can-read-the-controllers-signing-key/00-report.md)): + `mesh-store.query` could read every sealed secret and the key that signs every node's declaration, so any + caller could forge what a node applies as root. The reader now reads only what a module declares readable, + never a secret. Live on the control node and the home server since 2026-10-08. +- **A module's places and accesses, and the keys a provider serves** + ([issue 339](../04-ISSUES/339-a-verb-can-place-a-directory-at-the-machines-own/00-report.md)): a settings + call could have the node-engine, as root, hand a system directory to any account, or mount one into a + container. Those keys, every key a provider serves, every setting a trusted file asks for, and the whole + layer of a module with a trusted mergeable file are now set at the controller's terminal alone. The + node-engine refuses a placement or a mount at the machine's own trees, root's or another account's + directory below a home, and uses a directory it did not make as found. This record's own copy of that + engine guard was dropped for it; the one rule kept here is the archive and written-into file below an + agent's home (§7). +- **What every Claude Code session obeys** + ([issue 340](../04-ISSUES/340-an-agent-can-plant-a-hook-in-every-agent-session-on-the-mesh/00-report.md)): + an agent could register a hook, managed settings, a permission rule or a tool server for every node, and + have it run as the operator at the operator's next session elsewhere. Those, and shared agent text, are now + set at the controller's terminal alone. The home scope is written only into the home of an agent account + of its own, on the node serving the call, never into the operator's home. +- **The login shell's `execute` on the control node** + ([ADR 0268](0268-the-login-shells-execute-is-withheld-on-the-control-node-until-a-call-to-it-needs-a-persons-approval.md)): + it ran any command as the operator account. The control node withholds it; the setting that withholds it + is a trusted file's, so it is changed at the terminal alone. + +**What still remains, said plainly.** None of these is closed by this record or its pull requests: + +- **The login shell's `execute` on the other nodes**, and the desk's `node-terminal-emulator.open` and + `rofi_run`, run a caller's command as the operator account of their node, which has passwordless sudo + there. So an agent on the control node can still become root **on another node** through the mesh MCP + server. Whether the operator account on one of those nodes holds a key that logs into the control node + was not measured; where one does, this is also a way back to root on the control node. ADR 0268 §4 closes it + once a call to such a tool needs a person's approval (research 039); until then, withholding `execute` on + every node is the operator's choice, at the terminal. +- **Tool servers already on the bus** keep rendering until their own change lands (issue 340, "still + open"). Text written straight into the bus state needs a tool runner's credential, which the agent account + cannot read. +- **The tool runner on the control node is still the operator account** (Considered Options), so a tool + found tomorrow that reads, writes or runs a caller's path or command is a way round this record until it is + bounded. Each is a defect of that tool, found by review. +- **The self-check judges this record's account only.** ADR 0259's own root probe (mesh-controller pull + request 154) asks the same question through the `sudo` module; it must call this record's judgement, one + function, rather than keep a second answer. 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. @@ -306,7 +349,7 @@ works on branches and pull requests, and a merge needs a person anyway. | 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` | | `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` | +| No archive and no file written into is placed under the agent's home | the catalogue's `checks/agenthome`, in its `merge-check.sh`; node-engine test: an archive or a written-into file below the home of a `root: never` account is refused, a whole file passes | | 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 | diff --git a/03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md b/03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md index 736de870..9d1e0ff8 100644 --- a/03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md +++ b/03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md @@ -2,7 +2,7 @@ layer: to-be status: in-progress code: [mesh-catalog modules/claude-code] -updated: 2026-10-08 +updated: 2026-10-09 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 @@ -339,8 +339,15 @@ root that no verb serves. 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. +0. **Before any of it**, the routes round the boundary that ADR 0266 §7 names as closed are closed and + running: the store's reader (issue 337), places, accesses and served keys at the terminal (issue 339), + what every session obeys at the terminal (issue 340), and `execute` withheld on the control node + (ADR 0268). All four ran on 2026-10-09; the tool servers already on the bus stop rendering with their + own change. What ADR 0266 §7 says still remains is the operator's to weigh before step 3. +1. The node-engine, the controller and this module are built with ADR 0266 and sent to every node, in this + order: the node-engine first, until every engine says contract 3; then the controller, its node-engine + pin moved to the engine's merged commit; then the module's code; then its manifest, which the running + controller refuses until it knows the new machine facts. Nothing changes on any node. 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, From d7999e6fc99888eb03c83eed24ea6003ec5da99b Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 11:49:25 +0200 Subject: [PATCH 5/6] ADR 0266: the setuid search's bound is 15 minutes, and the two routes and the restart condition the review of 2026-10-09 found Item 10 said two minutes; the engine's bound is 15, with a two-minute limit inside it still to settle. What still remains now names a build from another repository registering a module (mesh-controller 174), the forge's tools changing its protection (mesh-catalog 153), and the urgent condition every engine restart raises (mesh-controller 175). --- ...hat-cannot-become-root-without-a-person.md | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 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 a357c17c..3d380c70 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 @@ -153,8 +153,13 @@ the C locale. It finds a way to root when the account: tool runner's credential is one of those; 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; + and pseudo filesystems excepted). The engine searches for these in the background, at most hourly, and + lets one search run for at most 15 minutes. Until the first search after the engine starts has finished, + the verdict is *not judged yet*. *Corrected 2026-10-09: this said the search gets two minutes; the engine's + bound on a search is 15 minutes (mesh-host pull request 61). Its `find` and the package queries still carry + a two-minute limit of their own inside that bound, so as written a search longer than two minutes ends + incomplete before the 15 minutes are reached; which of the two limits holds is for that pull request to + settle;* 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). @@ -313,6 +318,34 @@ found around it and are closed on the mesh's main line: - **The tool runner on the control node is still the operator account** (Considered Options), so a tool found tomorrow that reads, writes or runs a caller's path or command is a way round this record until it is bounded. Each is a defect of that tool, found by review. +- **A build from another repository registered a module** (found in review, 2026-10-09). The controller's + `build` verb cloned any repository as given, and the outcome registered the module its `module.json` named, + from that repository: a module is registered by name and its source overwritten, and "only a commit on the + trunk is published" (ADR 0238) checked the trunk of the repository built. An agent that made a repository, + or forked the catalogue or the node-engine, and committed a `sudo` module granting itself a rule without a + password, had the next push make it root on every node. `rebuild`, `replay --register` and `assign` with + `build` reached the same take-in. Closed by mesh-controller pull request 174, once merged after pull request + 164: an outcome registers a module only from the repository the catalogue already builds it from, a new + module only from a repository the catalogue already builds another from (a merge adding one), and anything + else only when the build was asked at the controller's terminal; through a verb, a build of a repository + the catalogue builds nothing from is not asked at all, and a failed gate puts back only a build of the + module's own repository. +- **The forge's tools could change the forge's protection** (found in review, 2026-10-09). `gitea_api` sent + any call with a token minted for the forge's administrator, `write:admin` included: an agent could drop a + branch's protection or its required checks, set the merge check's status, write to the default branch, + delete and recreate a repository the mesh builds from, or merge its own pull request past the checks; and + `gitea_branch_protection_set` could weaken a rule. Closed by mesh-catalog pull request 153: `gitea_api` + reads anything but the forge's administration and writes only what an author writes, protection is only + made stronger, a merge needs every required status a success and merges only the head that was checked, a + guarded repository is not deleted, and the token is minted without `write:admin`. A merge after the + operator's yes, of a pull request whose checks passed, works as before. The token still belongs to the + forge's administrator; a non-admin account for the tools is a follow-up. +- **Every node-engine restart raises `agent-can-become-root`.** Until the engine's first search for setuid + programs finishes (§3 item 10), the verdict is *not judged yet*, and the self-check (§4) raises the urgent + condition on every restart. Proposed in mesh-controller pull request 175: while that first search is within + its bound (15 minutes, and one statement more), the condition is not raised; the account is still not + confined, `node show` still says not judged, and the condition is raised as before once the search fails, + runs out its bound or finds a way to root. - **The self-check judges this record's account only.** ADR 0259's own root probe (mesh-controller pull request 154) asks the same question through the `sudo` module; it must call this record's judgement, one function, rather than keep a second answer. From cb907d5810a2936349eee922139092a406a85ec6 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 12:27:13 +0200 Subject: [PATCH 6/6] ADR 0266: an agent can merge its own pull request, and the restart condition's quiet counts on the controller's clock The record said a merge needs a person; the forge requires no approval and the merge tool merges once the checks pass, so nothing enforces the operator's yes. What still remains now says so, and that the restart fix counts from when the controller first saw the search pending. Item 10 and routes A and B describe the follow-ups of the review of 2026-10-09. --- ...hat-cannot-become-root-without-a-person.md | 54 +++++++++++-------- 1 file changed, 33 insertions(+), 21 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 3d380c70..9a334026 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 @@ -156,10 +156,9 @@ the C locale. It finds a way to root when the account: and pseudo filesystems excepted). The engine searches for these in the background, at most hourly, and lets one search run for at most 15 minutes. Until the first search after the engine starts has finished, the verdict is *not judged yet*. *Corrected 2026-10-09: this said the search gets two minutes; the engine's - bound on a search is 15 minutes (mesh-host pull request 61). Its `find` and the package queries still carry - a two-minute limit of their own inside that bound, so as written a search longer than two minutes ends - incomplete before the 15 minutes are reached; which of the two limits holds is for that pull request to - settle;* + bound on a search is 15 minutes, and it governs the whole search: mesh-host pull request 61 dropped a + two-minute limit that sat inside it. The package manager is asked about every path found in one call, and a + walk `find` ended with an error is incomplete unless every error was a file that vanished while it walked;* 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). @@ -325,33 +324,46 @@ found around it and are closed on the mesh's main line: or forked the catalogue or the node-engine, and committed a `sudo` module granting itself a rule without a password, had the next push make it root on every node. `rebuild`, `replay --register` and `assign` with `build` reached the same take-in. Closed by mesh-controller pull request 174, once merged after pull request - 164: an outcome registers a module only from the repository the catalogue already builds it from, a new - module only from a repository the catalogue already builds another from (a merge adding one), and anything - else only when the build was asked at the controller's terminal; through a verb, a build of a repository - the catalogue builds nothing from is not asked at all, and a failed gate puts back only a build of the - module's own repository. + 164: through any verb, an outcome registers a module only from the repository the catalogue already builds + it from — the same repository by the forge's own id, so one deleted and made again under its name is + refused — and a new module only from a repository the catalogue already builds another from (a merge adding + one); and only from the mesh's forge, where that repository's trunk refuses direct pushes, requires a status + and lets no administrator merge past one. Anything else only when the build was asked at the controller's + terminal, for that repository and path; the serving controller marks itself and everything it starts, so + none of it reads as the terminal. Through a verb, a build of a repository the catalogue builds nothing from + is not asked at all, and a failed gate puts back only a build of the module's own repository. - **The forge's tools could change the forge's protection** (found in review, 2026-10-09). `gitea_api` sent any call with a token minted for the forge's administrator, `write:admin` included: an agent could drop a branch's protection or its required checks, set the merge check's status, write to the default branch, delete and recreate a repository the mesh builds from, or merge its own pull request past the checks; and `gitea_branch_protection_set` could weaken a rule. Closed by mesh-catalog pull request 153: `gitea_api` - reads anything but the forge's administration and writes only what an author writes, protection is only - made stronger, a merge needs every required status a success and merges only the head that was checked, a - guarded repository is not deleted, and the token is minted without `write:admin`. A merge after the - operator's yes, of a pull request whose checks passed, works as before. The token still belongs to the - forge's administrator; a non-admin account for the tools is a follow-up. -- **Every node-engine restart raises `agent-can-become-root`.** Until the engine's first search for setuid - programs finishes (§3 item 10), the verdict is *not judged yet*, and the self-check (§4) raises the urgent - condition on every restart. Proposed in mesh-controller pull request 175: while that first search is within - its bound (15 minutes, and one statement more), the condition is not raised; the account is still not - confined, `node show` still says not judged, and the condition is raised as before once the search fails, - runs out its bound or finds a way to root. + reads anything but the forge's administration and writes only what an author writes, never a pull + request's base; protection is only made stronger, a rule named for a branch included; a merge goes only into + a branch whose protection requires the checks, needs every required status a success, and merges only the + head that was checked; a tool deletes only a repository of its own user that no rule guards and that had no + commit in 30 days, named in full; and the token is minted without `write:admin`. The token still belongs to + the forge's administrator; a non-admin account for the tools is a follow-up. +- **An agent can merge its own pull request.** Every mesh repository's trunk requires its checks and no + approval (0 required approvals), and the forge's merge tool merges as the forge's administrator once the + checks pass. Nothing on the forge checks that the operator said yes: the operator's approval in chat is a + habit of the session, not a rule anything enforces. A merged module is what the next push sends, so an agent + that writes a change the checks pass can deliver it without a person. Whether to require an approval on the + forge is the operator's decision, not yet taken. +- **Every node-engine restart raises `agent-can-become-root`**, and the fix proposed for it must not open a + quiet of its own. Until the engine's first search for setuid programs finishes (§3 item 10), the verdict is + *not judged yet*, and the self-check (§4) raises the urgent condition on every restart. Mesh-controller pull + request 175 (merged after 174) keeps the condition quiet while that search is within its bound, counted from + when the controller first saw it waiting, kept in its store and forgotten at the next complete verdict — + never from the engine's own time, which starts again at every restart, so an agent that restarts the engine + in a loop does not keep it quiet. The account is still not confined, `node show` still says not judged, and + the condition is raised at once when the search fails or finds a way to root. Until that pull request is + merged, every restart raises the urgent condition. - **The self-check judges this record's account only.** ADR 0259's own root probe (mesh-controller pull request 154) asks the same question through the `sudo` module; it must call this record's judgement, one function, rather than keep a second answer. 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. +works on branches and pull requests; what it may merge is said under *What still remains*. ## Consequences