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:
+133
@@ -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).
|
||||
+212
@@ -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.
|
||||
Reference in New Issue
Block a user