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