mesh/merge-gate pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh, merge-check-typescript.sh): main's version judged it; the change's judges the pull r…
mesh/delivery delivered
Removing a rule from the operator's own home changes what their sessions obey as surely as placing one: an agent could remove the rule that constrains it (hq issue 340).
196 lines
16 KiB
Markdown
196 lines
16 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 operator account's home, the operator'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.
|
|
|
|
## 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 |
|
|
| 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 `Bash`, `Edit`, `Write`, `MultiEdit` and `NotebookEdit`: 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-controller settings set claude-code '{"managed_settings": {…}}' [--node <node>]`, typed at the
|
|
controller's terminal. 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-controller 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.
|
|
|
|
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`.
|
|
|
|
## Settings
|
|
|
|
Per node or for the whole mesh, at the controller's terminal alone: `mesh-controller settings set claude-code '<json>'
|
|
[--node <node>]`. 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
|
|
|
|
## 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.
|
|
|
|
## 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.
|