diff --git a/01-RESEARCH/041-the-agents-session-runs-confined/00-overview.md b/01-RESEARCH/041-the-agents-session-runs-confined/00-overview.md new file mode 100644 index 00000000..0a469d93 --- /dev/null +++ b/01-RESEARCH/041-the-agents-session-runs-confined/00-overview.md @@ -0,0 +1,68 @@ +--- +status: active +initiated: 2026-10-10 +touches: + - 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/0259-an-ask-is-a-message-on-the-bus-any-channel-answers-it-and-the-asker-acts-on-a-warrant.md + - 02-DECISIONS/0245-a-verb-says-what-it-replaces-and-the-agent-is-guarded-from-working-round-the-mesh.md + - 02-DECISIONS/0268-the-login-shells-execute-is-withheld-on-the-control-node-until-a-call-to-it-needs-a-persons-approval.md + - 02-DECISIONS/0144-anything-on-a-machine-may-call-anything-on-it.md + - 03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md + - 04-ISSUES/344-known-gaps-in-the-agents-boundary-accepted-for-now/00-report.md + - 01-RESEARCH/039-a-tool-that-needs-a-persons-approval/00-overview.md +became: [] +--- + +# 041 — The agent's session runs confined, instead of being proven by a disk-wide search + +## What + +Whether the coding agent's session on a node with an **agent account** should be **confined when it +starts**, so that what it cannot do is enforced by the kernel, instead of the node-engine proving the +account harmless by searching every local filesystem for setuid programs. Three parts, from the +operator's idea of 2026-10-10: + +1. **No new privileges.** The session and everything it starts run with the kernel's `no_new_privs` flag, + so a setuid or setgid program, packaged or not, and a program with file capabilities, grant it nothing. +2. **A confined view of the files.** The session sees `/usr` read-only, its own home, a private `/tmp` and + the few system files a program needs to run, and nothing else. +3. **Everything else through the mesh's tools.** What an agent reads outside that view (a file, a + directory, a log) is a tool of a seat, not a path. + +And then: what must be **proven** for a node to be root-free under such a start (every way into the +account goes through it), how the node-engine checks that cheaply, and what becomes of the setuid search. + +## Why + +[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) +§3 item 10 judges the agent account by a search of every local filesystem mounted without `nosuid` for +setuid or setgid root programs no package owns, bounded at 15 minutes. Until a search finishes, the verdict +is *not judged*, and [ADR 0259](../../02-DECISIONS/0259-an-ask-is-a-message-on-the-bus-any-channel-answers-it-and-the-asker-acts-on-a-warrant.md) +§8 rule 2 counts a machine without a recent verdict as not root-free, so the router approves nothing proven +there and a channel's `verified-sender` is stripped. + +Measured on 2026-10-10 (the controller's `root-free` verb and `node` verb): on the control-node the search +"did not finish within 15m0s", the agent account is *not judged*, and two urgent conditions are open +(`agent-root` and `root-not-free`). Linking the operator's phone channel is blocked by it. +[Issue 344](../../04-ISSUES/344-known-gaps-in-the-agents-boundary-accepted-for-now/00-report.md) gap 9 had +foreseen it ("on a large data pool the root search may never finish"). A resumable search is the short-term +fix, tracked separately. This effort asks whether the search is the right instrument at all: it answers +"is there a program that could make this account root?", while the question ADR 0259 needs is "can an +agent's session become root?". A confined start answers the second question directly. + +## What it touches + +- **ADR 0266 §3 and §4**: which ways to root the node-engine judges, and what a root-free verdict rests on. + Under `no_new_privs` item 10 (unpackaged setuid) and the *not judged* file capabilities no longer + apply to the session; new things must be proven instead (§3 of the findings). +- **ADR 0266 §7 and to-be 36 §9**: `claude-agent` becomes the confined start, not only an account switch. +- **ADR 0259 §8**: `root-free` would read the new proof. +- **ADR 0245**: the guard stays as the session's own refusal; the confinement backs it in the kernel. +- **ADR 0144, ADR 0152 and ADR 0266 §7**: the mesh MCP server has no login of its own, so a confined agent + still reaches every tool the operator reaches. Not closed here; recorded as the boundary of this effort. +- **Issue 344** gaps 1, 3, 4 and 9, and **research 039** (a tool that needs a person's approval). +- **A node filesystem seat** that does not exist today. + +## Documents + +- [01 — The options, what must be proven, and the open questions](01-options-and-open-questions.md) diff --git a/01-RESEARCH/041-the-agents-session-runs-confined/01-options-and-open-questions.md b/01-RESEARCH/041-the-agents-session-runs-confined/01-options-and-open-questions.md new file mode 100644 index 00000000..bdedb39b --- /dev/null +++ b/01-RESEARCH/041-the-agents-session-runs-confined/01-options-and-open-questions.md @@ -0,0 +1,184 @@ +# 041 — The options, what must be proven, and the open questions + +Sources: [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) +§3, §4, §6 and §7; [ADR 0259](../../02-DECISIONS/0259-an-ask-is-a-message-on-the-bus-any-channel-answers-it-and-the-asker-acts-on-a-warrant.md) +§8; [to-be 36](../../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md) §9; +[issue 344](../../04-ISSUES/344-known-gaps-in-the-agents-boundary-accepted-for-now/00-report.md); +[ADR 0144](../../02-DECISIONS/0144-anything-on-a-machine-may-call-anything-on-it.md) and +[ADR 0152](../../02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md) (the machine's login is +the authority on loopback). The records module was searched on 2026-10-10 for `setuid`, `not judged yet`, +`loopback` and `cannot tell the operator from an agent`; the hits are the records above. +`no_new_privs`, `NoNewPrivileges`, `Landlock`, `bubblewrap` and `ProtectSystem` appear in no record. + +What the kernel and the tools do is stated from their documentation and has **not been measured on any node** +yet. Each such claim is marked *to measure* where the decision would rest on it. + +## 1. What the setuid search answers, and what it does not + +ADR 0266 §3 judges eleven ways to root by reading the machine. Item 10, the setuid search, is the only one +whose cost grows with the data on the machine. On the control-node it did not finish within its 15-minute +bound (measured 2026-10-10, `mesh-controller.root-free`), so the verdict is *not judged* and ADR 0259 §8 +blocks every approval proven there. + +The search asks whether a program exists that could make the account root. It does not ask whether the +agent's session can run one. Even when it finishes, ADR 0266 lists what it leaves unseen: a packaged setuid +program with a flaw of its own, file capabilities on a program, setuid programs on filesystems not searched. + +## 2. The three parts, compared + +### 2.1 No new privileges + +`no_new_privs` is a per-process flag (Linux 3.5 and later). It is set by `prctl(PR_SET_NO_NEW_PRIVS)`, by +`setpriv --no-new-privs`, or by the service manager's `NoNewPrivileges=yes`. Any process may set it, without +privilege. Once set it is inherited by every child and cannot be cleared. Under it, `execve` grants no +setuid, no setgid and no file capability. + +| Way to root (ADR 0266 §3) | Under `no_new_privs` | +|---|---| +| 3, 4: a sudo or doas rule | inert: `sudo` and `doas` are setuid programs | +| 5, 6: polkit through `pkexec` | inert (setuid); polkit **through D-Bus** to a root daemon is not touched | +| 7, 8: a container runtime's socket or TCP port | not touched: the request goes to a root daemon | +| 10: unpackaged setuid program | inert, on every filesystem, searched or not | +| *not judged*: file capabilities, a flawed packaged setuid program | inert | +| *not judged*: a root-run file the account can write; root's keys | not touched | + +So it removes the whole class "a program that gains privilege when executed", and leaves the class +"**a root daemon that does something when asked**": the container runtime, the service manager over D-Bus +(polkit), the ssh daemon (a key into another account), cron and at, and any file root later reads. + +**It has an escape that must be closed: a process the session asks to start something.** The flag passes +only from parent to child. If the agent account has a running user manager, `systemd-run --user` starts a +command as a child of that manager, which root started without the flag. The same holds for a cron or at +daemon, for D-Bus activation on a session bus, and for a terminal multiplexer server started before the +confined session. Either the account has none of these, or each is itself started with the flag (a drop-in +on the account's `user@` unit can set `NoNewPrivileges=yes`; *to measure*). + +### 2.2 A confined view of the files + +| | Service manager unit (`ProtectSystem=strict`, `ProtectHome=tmpfs`, `BindPaths=`, `PrivateTmp=`, `TemporaryFileSystem=`, `InaccessiblePaths=`) | Landlock | bubblewrap | +|---|---|---|---| +| Kernel | mount namespaces, present on every kernel the mesh runs | an LSM, 5.13 or later, and enabled in the kernel's LSM list; features grow by ABI (5.19, 6.2, 6.7, 6.10, 6.12) — *to measure on each node* | user namespaces; refused where unprivileged user namespaces are switched off | +| Who sets it up | root (a system unit, or `systemd-run` as root with `User=`); `claude-agent` already goes through the operator's sudo | the process itself, unprivileged, and it requires `no_new_privs` first | the process itself, unprivileged where user namespaces are allowed; sets `no_new_privs` itself | +| Shape | hides or makes read-only whole trees; can show only a list of paths | an allow list of path rights, checked on access; nothing is hidden from a directory listing above an allowed path | a new mount tree built from bind mounts: only what is listed exists | +| What the node-engine can read | the unit's properties from the service manager, and the session's processes' cgroup | `/proc//status` shows `NoNewPrivs`; whether a Landlock domain is applied is not readable from outside on older kernels — *to measure* | `NoNewPrivs` and the mount namespace in `/proc`; the bind list is not declared anywhere the engine reads | +| Also gives | `RestrictSUIDSGID=`, `SystemCallFilter=`, `RestrictNamespaces=`, resource limits, one cgroup for every process of the session | per-process, stackable; can be added inside any of the other two | Claude Code's own sandbox on Linux is built on it | +| Interactive terminal | `systemd-run --pty`; *to measure* with `use_pty` from the `sudo` module | unchanged | unchanged | + +**Claude Code's own sandbox** (from its documentation, *to measure* on the installed version): on Linux it +runs the shell tool's commands, and their children, under bubblewrap with a write allow list and a network +proxy. It does **not** cover the session's file tools (read, edit, write), which permission rules govern, +nor the session's MCP servers. The shell tool also takes a per-call override that runs a command outside the +sandbox, which a setting can forbid. So it confines the commands an agent types, not the session, and it is +configured by the session's own settings. It is a useful inner layer; it is not the boundary ADR 0259 can +rest on. + +**Composability.** The three nest: a unit started by root gives the outer view and `no_new_privs`; Landlock +can narrow further inside it; Claude Code's bubblewrap runs inside both if the unit allows user namespaces +(`RestrictNamespaces=` would forbid it, and with it a class of kernel exploits; a trade-off, see §5). The +guard ([ADR 0245](../../02-DECISIONS/0245-a-verb-says-what-it-replaces-and-the-agent-is-guarded-from-working-round-the-mesh.md)) +runs inside the session as a hook and keeps refusing; the confinement makes a refusal it misses fail in the +kernel. The tool runner runs outside, as the operator account, and the session reaches it over loopback, so +the confinement must leave the loopback network open. + +**"Nothing else" is a short list, not nothing.** A program needs the dynamic linker's cache, the account +database, the resolver file, the certificate store (the agent talks to its API over TLS), `/proc`, `/dev/null` +and its terminal. Which `/etc` files the agent needs, and whether `/etc` is read-only or hidden but for them, +is to be listed by trying it. + +### 2.3 Everything else through the mesh's tools + +Measured on 2026-10-10 with `mesh_overview` and `mesh_search`: every node holds `node-service-manager`, whose +`journal` verb reads one unit's journal, and `node-hostname`, whose `entries` verb reads the machine's `/etc/hosts`. +**No seat reads an arbitrary file, lists a directory or reads a log file that is not a unit's journal.** +Today that is done through `node-login-shell.execute`, which runs any command as the operator account, and +which the control-node withholds ([ADR 0268](../../02-DECISIONS/0268-the-login-shells-execute-is-withheld-on-the-control-node-until-a-call-to-it-needs-a-persons-approval.md)). + +A node filesystem seat cannot simply read a caller's path: the tool runner acts for any caller, and ADR 0266 +§7 calls a tool that reads a caller's path as the operator or as root a defect. A shape that would hold +follows the store's reader after issue 337: it reads only what a module **declares readable** (its +configuration, its logs), never a secret, through no symbolic link. Verbs it needs, at least: read a declared +file, list a declared directory, read a declared log's tail with a filter. + +On the operator's machines the agent works in the operator's desktop and files (ADR 0266 §1). Moving it to an +agent account there is only possible once such tools, and the desk's own tools, cover that work, and issue +344 gaps 3 and 4 (the display, the chat client) need the agent account to have no access to the operator's +display at all. Confining the agent while it **stays** the operator account does not hold: it can write a +file the operator later runs with sudo (a shell start file), and the operator's own user manager starts +anything without the flag. + +## 3. What must then be proven for root-free + +A confined start proves nothing if another way into the account exists. The proof becomes two halves. + +**The doors (read from the machine's configuration, cheap).** + +1. The account has no password: a locked entry in the shadow database. +2. No ssh key logs into it: no `authorized_keys` under its home, or the ssh daemon refuses the account. +3. No cron table and no at job for it, or no cron or at daemon runs. +4. No lingering user manager, and no user units under its home; or its `user@` unit carries + `NoNewPrivileges=yes` and the same confinement. +5. Sudo rules only **toward** it (the operator to the agent account, through `claude-agent`), none from it. + ADR 0266 §3 item 3 already judges the second half. +6. `claude-agent` starts the session as the confined unit, and the unit's declaration is the mesh's. + +**The session itself (read from `/proc`, cheap, and closer to the truth than the doors).** + +7. Every process running as the agent account has `NoNewPrivs: 1` in its status and sits in the cgroup of + a confined session unit. A process that does not is a door nobody listed, found by its effect. + +**The daemons that still act on request** (ADR 0266 §3 items 5 to 9 and part of the *not judged* list) stay +judged as today, because `no_new_privs` does not reach them. The confined view can also hide their entry +points: the container runtime's socket and the system bus can be made inaccessible to the session, which a +unit property states and the engine reads. + +Every one of these reads is bounded by the number of processes and a handful of files, not by the size of the +disks. Item 7 is checked at a moment; a process started between two looks is seen at the next one, within the +engine's interval. + +**What becomes of the setuid search.** Three choices, for the operator (Q2): + +- **Retired for agent accounts** once 1 to 7 hold: it judges nothing a confined session can use. +- **Kept as defence in depth**, resumable and unbounded, with a finding raising a warning but no longer + gating `root-free`. +- **Kept only for accounts that are not confined**: the operator account where agents still run as it, and + any account whose processes fail item 7. + +## 4. What this does not cover + +- **The mesh MCP server has no login of its own** (ADR 0144, ADR 0152: the machine's login is the authority + on loopback; ADR 0266 §7: the tool runner acts for any caller). A confined agent reaches the same tools as + the operator. Every tool that runs a caller's command or reads a caller's path as another account remains a + way round, as ADR 0266 §7 and issue 344 gaps 2, 5 and 11 already record. Research + [039](../039-a-tool-that-needs-a-persons-approval/00-overview.md) is the work that narrows it. +- **A kernel exploit.** `no_new_privs` and a confined view do not stop a flaw in the kernel. A system call + filter and no new user namespaces shrink the surface; the second conflicts with Claude Code's bubblewrap + sandbox (§2.2). The residual risk is the kernel's, as for every other process on the machine. +- **Network reach.** The session keeps the network it needs for its API and the loopback MCP server; a + network allow list is a separate question. + +## 5. Open questions for the operator + +1. **Is the confined start the boundary ADR 0259 rests on**, replacing the setuid search as the gate for + `root-free` on a node with an agent account? Recommendation: yes, because it answers "can the session + become root" instead of "does a program exist", and its cost does not grow with the data on the machine. +2. **What becomes of the setuid search** (§3)? Recommendation: keep it resumable as defence in depth for a + warning, gate `root-free` only on unconfined accounts. +3. **Which mechanism carries the confinement?** Recommendation: a service manager unit started by root + through `claude-agent`, because it needs no new package, gives `no_new_privs` and the view in one + declaration the node-engine can read; Landlock as an optional inner layer where the kernel has it; Claude + Code's bubblewrap sandbox kept for the shell tool, with its per-call override forbidden. +4. **May the session keep user namespaces**, so that Claude Code's sandbox works inside it, at the price of + a larger kernel surface? Or is the outer unit enough and the inner sandbox dropped? +5. **Is a node filesystem seat that reads only declared paths enough** for what agents do on the + control-node, and is it the precondition for moving the operator's machines to an agent account? + +## 6. Next measurements + +- On each node: the kernel's LSM list and Landlock ABI, whether unprivileged user namespaces are allowed, and + whether `sudo -iu` there starts a user manager for the target account. No mesh tool reads the first two + today; that is itself a gap for the node-engine's facts. +- A trial `claude-agent` as a transient unit on a non-control node: which `/etc` files the session needs, that + `sudo -n true` fails with the flag set, that `systemd-run --user` is refused or inherits the flag, and that + the mesh MCP server and the guard still answer. +- The `/proc` sweep of item 7 on the control-node: how many processes run as the agent account, and how long + the read takes.