claude-code: write the agent's home as its own account where it has one
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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user