The gap
Three process-wide registries in mureo/policy/declarations.py are keyed by bare tool name, with no plugin or distribution identity, and every one of them is last-write-wins:
They are populated at import from _PLUGIN_SEMANTICS (mureo/mcp/server.py:343), which is itself a dict comprehension keyed by bare t.name across all discovered plugin tools from all distributions:
_PLUGIN_SEMANTICS: dict[str, ToolSemantics] = {
t.name: derive_semantics(t) for t in _PLUGIN_TOOLS
}
So if two installed providers ship a tool with the same generic name, one silently overwrites the other's declaration — in _PLUGIN_SEMANTICS first, and then in all three registries downstream (_register_plugin_budget_declarations :353, _register_plugin_bid_declarations :381, _register_plugin_read_only_hints :408).
This is not hypothetical naming paranoia. _declares_from_bridged_table (server.py:517) already documents exactly this risk and guards against it:
Tool names are keyed WITHOUT plugin identity everywhere in this module, and the Amazon manifest's names (campaign_management-update_campaign, …) are generic enough that another provider could plausibly ship the same string. Hanging exact money paths off a bare name would then point Amazon's schema at a different platform's arguments, so the owning distribution is checked against the tool's actual provider instance — the same breadcrumb the audit trail attributes calls with.
That check exists for mureo's own bridged money-path table. It is not applied to the three registries above, which carry self-declared plugin metadata — and those are what actually gate enforcement.
Why it matters
The failure is silent and points the wrong way for safety:
- Budget/bid: plugin A's
update_campaign gets plugin B's BudgetDeclaration, so the gate reads a key that does not exist in A's arguments, finds no proposal, and A's ## Guardrails cap is not applied to a call that really does move money. (Or it reads a differently-scaled field and compares micros against currency units.)
readOnlyHint: plugin A's real mutation inherits plugin B's readOnlyHint=True, and then loses both its pattern-fallback budget/bid scan (_register_plugin_pattern_fallbacks) and its block_learning_resets refusal (mureo/policy/learning_reset.py::_is_mutation).
Neither plugin has to be malicious. Two honest providers shipping list_campaigns or update_campaign is enough, and nothing warns.
Built-in tools are not affected: _is_mutation short-circuits on is_mutating_builtin_tool and _BUILTIN_PREFIXES before any hint lookup, and there is a test pinning that.
Proposed fix
Key all three registries by (distribution, tool_name) — the same breadcrumb _declares_from_bridged_table and the audit trail already use (plugin_source / plugin_provider_name, cf. the plugin:<dist>:<provider> platform key from #537) — and resolve at lookup time from the tool's actual provider instance rather than from the bare name.
Open questions for whoever picks this up:
budget_declaration_for(tool_name) / bid_declaration_for(tool_name) / declared_read_only_hint(tool_name) are called from StrategyPolicyGate.evaluate and from learning_reset, which see only a tool name. Threading the owning distribution to those call sites is the bulk of the work; the dispatcher knows it (_PLUGIN_DISPATCH[name]), the pure policy layer does not.
_PLUGIN_SEMANTICS and _PLUGIN_DISPATCH collide the same way and would need the same treatment for the fix to be complete — a bare-name _PLUGIN_SEMANTICS that already lost a declaration cannot be repaired downstream.
- Cheap interim step, independent of the above: detect and log the collision.
_PLUGIN_TOOLS is built once at import, so a duplicate name is trivially detectable there, and a logger.warning naming both distributions turns a silent overwrite into something an operator can see. Worth doing even if the full re-keying is deferred.
mureo.policy.pattern_scan's _PATTERN_FALLBACK_TOOLS is a bare-name set too, but it holds no per-plugin data — a collision there only over- or under-registers a name for the heuristic scan. Lower stakes; decide whether it is in scope.
Provenance
Pre-existing since #414 / #517; surfaced by the security review of #587, which added the third registry in the same shape as its two siblings (consistent with them rather than uniquely weak) and deliberately left the shared keying problem out of scope.
The gap
Three process-wide registries in
mureo/policy/declarations.pyare keyed by bare tool name, with no plugin or distribution identity, and every one of them is last-write-wins:_BUDGET_DECLARATIONS(declarations.py:245,register_budget_declarationat :248)_BID_DECLARATIONS(declarations.py:319,register_bid_declarationat :322)_READ_ONLY_HINTS(declarations.py:341,register_read_only_hintat :344, added in fix: let a plugin's own readOnlyHint outrank a guess about its name #587)They are populated at import from
_PLUGIN_SEMANTICS(mureo/mcp/server.py:343), which is itself a dict comprehension keyed by baret.nameacross all discovered plugin tools from all distributions:So if two installed providers ship a tool with the same generic name, one silently overwrites the other's declaration — in
_PLUGIN_SEMANTICSfirst, and then in all three registries downstream (_register_plugin_budget_declarations:353,_register_plugin_bid_declarations:381,_register_plugin_read_only_hints:408).This is not hypothetical naming paranoia.
_declares_from_bridged_table(server.py:517) already documents exactly this risk and guards against it:That check exists for mureo's own bridged money-path table. It is not applied to the three registries above, which carry self-declared plugin metadata — and those are what actually gate enforcement.
Why it matters
The failure is silent and points the wrong way for safety:
update_campaigngets plugin B'sBudgetDeclaration, so the gate reads a key that does not exist in A's arguments, finds no proposal, and A's## Guardrailscap is not applied to a call that really does move money. (Or it reads a differently-scaled field and compares micros against currency units.)readOnlyHint: plugin A's real mutation inherits plugin B'sreadOnlyHint=True, and then loses both its pattern-fallback budget/bid scan (_register_plugin_pattern_fallbacks) and itsblock_learning_resetsrefusal (mureo/policy/learning_reset.py::_is_mutation).Neither plugin has to be malicious. Two honest providers shipping
list_campaignsorupdate_campaignis enough, and nothing warns.Built-in tools are not affected:
_is_mutationshort-circuits onis_mutating_builtin_tooland_BUILTIN_PREFIXESbefore any hint lookup, and there is a test pinning that.Proposed fix
Key all three registries by
(distribution, tool_name)— the same breadcrumb_declares_from_bridged_tableand the audit trail already use (plugin_source/plugin_provider_name, cf. theplugin:<dist>:<provider>platform key from #537) — and resolve at lookup time from the tool's actual provider instance rather than from the bare name.Open questions for whoever picks this up:
budget_declaration_for(tool_name)/bid_declaration_for(tool_name)/declared_read_only_hint(tool_name)are called fromStrategyPolicyGate.evaluateand fromlearning_reset, which see only a tool name. Threading the owning distribution to those call sites is the bulk of the work; the dispatcher knows it (_PLUGIN_DISPATCH[name]), the pure policy layer does not._PLUGIN_SEMANTICSand_PLUGIN_DISPATCHcollide the same way and would need the same treatment for the fix to be complete — a bare-name_PLUGIN_SEMANTICSthat already lost a declaration cannot be repaired downstream._PLUGIN_TOOLSis built once at import, so a duplicate name is trivially detectable there, and alogger.warningnaming both distributions turns a silent overwrite into something an operator can see. Worth doing even if the full re-keying is deferred.mureo.policy.pattern_scan's_PATTERN_FALLBACK_TOOLSis a bare-name set too, but it holds no per-plugin data — a collision there only over- or under-registers a name for the heuristic scan. Lower stakes; decide whether it is in scope.Provenance
Pre-existing since #414 / #517; surfaced by the security review of #587, which added the third registry in the same shape as its two siblings (consistent with them rather than uniquely weak) and deliberately left the shared keying problem out of scope.