Skip to content

fix: let a plugin's own readOnlyHint outrank a guess about its name - #587

Merged
hyoshi merged 3 commits into
mainfrom
fix/declared-mutation-beats-name-guess
Aug 12, 2026
Merged

fix: let a plugin's own readOnlyHint outrank a guess about its name#587
hyoshi merged 3 commits into
mainfrom
fix/declared-mutation-beats-name-guess

Conversation

@hyoshi

@hyoshi hyoshi commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

The defect

derive_semantics believes an explicit annotations.readOnlyHint verbatim in both directions — readOnlyHint=False is a plugin author saying "this call moves money", and it arrives as ToolSemantics.mutating=True. _register_plugin_pattern_fallbacks then discarded that declaration whenever the tool NAME happened to look like a read:

if not sem.mutating or is_read_only_tool_name(name):
    continue

Reproduced:

name = "list_and_delete_stale_campaigns"
sem = {name: ToolSemantics(mutating=True)}   # explicit readOnlyHint=False
_register_plugin_pattern_fallbacks(sem)
has_pattern_fallback(name)   # -> False: the guardrail is NOT registered

So a plugin that correctly declared its mutation silently lost its ## Guardrails budget/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_semantics itself uses:

if not sem.mutating:
    continue
# The name is a fallback, not an override: it decides only for a tool
# that declared no readOnlyHint at all.
if sem.read_only_hint is None and is_read_only_tool_name(name):
    continue

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).

ToolSemantics gains read_only_hint: bool | None to carry the raw declaration beside the derived mutating; None means "undeclared, so the name was the only signal". That information did not survive derive_semantics before.

The same inversion in learning_reset

mureo/policy/learning_reset.py::_is_mutation consulted only the name, and was wrong in both directions for a plugin tool:

  • read-shaped name + readOnlyHint=False → classified a read → no learning-period notice, no block_learning_resets refusal;
  • mutation-shaped name + readOnlyHint=True → classified a mutation → a possible spurious refusal.

It is reachable: register_platform_learning_rules lets a plugin or bridge claim a tool_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 by mureo.mcp.server at import" contract as the sibling budget/bid registries, re-exported from strategy_gate like they are.

Built-in tools stay immune: is_mutating_builtin_tool and the _BUILTIN_PREFIXES short-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 declaring readOnlyHint=True on 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.py is untouched

The two-matcher split from #585 is deliberate and stays: is_read_only_tool_name is the strict matcher for the plugin-facing safety surfaces, reads_as_a_report_only_action the 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 HEAD gives 5 failed / 5 errored — including the end-to-end case through the real StrategyPolicyGate, where list_and_delete_stale_campaigns with readOnlyHint=False and {"dailyBudget": 25_000} against a max_daily_budget_per_campaign: 10000 cap returned allowed=True on 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.pyread_only_hint is True/False when declared and None when undeclared, including annotations that exist but omit the hint (destructiveHint only).
  • 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_for are corrected: they claimed derive_semantics defaults an undeclared tool to mutating, which has not been true for read-shaped names since #517. No assertion was weakened.

Checks

  • python3 -m pytest12 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_HINTS is 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_DECLARATIONS and _BID_DECLARATIONS too — 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_table already checks the owning distribution.

hyoshi added 3 commits August 12, 2026 13:29
`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.
@hyoshi
hyoshi merged commit 3a1c03b into main Aug 12, 2026
13 checks passed
@hyoshi
hyoshi deleted the fix/declared-mutation-beats-name-guess branch August 12, 2026 06:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant