diff --git a/04-ISSUES/340-an-agent-can-plant-a-hook-in-every-agent-session-on-the-mesh/00-report.md b/04-ISSUES/340-an-agent-can-plant-a-hook-in-every-agent-session-on-the-mesh/00-report.md new file mode 100644 index 00000000..adc91852 --- /dev/null +++ b/04-ISSUES/340-an-agent-can-plant-a-hook-in-every-agent-session-on-the-mesh/00-report.md @@ -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). diff --git a/04-ISSUES/340-an-agent-can-plant-a-hook-in-every-agent-session-on-the-mesh/01-diagnosis.md b/04-ISSUES/340-an-agent-can-plant-a-hook-in-every-agent-session-on-the-mesh/01-diagnosis.md new file mode 100644 index 00000000..68c74db8 --- /dev/null +++ b/04-ISSUES/340-an-agent-can-plant-a-hook-in-every-agent-session-on-the-mesh/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: + +- `/claude-code.claude_code_hook_register {name: x, event: SessionStart, command: "true"}`; +- `/claude-code.claude_code_settings_set {settings: {"model": "x"}}`, and with `scope: node, nodes: all`; +- `/claude-code.claude_code_permission_add {list: allow, rule: Bash}`; +- `/claude-code.claude_code_mcp_register {name: x, url: "https://example.org/mcp", nodes: all}`; +- `/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 "}`. + +Then `claude_code_registrations` lists what this section lists, and `claude_code_settings_get` on each node +shows the managed settings file unchanged.