Issue 340: an agent can plant a hook in every agent session on the mesh
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request

Record the hole the review traced, why the module cannot tell who called,
which kinds run or are obeyed in a session and which stay registrable,
the fix in the controller and the module, what is registered today, and
that it must land before ADR 0266's agent account is switched on.
This commit is contained in:
jochen
2026-10-09 02:30:11 +02:00
parent 4b6642de5e
commit 2953b8a8f4
2 changed files with 238 additions and 0 deletions
@@ -0,0 +1,99 @@
---
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
its keys without one, so `mesh-controller.settings {module: claude-code, values: {"managed_settings":
{"hooks": …}}}` was taken from any caller.
- **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.
## 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. 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. 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,139 @@
# 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, and stays registrable.** Instructions, output styles and skills steer the agent, as
any text in its context does. A session still asks before it runs what the text suggests, under the
operator's permission rules and the mesh's guard. A skill is also invoked by the model on its own, which is
why its shell lines and pre-approved tools are refused. What the text alone asks for is judged by the
session. Blocking all registered text would end the shared instructions the mesh relies on, and would not
stop text reaching an agent by other routes (a file in a repository, a tool's answer). This is a judgement,
and it is said: a session in a mode that runs everything without asking would run what a registered text
asks for. That mode is the operator's own choice for their own session.
### 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 asks for every key it holds without one.
The module's settings file is mergeable, so `managed_settings`, `mcp_servers` and `role` were the verb's.
- **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.
- **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 mergeable file asks for every key its own content names. Unless the file says `"trusted": false`, each
is a terminal key, refused through every verb like the keys issue 339 named. For the agent's module,
`managed_settings`, `mcp_servers` and `role` are the terminal's.
2. The refusal names this issue beside 339.
This reaches every mergeable file whose content names keys, not only the agent's. On 2026-10-09 that was 12
more files in the catalogue: the database web app, the agent's licence manager, the dashboards, the agent
memory store, the chat client's look, the watcher's and the messenger's chat ids, the flow editor, the route
adapter, the search engine, the database pooler and the network controller. In the media catalogue it was 5:
the library sorter and four download clients' settings. Through a verb these keys were the caller's before.
Many decide what a consumer trusts or reaches: a route, a chat id that verifies a sender, a password.
`module check`'s count of files that do not say whether they are trusted rose from 9 to 22 over the catalogue. An author opts a
harmless one out with `"trusted": false`. That waits until the node-engine accepts the field, as issue 339
says.
A key the file does not name still lands through a verb. Nothing reading the agent's file reads such a key.
**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.
4. What the module kept in its own file before is judged again when it renders.
5. Removing stays: unregistering a tool server, a hook or settings takes away what runs.
6. 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`.
### 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;
- one tool server for the laptop alone, a program in the operator's own home: still rendered;
- 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"}`;
- `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.