Merge pull request 'Issue 340: an agent can plant a hook in every agent session on the mesh' (#240) from issues/340-an-agent-can-plant-a-hook-in-every-session into main

This commit was merged in pull request #240.
This commit is contained in:
2026-10-09 08:00:25 +00:00
3 changed files with 402 additions and 0 deletions
@@ -0,0 +1,133 @@
---
status: located
opened: 2026-10-09
located-in: [mesh-catalog (modules/claude-code), mesh-controller (internal/catalogue/terminal_keys.go)]
fixed-by:
amended-design:
---
# 340. An agent can plant a hook in every agent session on the mesh
**Severity: critical.** It is a path from an agent confined on the control node to root on every other node.
## Symptom
A review on 2026-10-09 traced this in the code. Nothing was run against a node.
The `claude-code` module registers the agent's configuration at three scopes
([ADR 0216](../../02-DECISIONS/0216-the-agents-configuration-is-registered-through-its-module-at-three-scopes-and-served-as-one-plugin.md)).
Its tools take a hook, managed settings, a permission rule and a tool server at the **mesh** and **node**
scopes: `claude_code_hook_register`, `claude_code_settings_set`, `claude_code_permission_add` and
`claude_code_mcp_register`. What they register becomes the shared plugin's hook file, the managed settings
file and the managed tool-server file on every node it reaches. The renderer takes out only the key-helper.
Every Claude Code session there loads these files, as whoever starts it.
The tool runner acts for any caller of the mesh MCP server, and it is not told who called
([ADR 0175](../../02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md),
[ADR 0195](../../02-DECISIONS/0195-the-meshs-tools-are-found-by-address-not-announced-whole.md)). So an agent
confined on the control node can register a `SessionStart` hook for every node. The next time the operator
starts `claude` on the laptop, the workstation or the home server, the hook runs as the operator, whose
account has passwordless sudo there.
Two more routes write the same files:
- **The controller's settings verb.** The module's `managed_settings` and `mcp_servers` settings land in a
mergeable file. [Issue 339](../339-a-verb-can-place-a-directory-at-the-machines-own/00-report.md) made a
setting the terminal's alone when a trusted file asks for it with a placeholder. A mergeable file takes
any key without one, so `mesh-controller.settings {module: claude-code, values: {"managed_settings":
{"hooks": …}}}` was taken from any caller. The review found the same for every module with a mergeable
file: an identity provider's empty runtime configuration took a `url` of the caller's, and its provisioner
would send its admin login there.
- **Items whose text runs something.** A skill or a slash command may hold shell lines that the agent runs
when it loads the item, before any permission is asked. A skill's front matter may hold hooks and tools it
pre-approves. A subagent placed in a home may start a tool server or change the permission mode from its
front matter. These kinds are registered at every scope, the home included.
## Why it is an issue
ADR 0266 (still on its own pull request, hq 225, so not linked here) moves agents on the control node
to an account of their own that cannot become root without a person. This route leaves that boundary
sideways: the agent does not become root on the control node, but plants what runs as root's equal on every
other node.
[ADR 0216](../../02-DECISIONS/0216-the-agents-configuration-is-registered-through-its-module-at-three-scopes-and-served-as-one-plugin.md)
§7 assumed the vendor's guard stops an agent from loosening its own settings. That guard judges a session's
own settings file. It does not see a tool call that changes the managed files of another node.
## Impact
- **Any caller of the mesh MCP server could run a command as the operator on every node running the agent**,
at the operator's next session there. That includes every agent session, on the control node too.
- **Whether anyone did is not known for certain, but nothing shows it.** On 2026-10-09, read-only:
- no hook, no settings and no permission rule was registered through the module;
- one tool server was registered, for the laptop alone: a program in the operator's own home, which the
operator made;
- three instruction sections were registered for the mesh;
- no node's layer of the module's settings set `managed_settings` or `mcp_servers`, and no layer had ever
been replaced.
## The operator's decisions (2026-10-09)
1. **The wider controller rule stands.** Every module with a mergeable file that does not say
`"trusted": false` takes its settings at the controller's terminal alone.
2. **Shared agent text is the terminal's, for now.** Instruction sections, output styles, skills, commands and
subagents for every node or some are set at the controller's terminal, in the agent module's settings, and
refused through the tools. A person's own home stays free where it is an agent account's own; the
operator's own home is not written from the tools (see "How it is checked"). In the operator's words: "once we build a UI, we
can also update the context through it". A surface of the operator's own, which can tell the operator from
an agent, is the intended way back.
3. **Passwords are not settings.** They come from the key vault and can be rotated. Every module found taking
a password, token or API key through a setting is recorded in
[issue 342](../342-a-password-is-taken-as-a-setting-and-outranks-the-vaults/00-report.md), to move to
vault-held secrets, not to stay terminal-only settings.
## Still open after these pull requests
- Text at the mesh or node scope written straight into the bus state still renders. It needs a tool runner's
credential. It closes once the three instruction sections are set at the terminal and text is no longer
rendered from the bus.
- The tool servers left on the bus stop rendering with their own pull request, after the laptop's tool server
is set at the terminal.
- Passwords taken as settings: [issue 342](../342-a-password-is-taken-as-a-setting-and-outranks-the-vaults/00-report.md).
## Ordering
**This must land before ADR 0266's agent account is switched on** (hq pull request 225). Until it lands, the
agent account's boundary does not hold, whatever the node-engine judges of the account itself.
## The acceptance test (the operator's view)
No agent session can change what runs in another person's or node's Claude Code session. Hooks,
permissions, tool servers and managed settings at the mesh or node scope can only be registered or changed
at the controller's terminal. An agent's attempt through any mesh tool is refused and says why.
## How it is checked
- **In the module's tests, through its real tool dispatch.** The test serves the module's tools the way the
runtime speaks to them and calls each one: a hook, settings with a hook, a status line or a permission
mode, a permission rule added or removed, a tool server by command or by address, and a skill, command or
subagent with a shell line or with `hooks`, `allowed-tools`, `mcpServers` or `permissionMode` in its front
matter, at the mesh, node and home scopes. Front matter hidden behind a byte-order mark, written as a flow
mapping or with merged keys, and front matter that does not parse, are refused, and so is an `@` reference
to a file outside the item. Each is refused with an error that names this issue, and
nothing is put on the bus or placed in a home. On the commit before the fix every one of them was taken.
- **In the module's render tests.** A hook, settings or a skill with a shell line already on the bus, or
kept in the module's own file by an older build, is never rendered. The tool server and the instruction
sections registered before are still rendered, may be unregistered, and are listed with who registered
them. The operator's `managed_settings`, hooks and permission rules included, are what the managed
settings file holds.
- **In the controller's tests, through the real verb dispatch.** Setting, changing, dropping or clearing
the module's `managed_settings`, `mcp_servers` or `role` through the `settings` verb, for one machine or
the mesh, and through the `command` verb, is refused with an error naming this issue. Text for every node or
some, of each kind, is refused through the tools and the import tool, and lands when set in the module's
settings at the terminal. The home scope is refused for any node but the one serving the call, and there
wherever the home is the operator's own; it is taken only where the home is an agent account's own, and a
home item on the bus is never placed in the operator's home. Through the settings verb, a new `url` on a
module whose mergeable runtime configuration starts empty is refused too, while a file marked
`"trusted": false` stays the verb's. The layer is
unchanged after every refusal. At the terminal the same is taken. These tests fail on the commit before
the fix.
- **Live, after both are taken:** the calls in the diagnosis's last section are each refused, and
`claude_code_registrations` lists what it listed on 2026-10-09.
The diagnosis is in [01-diagnosis.md](01-diagnosis.md).
@@ -0,0 +1,212 @@
# 340 — Diagnosis
## 2026-10-09
**The records first.** `records_search` for `claude_code_hook_register` and for the module's phrase "refuses
to loosen its own settings" found nothing. The rule the tools rested on is in
[ADR 0216](../../02-DECISIONS/0216-the-agents-configuration-is-registered-through-its-module-at-three-scopes-and-served-as-one-plugin.md)
§7 and [ADR 0213](../../02-DECISIONS/0213-the-operator-sets-the-agents-managed-settings-through-the-agent-module.md)
option 3: the operator sets the managed settings through the controller's settings verb, and the vendor's
guard refuses an agent that loosens its own. Neither asks who may call the verb or the tools, nor whether the
settings reach a session other than the caller's.
### Can the module tell who called?
No. The runtime hands a bundle a `tools/call` with a tool's name and its arguments, nothing else (the SDK's
`ServeOn`). The runtime answers for any account the mesh MCP server serves, so the operator's own session and
an agent's reach a tool the same way. ADR 0266 §7 says the same of the tool runner. The only signal the mesh
has that a person typed a line is the controller's: a command a verb runs carries the verb in its environment
(`MESH_VERB`, issue 327), and a line typed at the terminal does not. Issue 339 built its terminal-only
settings on that signal. A terminal-only command line in the module was considered and not taken: it would
need the module's bus credential at the operator's terminal on every node, and it would duplicate what the
controller's settings already are.
So the robust signal is the controller's terminal, and the module's own tools refuse.
### What a session runs or obeys, by kind
What the module writes, and whether it runs without the session's own permission check (the vendor's
documentation of skills, subagents, plugins, output styles and memory, read 2026-10-09):
| Kind | Where it lands | Runs, or decides what runs | Decision |
|---|---|---|---|
| hook | the plugin's hook file | runs its command on its event, as the session's account | terminal only, in `managed_settings` |
| settings, permission rules | the managed settings file | permissions, permission mode, hooks, the status line command, the environment, the key-helper | terminal only, in `managed_settings` |
| tool server | the managed tool-server file | a stdio server is a program the session starts; an http server is handed what the agent asks | terminal only, in `mcp_servers` |
| skill, slash command | the plugin, or a home | shell lines (`` !`…` `` and blocks opened with ```` ```! ````) run when the item loads, before any permission is asked; `allowed-tools` pre-approves tools; a skill's `hooks` run | refused when it holds any of these |
| subagent | the plugin, or a home | in a home, `hooks`, `mcpServers` and `permissionMode` are honoured; in a plugin they are ignored | refused when it holds any of these |
| output style, instructions, rule file | the plugin, the managed instruction file, a home | text the model reads; no field runs anything | still registered |
| a script beside a skill | the plugin, or a home | only when the agent runs it, through the session's own rules and the guard | still registered |
**Text is a lower class. As the review first left it, it was still registrable through a verb, which was a
risk for the operator to take or close; the operator closed it (below).** Before that decision, at the mesh and
node scopes, an instruction section, an output style, and a skill, command or subagent without the fields above
were still registered through the module's tools, so any caller of the mesh
MCP server could register them, an agent confined on the control node included. They reach every session on the
nodes they name, as text the model reads:
- the instruction sections land in the managed instruction file, which every session reads first and treats
as the mesh's own word;
- a skill is invoked by the model on its own when its description matches what it is doing.
So a registered text can ask the operator's agent, on every node, to run a command. Nothing in it runs by
itself: the session still asks before it runs what the text suggests, under the operator's permission rules and
the mesh's guard. But a session in a mode that runs without asking, or a person who approves what looks like the
mesh's own instruction, would run it as the operator. That is the same sideways path as a hook, made one
approval longer.
What closing it costs: the mesh's shared instructions and skills, which agents keep up today, are set at the
controller's terminal alone. It also does not stop text reaching an agent by other routes (a file in a
repository, a tool's answer).
**The operator decided on 2026-10-09: this text is the terminal's, for now.** At the mesh and node scopes every
kind is refused through the tools and set at the controller's terminal. The home scope, one account's own
directory, stays registrable where that account is an agent's own (the next section). In the operator's words: "once we build a UI, we can also update the context
through it". A surface of the operator's own, which knows who is using it, is the intended way back.
### The home scope reached the operator's own home
The confirmation review found the home scope a way round the rest. `scope: home` takes a list of nodes. On the
laptop, the workstation and the home server the agent runs as the operator, so the home there is the operator's
own `~/.claude`, and its `rules/` and `skills/` load in every session the operator starts. An agent on the
control node could reach it two ways: by naming those nodes in `nodes`, or by addressing the other node's own
tool (`<node>/claude-code.<tool>`), which serves it there as if the operator had called.
The fix, in the module:
- the home scope is taken only for the node whose module serves the call, and only where its home belongs to an
agent account of its own: the agent runs there as an account that is not the operator's (ADR 0266), and the home
the module writes is that account's. Any other node is refused, because whose home it is cannot be judged from
elsewhere; that node's own module judges it;
- a home item on the bus is never placed in the operator's own home, whoever put it there, and what the module
placed there before is taken back unless the person changed it;
- every node a call names is judged before anything is put, and a list naming one refused node is refused whole.
- removing an item of the home, or putting one back (`claude_code_home_remove`, `claude_code_home_restore`), is
refused the same way wherever the home is the operator's own, because an agent could remove a rule that constrains it.
Until ADR 0266's account is switched on, no node has an agent account of its own, so the home scope is refused
everywhere. Nothing is registered at the home scope today.
### The routes that write the same files
- **The module's tools**: refused (below).
- **The controller's settings verb**: `TerminalKeys` (`internal/catalogue/terminal_keys.go`) counted the
settings a file asks for by placeholder alone. A mergeable file takes every key a layer sets, named in its
content or not. The module's settings file is mergeable, so `managed_settings`, `mcp_servers` and `role` were
the verb's. The review found the same for every other mergeable file: 13 runtime configurations start empty,
and a provider reads what lands in them. A `url` set through a verb on the identity provider's would have
had its provisioner send the admin login to the caller's listener.
- **The catalogue's defaults**: the module's settings file holds empty values only. No layer sets them in
the catalogue.
- **The bus**: the module's `config` and `servers` state. Only a holder of the module's grants writes them,
through the tool runner's credential. On the control node, under ADR 0266, the agent account cannot read
it. On the other nodes an agent runs as the operator already. The module still judges every item it takes
from the state, so a hook, settings or an item that runs something is never rendered, whoever put it there.
**A gap stays open here:** the item check (`Problem`) refuses hooks and settings at every scope, but text at the
mesh or node scope is refused only by `Register`, the tools' path. Text put straight into the state at those
scopes still renders. Doing so needs a tool runner's credential, as with the tool servers. What closes it: the
three instruction sections set at the controller's terminal, then text no longer rendered from the bus.
- **The `nox-mesh` plugin** is the module's own render. It carries the guard's hook and nothing else that runs.
### The fix
**In the controller** (pull request in `mesh-controller`):
1. A module with a mergeable file not marked `"trusted": false` has its **whole layer** set at the terminal.
Through any verb, any change to it is refused, whatever the key, a key no file names included. Reading it
through a verb still answers. For the agent's module that covers `managed_settings`, `mcp_servers` and
`role`.
2. A mergeable file's own keys are also counted as keys it asks for, so `module check` names the file when it
does not say whether it is trusted.
3. The refusal names this issue beside 339.
This reaches every module with a mergeable file, not only the agent's. On 2026-10-09 that was 25 modules in the
catalogue and 11 in the media catalogue, every one with a file that says nothing about trust. Through a verb
their settings were any caller's before. Many decide what a consumer trusts or reaches: a provider's address, a
route, a chat id that verifies a sender. Some take a password, token or API key; the operator ruled on
2026-10-09 that a password is not a setting, and those modules are
[issue 342](../342-a-password-is-taken-as-a-setting-and-outranks-the-vaults/00-report.md). From now on the operator sets them at the terminal. An
author gives a verb back a file nothing trusts with `"trusted": false`. That waits until the node-engine accepts
the field, as issue 339 says. `module check`'s count of files that do not say whether they are trusted rose from
9 to 22 over the catalogue. It counts the files whose content names keys. A file that starts empty is not
counted, but is refused all the same.
**In the module** (pull request in `mesh-catalog`):
1. `claude_code_hook_register`, `claude_code_settings_set`, `claude_code_permission_add`,
`claude_code_permission_remove` and `claude_code_mcp_register` refuse whatever they are given. The error
says why and gives the terminal's line. The tools stay, so an agent that calls one learns why.
2. An item of the kinds `hook` and `settings` is refused at registration, never taken from the state, and
never rendered. The plugin's hook file holds the guard alone. The managed settings file holds the
operator's `managed_settings` under the mesh's own keys.
3. A skill, subagent or command holding a shell line, or `hooks`, `mcpServers`, `permissionMode` or
`allowed-tools` in its front matter, is refused at every scope, and the import tool refuses one too.
Front matter is read as YAML, after a byte-order mark, in flow style and with merged keys. Front matter
that does not close, does not parse, holds a key twice or is not a mapping is refused rather than guessed
at.
4. A skill or command that refers with `@` to a file that is not one of its own files is refused. The agent
reads such a file into its prompt when the item loads, so the item would carry another file's words, or a
secret, into every session it reaches.
5. What the module kept in its own file before is judged again when it renders.
6. Removing stays: unregistering a tool server, a hook or settings takes away what runs.
7. A new read-only tool, `claude_code_registrations`, lists every registration with its `by`, `at`, and
whether it is still rendered and why not, beside what the terminal set for the node. `by` is the node
whose tool runner served the call. The caller was never recorded, because the runner is not told. A tool
server's registration kept no `by`.
8. **Shared text is set at the terminal** (the operator's decision, above). Through the tools, an instruction
section, output style, skill, command or subagent at the mesh or node scope is refused, saying where it is set,
and the import tool is refused. The module's settings gain `instructions`, `output_styles`, `skills`,
`commands` and `agents`. A setting holds no line break (issue 339), so each text is a list of its lines.
They land where a registration did, laid over what was registered before. What the operator writes there is
not judged for what it asks of a session, because it is the operator's word. An item that is not well formed
is left out, and the render says so. Text registered before is still rendered, and may be unregistered.
9. **Tool servers from the terminal alone** (a separate pull request, to be merged only after the laptop's tool
server is set in that node's `mcp_servers` at the terminal). The managed tool-server file then holds the
mesh's own entry and the `mcp_servers` setting alone. A server left on the bus is listed and may be
unregistered, and no longer starts in a session. That closes, for tool servers, the route by which a holder of
a tool runner's credential could write the bus directly. Text registered before still renders from the bus.
The same move for it is the step left: the three sections set at the terminal, then text no longer rendered
from the bus.
### Existing registrations
Read on 2026-10-09 through the module's list tools and the settings verb, on all four nodes:
- three instruction sections for the mesh, registered through the laptop: still rendered, until they are set at
the terminal;
- one tool server for the laptop alone, a program in the operator's own home: still rendered, until item 9
merges, which waits for the operator to set it in the laptop's `mcp_servers` at the terminal;
- no hook, no settings, no permission rule;
- no layer of the module's settings sets `managed_settings` or `mcp_servers` on any node, and none was ever
replaced.
Nothing is stranded by the fix. Should the operator want the tool server under the terminal's care, it can be
set in that node's `mcp_servers` and then unregistered.
### The decisions this departs from
ADR 0213 option 3 named the controller's settings verb as the operator's route, and ADR 0216 §2 and §7
registered hooks, settings and permission rules through the module's tools. Both rested on a premise that
does not hold: that the verb's or the tool's caller is the operator, or that the vendor's guard covers
another node's session. The fix keeps what they decided (one managed-settings setting; one plugin) and moves
who may write it to the terminal. A decision record saying so, and the amendment to to-be 36's checks (its
render test row names a registered hook and setting landing in the plugin), follow this issue through
playbook 02.
### The acceptance test, live, after both are taken
Each of these must be refused, saying why, and change nothing:
- `<node>/claude-code.claude_code_hook_register {name: x, event: SessionStart, command: "true"}`;
- `<node>/claude-code.claude_code_settings_set {settings: {"model": "x"}}`, and with `scope: node, nodes: all`;
- `<node>/claude-code.claude_code_permission_add {list: allow, rule: Bash}`;
- `<node>/claude-code.claude_code_mcp_register {name: x, url: "https://example.org/mcp", nodes: all}`;
- `<node>/claude-code.claude_code_skill_register {name: x, content: "---\nname: x\nallowed-tools: Bash\n---\nx"}`, and
with the front matter as a flow mapping, and with `@~/.ssh/id_ed25519` in its body;
- `mesh-controller.settings {module: keycloak, node: <its node>, values: "{\"url\": \"http://192.0.2.1\"}"}`;
- `mesh-controller.settings {module: claude-code, values: "{\"managed_settings\": {\"model\": \"x\"}}"}`, and
with `node`, and `clear: "true"`;
- `mesh-controller.command {command: "settings set claude-code '{\"mcp_servers\":{}}' --node <a node>"}`.
Then `claude_code_registrations` lists what this section lists, and `claude_code_settings_get` on each node
shows the managed settings file unchanged.
@@ -0,0 +1,57 @@
---
status: open
opened: 2026-10-09
located-in: []
fixed-by:
amended-design:
---
# 342. A password is taken as a setting, and outranks the vault's
## Symptom
Reading the catalogue for [issue 340](../340-an-agent-can-plant-a-hook-in-every-agent-session-on-the-mesh/00-report.md)
on 2026-10-09 found modules that take a password, a token or an API key through a **setting**. Nothing was run
against a node, and no node's layers were read for such values, so that no secret is copied into a session.
The operator's rule, said on 2026-10-09: **a password is not a setting.** It comes from the key vault, and it can
be rotated there. A setting is neither: it is kept in the controller's layers, shown by the settings verb to
whoever reads them, kept in the layer history, and rotated by nobody.
Two shapes, found by reading the manifests and the modules' clients:
1. **A mergeable file that holds the vault's secret, which a setting overrides.** The file's content names the
secret as `${secret:…}`, and a layer setting the same key replaces it in the file:
- `baserow` (`password`), `grafana` (`password`), `letta` (`password`), `unifi` (`password`);
- `searxng` (`server.secret_key`);
- the media library's sorter, `kometa` (`plex.token`, `tmdb.apikey`).
2. **A client that reads a secret from its settings file before the vault's.** The settings file starts empty, and
the client reads `cfg.<secret> ?? <the vault's file or environment>`, so a setting wins over the vault:
- `gitea` (`token`), `home-assistant` (`token`), `icecast` (`password`), `influxdb` (`token`),
`mailu` (`apiKey`), `nextcloud` (`password`), `nodered` (`token`);
- in the media catalogue: `bazarr`, `jackett`, `ombi` and `tautulli` (`apiKey`), `nzbget` and `qbittorrent`
(`password`).
Not affected, as read: `jira`, `confluence` and `gitlab` read their token from their own secret's file alone. The
watcher and the messenger take a token *file* path, not a token. The four media download steps read the API key
from the application's own configuration, and the download clients' credentials from the provisions.
## Why it is an issue
- A secret in a setting cannot be rotated by the vault: the setting outranks the rotated value until someone
removes it by hand.
- It is read back by the settings verb and kept in the layers' history.
- Until issue 340's controller fix, any caller of the settings verb could set one, and so point a module at a
credential of the caller's choice. Since that fix these keys are the terminal's. That keeps agents out, and it
still leaves the secret a setting.
## Acceptance test (the operator's view)
No module takes a password, token or API key from a setting. Each comes from a vault-held secret that rotates,
and a setting naming one is refused with the reason.
## How it will be checked
To be written with the fix: per module, a test that a setting naming the secret's key is refused (or ignored, and
said), and the catalogue's check refusing a mergeable file whose own keys hold `${secret:…}` that a layer could
replace.