mesh/merge-gate pass: builds claude-code, mesh-issues → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 o…
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
The operator asked for duplicate issues to be consolidated as they are opened. The tracker consumes its own opened event and asks the agent on its node, through claude-code's new claude_code_judge, whether an older issue not resolved asks the same question; it closes only the newer issue of a certain duplicate, keeping its report on the older one, and fails loudly when it cannot judge. groom runs the same judgement over the backlog as a dry run, applied on confirm.
286 lines
25 KiB
Markdown
286 lines
25 KiB
Markdown
# claude-code
|
|
|
|
The operator's agent on a machine (novox/hq design 36): its package, its machine-wide managed
|
|
configuration, and the consumer side of the Anthropic licence manager (design 39, ADR 0183).
|
|
|
|
## What it owns
|
|
|
|
Two directories, declared, so the mesh refuses a second module owning either:
|
|
|
|
- `/etc/claude-code`, the agent's machine-wide managed directory, root's, `0755`.
|
|
- `~/.claude` under the home of the account agents run as (the operator's, unless the machine names an agent
|
|
account of its own), that account's, `0700`. The module owns the directory —
|
|
that it exists, who owns it, its mode — and of what is inside only what it writes. Everything else
|
|
in it (memory, history, projects, local settings, a person's own rules and skills) is the person's
|
|
and is never read or written (hq ADR 0182). Unassigned, the module leaves the directory: the host
|
|
removes a directory only when it is empty.
|
|
|
|
And, for where the agent runs as an account of its own (hq ADR 0266):
|
|
|
|
- **the account agents run as**, a `user` named by the machine fact `agent-account` — the operator's
|
|
account where the machine names none, which declares nothing new of it. Where it names one, the mesh
|
|
makes the account (its home, its own group, no password, no group of the mesh's) and asks the node-engine
|
|
to judge `root: never` on every look: that it is not root, is in no group that grants root, has no sudo
|
|
rule and can read no secret the mesh placed for another account. A way found is this module's condition,
|
|
and the controller's self-check says it urgently.
|
|
- `/usr/local/bin/claude-agent`, root's, `0755`: starts the agent as that account — `claude` itself when run
|
|
as it, otherwise `sudo -iu <account> claude` through the operator's own sudo, carrying the guard's
|
|
override only when it is set in the operator's shell.
|
|
- `/etc/claude-code/agent-account`, root's, `0644`: the name of that account, the machine fact
|
|
`agent-account`, for the shell to read — the controller fills no machine fact into shell code.
|
|
- **`claude` in the operator's interactive zsh is `claude-agent`** where the machine names an agent account:
|
|
zsh code in the login shell's `normal` slot (hq ADR 0204) reads that file and adds
|
|
`alias claude=claude-agent` only when it names an account other than the shell's own. Where agents run as
|
|
the operator's account the file names that account, and nothing is added. `claude-agent` still runs the
|
|
real `claude`: an alias is a word of an interactive zsh, and neither `exec`, `sudo` nor its `sh` sees it.
|
|
|
|
## What it writes
|
|
|
|
Under the agent's managed directory, `/etc/claude-code`, owned whole by this module and rewritten
|
|
whenever the node's tool runtime collects the module's tools:
|
|
|
|
| file | holds |
|
|
|---|---|
|
|
| `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh`, and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's |
|
|
| `managed-settings.json` | the keys set in this module's `managed_settings` setting — at the controller's terminal alone (hq issue 340) — under the mesh's own keys: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, the key-helper while the node holds an API-key licence, and the two that name the `nox-mesh` marketplace and enable its plugin |
|
|
| `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions, the **"instead of" table** — then the instruction sections registered for every node and for this one |
|
|
| `marketplace/` | the `nox-mesh` plugin (hq ADR 0216): the skills, subagents, commands and output styles registered for every node and for this one, offered in a session as `nox-mesh:<name>` — and the **guard on the agent's shell** (`guard/`), the mesh's own hook and the plugin's only one (hq issue 340). Replaced whole, staged beside and swapped in |
|
|
|
|
Under the operator's home: `~/.claude/.credentials.json`, only when the licence manager hands this node a
|
|
subscription token; and what is registered at the **home** scope for this node — a skill, subagent,
|
|
command, output style, or instructions as a rule file — each path recorded in the module's state
|
|
(`home-placed.json`). It writes, changes and removes only those — never a path the person made, even one with the
|
|
same content, and never through a directory that is a symbolic link. A placed file changed by hand is left
|
|
alone, and one the person deleted stays deleted until the item is unregistered (hq ADR 0182). The status tool reads the rest of the home's
|
|
items to report them; nothing else is read or written.
|
|
|
|
## Over NATS
|
|
|
|
Everything between this module and the rest of the mesh is NATS, in three kinds: an **event** says that
|
|
something happened and carries no secret, because a stream keeps it; a **request** carries a token,
|
|
because nothing keeps it (hq design 32 §10); and **state** is the current value of something every node
|
|
must see, a node that joins later included — kept, so it carries no secret either (hq ADR 0201).
|
|
|
|
| what | how |
|
|
|---|---|
|
|
| what this node holds | the module's `holdings` state, one key for this node — the account, the kind, fingerprints and expiries, never a token — written at start and whenever the credentials file changes (hq ADR 0206) |
|
|
| a person ran `/login` here | the credentials file gains a refresh token this module never writes; its next report shows it, and the licence manager asks `claude_code_grant` for it, giving its key — the one time a refresh token leaves the node, for the manager to adopt by refreshing it |
|
|
| what this node should hold | the licence manager's `bindings` state, this node's key; on a newer generation this module asks `anthropic-licence-manager.current` for its token, sealed to the key it sends, and writes it access-token-only — so the agent here never refreshes. A node that was off reads its key when it is back |
|
|
| an MCP server registered through this module before registering was refused (hq issue 340) | a key in the module's `servers` state — `all.<server>` for every node, `<node>.<server>` for one; every node watches it and renders what applies to it, a node's own entry over the one for every node. A node that joins later, or was off, reads the whole current set at start; unregistering is a delete. Nothing adds a key any more: a new tool server is the operator's, in the `mcp_servers` setting at the controller's terminal |
|
|
| a proposed instruction section (hq ADR 0274) | a key in the module's `proposals` state per proposal, the ask published on `operator-channel` under this module's name, and the router's record of it read under that name alone; what a node approved is its own file, never the bus |
|
|
| the agent's configuration (hq ADR 0216) | a key in the module's `config` state per registration — `mesh.<kind>.<name>` for every node, `node.<node>.<kind>.<name>` for one, `home.<node>.<kind>.<name>` for one account's own directory — the item and its files in one value, at most 256 KiB. Every node watches it and renders what applies to it, a node item over a mesh item of the same kind and name. A hook, settings, or an item that would run something by itself is never taken, whenever it was put there (hq issue 340) |
|
|
|
|
## The mesh's tools first (hq ADR 0245)
|
|
|
|
The agent kept reaching for `ssh <machine> journalctl` while the service manager's `journal` verb existed. So:
|
|
|
|
- **The "instead of" table.** Every seat verb and module tool may say which shell commands it replaces
|
|
(`replaces`, in the seat's definition or the module's manifest). The module asks the controller — `tools`,
|
|
`modules`, `nodes`, `node` — at start, every ten minutes and on `claude_code_render`, keeps the answer in its
|
|
state (`mesh-tools.json`), and renders it into `CLAUDE.md` as one row per seat or module: `<node>/<seat>.` and
|
|
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 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
|
|
(any `*.internal` name, a machine's name or a name under its domains, one of its addresses, as `ssh -G` reads
|
|
the destination; a jump through one too), writing `/etc/hosts` or `/etc/resolv.conf`, and `HOSTALIASES` —
|
|
naming the verb that does the job on that machine when one says it replaces the command, and otherwise that a
|
|
missing tool is created in the module that owns it, never worked around. **Stated, not silent:** an ssh login
|
|
as `git` is the forge's account, which runs nothing but git, and passes; a git remote is never an ssh command
|
|
line anyway.
|
|
- **The operator's override**: `MESH_GUARD_OVERRIDE=<why>`, exported in the operator's own shell before the
|
|
session starts. It is read from the session's environment as the kernel kept it at exec
|
|
(`/proc/<pid>/environ` of the agent's process), so nothing a session does — a command's `export`, a
|
|
settings `env` key — can set it, and a command naming it is refused outright. Every override and every
|
|
refusal is a line in the module's `guard.log`; an override that cannot be recorded is not honoured.
|
|
`claude_code_guard` shows the rules, the data and the record.
|
|
|
|
It is a guard against the habit, not a sandbox: a command written to hide what it runs can hide it, and the
|
|
record is how a habit that found a way round is seen.
|
|
|
|
## Tools
|
|
|
|
`claude_code_status`, `claude_code_render`, `claude_code_guard`, `claude_code_pull`, `claude_code_grant` (for the licence
|
|
manager), `claude_code_mcp_list`, `claude_code_mcp_unregister` (this node by default; `nodes: "all"` or a list for
|
|
more).
|
|
|
|
**What a session runs or obeys is the operator's, at the controller's terminal** (hq issue 340). A hook, a
|
|
permission rule, any other managed setting, and a tool server run in, or decide what runs in, every session on the
|
|
nodes they reach, as whoever starts it — the operator, whose account holds root. The tool runner acts for any caller of
|
|
the mesh MCP server and is not told who called, so this module cannot tell the operator from an agent confined on
|
|
another node. So these tools refuse, and say why and how it is done instead:
|
|
|
|
- `claude_code_hook_register`, `claude_code_settings_set`, `claude_code_permission_add`, `claude_code_permission_remove`:
|
|
the operator sets hooks, permission rules and every other managed setting in this module's `managed_settings`
|
|
setting, `mesh-cli settings set claude-code '{"managed_settings": {…}}' [--node <node>]`, typed at the
|
|
controller's terminal (`mesh-cli`, or `nox`, on the control-node; hq ADR 0272). The controller refuses `managed_settings`, `mcp_servers` and `role` through any verb: a
|
|
mergeable file asks for every key its own content names;
|
|
- `claude_code_mcp_register`: the operator sets tool servers in the `mcp_servers` setting the same way;
|
|
- a skill, subagent or command that would run something by itself: a shell line the agent runs when it loads one
|
|
(`` !`…` ``, or a block opened with ```` ```! ````), or `hooks`, `mcpServers`, `permissionMode` or `allowed-tools`
|
|
in its front matter. Front matter is read as YAML (after a byte-order mark, flow style and merged keys
|
|
included); front matter that does not close, does not parse or is not a mapping is refused rather than guessed
|
|
at. A skill or command referring to a file with `@` that is not one of its own files is refused too: the agent
|
|
reads it into the prompt. A script beside a skill is taken: it runs only when the agent runs it, through the
|
|
session's own rules.
|
|
|
|
What was registered before stays: a tool server is still rendered, and may be unregistered; a hook or settings the
|
|
state still holds are never rendered, and `claude_code_hook_unregister` and `claude_code_settings_clear` remove
|
|
them. `claude_code_registrations` lists everything registered, with who registered it, when, and whether it is
|
|
rendered, beside what the operator set for this node at the terminal. Its `by` is the node whose tool runner served
|
|
the call, never the caller, whom the runner is not told.
|
|
|
|
**Text for every node or some is set at the controller's terminal too, for now** (hq issue 340, the operator's
|
|
decision of 2026-10-09; a surface of the operator's own may update it later). Instruction sections, output styles,
|
|
skills, commands and subagents at the `mesh` or `node` scope are refused through the tools, and the import tool is
|
|
refused. They are keys of this module's settings, each text a list of its lines, because a setting holds no line
|
|
break: `instructions`, `output_styles`, `commands` and `agents` map a name to its text; `skills` maps a name to its
|
|
files, each a list of lines. For example `mesh-cli settings set claude-code '{"instructions": {"conventions":
|
|
["Commit in the imperative.", "", "Test before pushing."]}}'`. They land where a registration did, laid over what was
|
|
registered before; what the operator writes there is not judged, but an item that is not well formed is left out and
|
|
said in the render's answer. What was registered before is still rendered and may be unregistered.
|
|
|
|
**An instruction section may be proposed for the operator's approval** (hq ADR 0274). An agent calls
|
|
`claude_code_instructions_propose` with a scope (`mesh`, or `node` and the nodes), a name and the text — a rule, a
|
|
hand-over, the context a later session needs — or with `remove` for a section approved before. Nothing becomes
|
|
active: the proposal is kept in the module's `proposals` state and the operator is asked through the operator
|
|
channel (`operator-channel`, hq ADR 0259) with Approve and Decline, both at the level approve, so only a channel that
|
|
proves who answered (Telegram) carries either. The phone shows the whole text when it is short and every line may
|
|
leave the mesh, else its first lines and a fingerprint; the whole text is read at the controller's terminal with
|
|
`mesh-cli ask claude-code claude_code_proposals '{"id": "<id>"}'`. Decline, or no answer within a day, discards it.
|
|
**Every node proves the approval itself**: it reads the router's own record of the ask under this module's name,
|
|
judges the record as this module would have made it, composes the ask again from the proposal and holds the warrant to it (the same ask digest, the option's bound act,
|
|
the text's digest, in time), and only then writes the section into its own `approved.json`, which the managed
|
|
instruction file renders under "Instructions the operator approved". A proposal is decided once per node, and an approval older than the last change applied to its section is never applied; a record
|
|
deleted from the bus removes nothing. Refused: shell lines, front matter, any `@` after a non-letter (a file the instruction file may read in), invisible or control characters, headings of level one to three, anything shaped like
|
|
a secret, more than 16 KiB, a name the terminal sets, and — at once, naming the terminal's line — whenever no channel
|
|
that proves its sender is there. The controller's hand-act log records each warrant once (`warranted`), read from the
|
|
router's record. The text is never logged.
|
|
|
|
The home scope is registered through the tools **only where the home belongs to an agent account of its own**
|
|
(hq ADR 0266, issue 340), and only for the node whose module serves the call. Where the agent runs as the operator,
|
|
the home is the operator's own `~/.claude`, whose rules and skills load in every session the operator starts, and
|
|
the tool runner cannot tell the operator from an agent: refused there, and for any other node, whose home cannot be
|
|
judged from here (call that node's own tool). A home item on the bus is never placed in the operator's own home,
|
|
and what the module placed there before is taken back unless the person changed it. `claude_code_home_remove` and
|
|
`claude_code_home_restore` are refused the same way wherever the home is the operator's own: removing a rule changes
|
|
what a session obeys as surely as placing one. Where it is taken:
|
|
|
|
- for each kind — `skill`, `agent`, `command`, `output_style`, `instructions` — `claude_code_<kind>_list`,
|
|
`_register`, `_unregister`. A skill is its files (`files`, or `content` for a lone SKILL.md);
|
|
- `claude_code_hook_list` and `_unregister`, `claude_code_settings_get` (the managed settings as written, the
|
|
operator's setting, and what was registered before and is not rendered) and `_clear`;
|
|
- `claude_code_config_list`, `_show` (one registration in full), `_status` (what applies here, the plugin
|
|
as written, and the home's own items — which the mesh placed, which share a name with a mesh item, which
|
|
call a tool server not loaded here) and `_import` (an item of this node's home, registered at a scope;
|
|
the original stays);
|
|
- `claude_code_home_show` (`kind`, `name`): one item of this node's home in full — a skill, subagent,
|
|
command, output style, rule file (`instructions`), or the account's own memory `~/.claude/CLAUDE.md`
|
|
(`memory`) — and whether the mesh placed it;
|
|
- `claude_code_home_remove` (`kind`, `name`, `why`): removes one item the person made, on their word —
|
|
removing it is the person's act, and this tool is that act made explicit. `why` is required; what the
|
|
mesh placed is refused (its `_unregister` owns it), and so is a symbolic link. A copy is kept first in
|
|
the module's state, `removed-from-home/<date>/<time>-<kind>-<name>/`, with a `removal.json` note, and
|
|
the removal and its reason are appended to `removed-from-home/removed.log`; the answer names the copy;
|
|
- `claude_code_home_removed`: every kept removal, newest first, with its `keptAt` and whether it was put back;
|
|
- `claude_code_home_restore` (`kept`): puts a removal's copy back at its path — refused when something is
|
|
there now, or when the copy is not, file by file, what its `removal.json` digests say was removed.
|
|
Logged to `removed.log` and the journal; the copy stays, marked with a `restored.json`.
|
|
|
|
A new session takes a change; a running one at `/reload-plugins`.
|
|
|
|
**One judgement** (hq ADR 0278): `claude_code_judge` (`question`, `schema`, optionally `model`) answers a question
|
|
only a language model can, for another module — the issue tracker's duplicate check. It runs
|
|
`/usr/bin/claude -p --output-format json --json-schema <schema> --model <model> --tools "" --safe-mode
|
|
--no-session-persistence` as the account agents run as here (through `sudo -n -H -u <agent>` where that is an account of
|
|
its own), in an empty directory, the question on its input: no built-in tools, no tool servers, plugins, hooks or
|
|
instruction files, no session kept, so it can only answer. It answers the structured answer, the model, the node
|
|
and the seconds, within 25 seconds — the process is ended past that, under the bus's 30 — and one at a time on a
|
|
node. Refused: an empty question or one over 64 KiB, a schema that is not an object, a model not `sonnet`, `haiku`
|
|
or `opus`, and a node holding no licence. The question is never logged.
|
|
|
|
## Settings
|
|
|
|
Per node or for the whole mesh, at the controller's terminal alone: `mesh-cli settings set claude-code '<json>'
|
|
[--node <node>]` on the control-node (in zsh, `nox`; hq ADR 0272). The controller refuses all three keys through any verb, `mesh-controller.settings` included, because
|
|
each reaches every session on the node and a verb's caller may be an agent (hq issue 340). Reading them through the
|
|
verb still answers.
|
|
|
|
- `role` — what this node is, in a few words; shown to every session.
|
|
- `mcp_servers` — the tool servers, set by the operator for the mesh or a node, beside the ones
|
|
registered through the tools before that was refused; keyed by name, in the vendor's `.mcp.json` entry shape
|
|
(`{"type":"http","url":…}` or `{"type":"stdio","command":…,"args":[…]}`). The name `mesh` is the
|
|
module's own and cannot be set. Put a person's own servers here, or they stop loading.
|
|
- `managed_settings` — keys of the agent's managed settings, in the vendor's `settings.json` shape:
|
|
`permissions` (allow, ask, deny), `autoMode` (environment, allow, soft_deny), `env`, hooks and so on.
|
|
Managed settings outrank every other scope, so a rule here holds in every session on the node. The
|
|
mesh's own keys (`attribution`, `allowAllClaudeAiMcps`, `apiKeyHelper`) are laid last and cannot be
|
|
set. A setting layer is replaced whole: setting `managed_settings` without `role` or `mcp_servers`
|
|
clears those in that layer.
|
|
|
|
## On a machine that carried the predecessor
|
|
|
|
Remove these by hand, once; the mesh removes nothing it did not make (ADR 0182):
|
|
|
|
- `~/.claude/CLAUDE.md`
|
|
- `~/.claude/rules/00-hal-mesh.md`, `~/.claude/rules/conventions.md`
|
|
- `~/.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, in `~/.claude/mesh/`, since the account cannot enter the module's state. Its key-helper
|
|
is no script there: the managed settings apply to every account's `claude`, the operator's and root's too,
|
|
so a script the agent owns would run as them. The managed settings carry the command whole instead, and it
|
|
reads the key only when run as the agent's account;
|
|
- 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. The same sudo starts the helper as the agent's account, where there is one.
|
|
|
|
## Code
|
|
|
|
Go, one binary (`cmd/claude-code`) the node's runtime launches. Tested with `go test ./...`; the managed
|
|
instruction file is held to the TypeScript renderer it replaced (`testdata/rendered-by-typescript.json`),
|
|
and the sealed box is the licence manager's own format.
|