claude-code: write the agent's home as its own account where it has one
mesh/merge-gate pass: builds claude-code, claude-licence-manager → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did w…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request

On the control node agents are to run as an account that cannot become root
(hq ADR 0266). The runtime keeps running as the operator, so the token, the
identity and the home-scope items go to the agent's home through a helper
started as that account; the licence and the account file follow the move,
what was placed in the operator's home goes once the agent holds the licence,
and the guard refuses every tool of a session running as anyone else,
recording to the journal. An API key is taken only from the agent's home and
only when it is one, here and in the licence manager, so the tool cannot carry
off the runtime's credential. Unchanged where no agent account is named.
This commit is contained in:
jochen
2026-10-08 20:32:34 +02:00
parent 53c4b47da4
commit 01c5c44249
17 changed files with 1472 additions and 99 deletions
+42 -2
View File
@@ -60,7 +60,7 @@ The agent kept reaching for `ssh <machine> journalctl` while the service manager
each verb with the commands it replaces. Generated, never written by hand: a verb that gains `replaces` is in
the next render. Ordered by what the guard here refused most, then the machines' seats, the modules, the
mesh's seats; at most sixteen rows, the rest one `mesh_search` away.
- **The guard.** A PreToolUse hook on `Bash`, `Edit`, `Write`, `MultiEdit` and `NotebookEdit`: this module's own
- **The guard.** A PreToolUse hook on every tool, judging `Bash`, `Edit`, `Write`, `MultiEdit` and `NotebookEdit` and letting the rest through (but see the account rule below): this module's own
binary (`claude-code guard`), copied root's into the plugin with what it judges with (`guard.json`: the
machines by name, domain and address, and the replaced commands), run by its path under `/etc/claude-code` so
the session cannot change it. It refuses `ssh`, `scp`, `sftp`, `rsync`, `mosh` and `autossh` to a mesh machine
@@ -142,11 +142,51 @@ Remove these by hand, once; the mesh removes nothing it did not make (ADR 0182):
- `~/.claude/skills/cleanup/`, `~/.claude/skills/hal-switch-license/`
- the hand-made console entry in `~/.claude.json` under `mcpServers` — it is ignored now anyway
## Where the agent runs as an account of its own (hq ADR 0266)
A machine may name an **agent account** beside the operator's, at the controller's terminal
(`mesh-controller node agent-account <node> <account>`); the control node does. Agents there run as that
account, which cannot become root without a person, and the operator keeps their own account and its sudo.
The runtime hands this module `MESH_AGENT_ACCOUNT` and `MESH_AGENT_HOME` — the operator's where none is
named, and read as the operator's from a controller that sets neither — and everything it writes for the
agent goes to that account's home:
- the licence token (`~/.claude/.credentials.json`) and the account it is logged in as (`~/.claude.json`);
- an API-key licence and its key-helper, in `~/.claude/mesh/`, since the account cannot enter the
module's state;
- the items registered at the home scope, recorded per home (`home-placed-<account>.json`).
The runtime's account cannot enter that home (0700, the agent's), so a path under it is touched by a
**helper running as the agent's account**: this module's binary as the plugin carries it
(`/etc/claude-code/marketplace/plugins/nox-mesh/guard/guard home-fs`, root's), started once through
`sudo -n -u <account>` and kept, one JSON request and answer per line. The escalation goes down only: the
runtime's account may become the agent's, never the other way.
When a machine moves its agent to such an account, the licence follows at once — a binding counts as
applied only while the agent's account holds it — and what this module placed under the operator's home
goes: the access-token-only credentials file (one holding a refresh token is a login the person made, and
stays, said), the home-scope items unless changed by hand, the key kept in the state.
**The guard there refuses every tool call of a session that does not run as the agent's account** —
shell, file edits, reads and the mesh's tools alike — and no override lifts it: the operator's account may become root, and an
agent started in it by habit must do nothing. Its record goes to the machine's journal
(`journalctl -t mesh-claude-code-guard`), which the session's account may add to and not change;
`claude_code_guard` reads it there, keeping only the lines the journal says the guard's own binary wrote. That
is not proof: the account can run the guard's binary itself and add lines in the guard's words. What it
cannot do is remove one.
**An API key is taken only from the agent's own home.** `claude_code_add_api_key` reads a file and removes it,
and the runtime runs as the operator: a path anywhere would hand whoever calls it the runtime's credential or
the operator's keys. So the file must lie under the home of the account the agent runs as, reached through no
symbolic link, is read as that account, must be at most 4 KiB and hold an Anthropic API key (`sk-ant-…`) —
otherwise nothing is handed over and nothing removed, and the refusal never repeats what the file holds. The
licence manager refuses a key of another shape too.
## Escalation
Writing `/etc/claude-code` needs root. The runtime runs as the operator account, and the module uses
that account's passwordless `sudo`; on a machine without it, `claude_code_render` says so and nothing
is written.
is written. The same sudo starts the helper as the agent's account, where there is one.
## Code