fix: let a plugin's own readOnlyHint outrank a guess about its name - #587
Merged
Conversation
`derive_semantics` believes an explicit `annotations.readOnlyHint` verbatim
in both directions, so a plugin declaring `readOnlyHint=False` is saying "this
call moves money" and arrives as `mutating=True`. The guardrail pattern-fallback
registration then threw that declaration away whenever the tool NAME happened to
look like a read:
if not sem.mutating or is_read_only_tool_name(name):
continue
So `list_and_delete_stale_campaigns`, declared a mutation by its own author, was
never registered for the `## Guardrails` budget/bid scan and its caps went
silently unenforced. A name shape is a guess; a declaration is evidence, and the
guess must not win.
The name check now applies only to a tool that declared no hint at all, which is
the case it was introduced for: a manifest snapshot carries no annotations, and a
bridged read whose arguments carry a numeric budget-shaped FILTER would otherwise
be refused outright. That exemption is unchanged.
`mureo/policy/learning_reset.py::_is_mutation` had the same inversion, and it was
wrong in both directions: a read-shaped name declaring `readOnlyHint=False` got no
learning-period verdict, and a mutation-shaped name declaring `readOnlyHint=True`
risked a spurious `block_learning_resets` refusal. It is reachable, because a
plugin or bridge can register its own learning rules under a `tool_prefix`. That
layer is pure and cannot import the MCP server, so the declarations reach it
through a registry in `mureo.policy.declarations`, populated at import exactly
like the sibling budget/bid registries. What believing the declaration costs is
now stated in `_is_mutation`'s docstring rather than left implicit.
- `ToolSemantics` gains `read_only_hint: bool | None` — the raw declaration
carried alongside the derived `mutating`, `None` meaning "undeclared, so the
name was the only signal".
- `register_read_only_hint` / `declared_read_only_hint` / `reset_read_only_hints`
in `mureo.policy.declarations`, re-exported from `strategy_gate` like its
siblings; populated by `_register_plugin_read_only_hints`.
- Built-in tools stay immune: `_is_mutation`'s pinned-classifier and
`_BUILTIN_PREFIXES` short-circuits both run before the hint lookup.
- Two stale test docstrings corrected: they claimed `derive_semantics` defaults an
undeclared tool to mutating, which has not been true for read-shaped names
since #517.
#585 landed while this branch was open. The only conflict was the `_register_plugin_pattern_fallbacks` docstring, which both changes rewrite: #585 added the paragraph explaining why this surface uses the STRICT matcher and the rollback planner a looser sibling, and this branch rewrote the surrounding text to describe declaration-over-name precedence. Both are kept — the two-matcher split is deliberate and is not collapsed here. Two docstrings that #585 left describing the pre-split world are corrected while they are in front of us, since they now claim the exact invariant this branch reinforces: - `plugin_semantics._is_read` said the name vocabulary is shared by three surfaces "so the three surfaces cannot answer 'is this a read?' differently". Since #585 the rollback planner reads that list through `reads_as_a_report_only_action` instead, so the sentence now names two surfaces and points at the sibling for why the third is separate. - `test_the_exemption_uses_the_shared_read_vocabulary` said "one list, two safety surfaces"; it is one list read through two matchers, and what the test actually pins is that both still agree on a verb-FIRST name. No logic changed in this merge. Full suite: 12 failed, 8784 passed — the 12 are the known environment baseline. mypy strict / ruff / black clean.
…-beats-name-guess
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The defect
derive_semanticsbelieves an explicitannotations.readOnlyHintverbatim in both directions —readOnlyHint=Falseis a plugin author saying "this call moves money", and it arrives asToolSemantics.mutating=True._register_plugin_pattern_fallbacksthen discarded that declaration whenever the tool NAME happened to look like a read:Reproduced:
So a plugin that correctly declared its mutation silently lost its
## Guardrailsbudget/bid cap enforcement because of a guess about its name. A name shape is a guess; a declaration is evidence.Found by a post-merge review of #569 and re-confirmed reviewing #585. Pre-existing, and deliberately outside #585's scope.
The fix
The name check now applies only to a tool that declared no hint at all, mirroring the precedence
derive_semanticsitself uses:The exemption for undeclared tools is untouched — it exists because a manifest snapshot carries no annotations, and a bridged read whose arguments carry a numeric budget-shaped FILTER would otherwise be refused outright (
TestReadNameExemption).ToolSemanticsgainsread_only_hint: bool | Noneto carry the raw declaration beside the derivedmutating;Nonemeans "undeclared, so the name was the only signal". That information did not survivederive_semanticsbefore.The same inversion in
learning_resetmureo/policy/learning_reset.py::_is_mutationconsulted only the name, and was wrong in both directions for a plugin tool:readOnlyHint=False→ classified a read → no learning-period notice, noblock_learning_resetsrefusal;readOnlyHint=True→ classified a mutation → a possible spurious refusal.It is reachable:
register_platform_learning_ruleslets a plugin or bridge claim atool_prefix, so plugin tool names really do reach this classifier.That layer is pure and must not import the MCP server, so the declarations reach it through a registry in
mureo.policy.declarations— same shape, same docstring discipline and the same "populated bymureo.mcp.serverat import" contract as the sibling budget/bid registries, re-exported fromstrategy_gatelike they are.Built-in tools stay immune:
is_mutating_builtin_tooland the_BUILTIN_PREFIXESshort-circuit both run before the hint lookup, and a test pins that a stray registration cannot reclassify one.What believing a declaration costs is now stated in
_is_mutation's docstring rather than left implicit: a plugin declaringreadOnlyHint=Trueon a real mutation is taken at its word and escapes the refusal. Accepted — refusing to believe a declaration in one direction only is the same name-beats-declaration inversion this ordering removes, and it would hard-refuse honest plugin reads whose names merely look like mutations. These guardrails defend the operator against an agent's mistakes, not against an installed plugin that lies about itself.mureo/core/tool_names.pyis untouchedThe two-matcher split from #585 is deliberate and stays:
is_read_only_tool_nameis the strict matcher for the plugin-facing safety surfaces,reads_as_a_report_only_actionthe looser one for the rollback planner only. This PR changes when the strict matcher is consulted, never what it matches.Tests
19 new tests. Applying only the new test files onto pre-change
HEADgives 5 failed / 5 errored — including the end-to-end case through the realStrategyPolicyGate, wherelist_and_delete_stale_campaignswithreadOnlyHint=Falseand{"dailyBudget": 25_000}against amax_daily_budget_per_campaign: 10000cap returnedallowed=Trueon the old code.test_strategy_gate_pattern_fallback.py::TestDeclarationBeatsNameGuess— all three legs: a declaration is believed in both directions, the name decides only for a tool that declared nothing; plus the end-to-end denial.test_mcp_plugin_semantics.py—read_only_hintisTrue/Falsewhen declared andNonewhen undeclared, including annotations that exist but omit the hint (destructiveHintonly).test_learning_reset_preflight.py::TestDeclaredReadOnlyHintBeatsTheName— both directions, the undeclared name fallback, and built-in immunity. Its fixture saves and restores the process-global registry rather than clearing it destructively.Two stale docstrings in
TestReadNameExemption/_semantics_forare corrected: they claimedderive_semanticsdefaults an undeclared tool to mutating, which has not been true for read-shaped names since #517. No assertion was weakened.Checks
python3 -m pytest→12 failed, 8741 passed, 8 skipped; the 12 are exactly the known environment baseline (9 ×tests/analytics/builtin/test_live_clients.py,test_list_tools_returns_all_tools,test_no_plugins_is_additive_no_op,test_default_discover_is_registry_and_yields_no_op_when_empty).mypy mureo/ --ignore-missing-imports→ clean (337 files) ·ruff check→ clean ·black --check→ clean.Known, not addressed here
_READ_ONLY_HINTSis keyed by bare tool name with no distribution identity, so two plugins shipping the same generic tool name overwrite each other's declaration. That is the pre-existing shape of_BUDGET_DECLARATIONSand_BID_DECLARATIONStoo — the new registry is consistent with its siblings rather than uniquely weak. Worth a follow-up that keys all three by(distribution, tool_name), the way_declares_from_bridged_tablealready checks the owning distribution.