Merge pull request 'Research 041: the agent's session runs confined, instead of being proven by a disk-wide search' (#262) from research/041-the-agents-session-runs-confined into main

This commit was merged in pull request #262.
This commit is contained in:
2026-10-09 23:07:33 +00:00
2 changed files with 252 additions and 0 deletions
@@ -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)
@@ -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/<pid>/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.