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..7b0a161f --- /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,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). 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..c883c468 --- /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,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 (`/claude-code.`), 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: + +- `/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"}`, and + with the front matter as a flow mapping, and with `@~/.ssh/id_ed25519` in its body; +- `mesh-controller.settings {module: keycloak, 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 "}`. + +Then `claude_code_registrations` lists what this section lists, and `claude_code_settings_get` on each node +shows the managed settings file unchanged. diff --git a/04-ISSUES/342-a-password-is-taken-as-a-setting-and-outranks-the-vaults/00-report.md b/04-ISSUES/342-a-password-is-taken-as-a-setting-and-outranks-the-vaults/00-report.md new file mode 100644 index 00000000..d8df5551 --- /dev/null +++ b/04-ISSUES/342-a-password-is-taken-as-a-setting-and-outranks-the-vaults/00-report.md @@ -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. ?? `, 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.