From f178137577a9f3b8eb5ca9eb168d421dea106629 Mon Sep 17 00:00:00 2001 From: hyoshi <4027404+hyoshi@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:29:56 +0900 Subject: [PATCH] fix: let a plugin's own readOnlyHint outrank a guess about its name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- mureo/mcp/plugin_semantics.py | 16 ++- mureo/mcp/server.py | 89 ++++++++++---- mureo/policy/declarations.py | 37 ++++++ mureo/policy/learning_reset.py | 27 +++++ mureo/policy/strategy_gate.py | 6 + tests/test_learning_reset_preflight.py | 84 +++++++++++++- tests/test_mcp_plugin_semantics.py | 40 +++++++ tests/test_strategy_gate_pattern_fallback.py | 116 +++++++++++++++++-- 8 files changed, 383 insertions(+), 32 deletions(-) diff --git a/mureo/mcp/plugin_semantics.py b/mureo/mcp/plugin_semantics.py index b45b464d..e80af63e 100644 --- a/mureo/mcp/plugin_semantics.py +++ b/mureo/mcp/plugin_semantics.py @@ -112,6 +112,11 @@ class ToolSemantics: """Safety classification derived from a plugin tool's MCP metadata.""" mutating: bool + #: The tool's OWN ``annotations.readOnlyHint``, ``None`` when it declared + #: none. ``mutating`` is the DERIVED answer; this is the raw declaration, + #: so a consumer can tell "declared a mutation" from "guessed a mutation" + #: — ``None`` means undeclared, i.e. the name was the only signal. + read_only_hint: bool | None = None reversal: dict[str, Any] | None = None throttle: ThrottleConfig | None = None observation_days: int | None = None @@ -253,6 +258,12 @@ def _meta_mureo(tool: Tool) -> dict[str, Any]: return {} +def _declared_read_only_hint(tool: Tool) -> bool | None: + """The tool's own ``readOnlyHint``, or ``None`` when it declares none.""" + hint = getattr(getattr(tool, "annotations", None), "readOnlyHint", None) + return None if hint is None else hint is True + + def _is_read(tool: Tool) -> bool: """Is ``tool`` a read? Declaration first, name shape only as a fallback. @@ -263,9 +274,9 @@ def _is_read(tool: Tool) -> bool: the guardrail pattern-fallback registration already share, so the three surfaces cannot answer "is this a read?" differently (#517). """ - hint = getattr(getattr(tool, "annotations", None), "readOnlyHint", None) + hint = _declared_read_only_hint(tool) if hint is not None: - return hint is True + return hint return is_read_only_tool_name(getattr(tool, "name", "") or "") @@ -301,6 +312,7 @@ def derive_semantics(tool: Tool) -> ToolSemantics: return ToolSemantics( mutating=mutating, + read_only_hint=_declared_read_only_hint(tool), reversal=reversal, throttle=throttle, observation_days=observation_days, diff --git a/mureo/mcp/server.py b/mureo/mcp/server.py index 024c9e81..83ba8760 100644 --- a/mureo/mcp/server.py +++ b/mureo/mcp/server.py @@ -405,6 +405,38 @@ def _register_plugin_bid_declarations( ) +def _register_plugin_read_only_hints( + semantics: dict[str, ToolSemantics], +) -> None: + """Publish plugin ``readOnlyHint`` declarations to the pure policy layer. + + The learning-period pre-flight (:mod:`mureo.policy.learning_reset`) has to + answer "is this call a mutation?" for a plugin/bridged tool too — a plugin + or bridge can register its own learning rules under a ``tool_prefix``, so + those names really do reach it. Without the declaration it had only the + NAME to go on and was wrong in both directions: a read-shaped name that + declares ``readOnlyHint=False`` got no learning-period notice and no + ``block_learning_resets`` refusal, and a mutation-shaped name that + declares ``readOnlyHint=True`` risked a spurious one. Only tools that + actually declared a hint are registered — absence must stay "undeclared", + never "read". Best-effort: a registry failure must not take the server + down. + """ + from mureo.policy.declarations import register_read_only_hint + + for name, sem in semantics.items(): + if sem.read_only_hint is None: + continue + try: + register_read_only_hint(name, sem.read_only_hint) + except Exception: # noqa: BLE001 — never break startup on a hint + logger.warning( + "could not register the readOnlyHint for plugin tool '%s'", + name, + exc_info=True, + ) + + def _register_plugin_pattern_fallbacks( semantics: dict[str, ToolSemantics], ) -> None: @@ -419,29 +451,37 @@ def _register_plugin_pattern_fallbacks( (:mod:`mureo.policy.pattern_scan`) for the channels no declaration covers. Reads are deliberately excluded — they move no money, so scanning their - arguments could only produce false denials — and "read" is decided by TWO - signals, because on this surface neither is sufficient alone: - - - ``annotations.readOnlyHint``, when the tool declares it; and + arguments could only produce false denials — and "read" is decided by + DECLARATION first, NAME second: the same precedence + :func:`~mureo.mcp.plugin_semantics.derive_semantics` itself uses, so the + two surfaces cannot answer "is this a read?" differently. + + - ``annotations.readOnlyHint``, when the tool declares it. An explicit + ``False`` is a plugin author saying "this moves money"; overturning it + with a name guess silently dropped that tool's budget/bid cap, which is + the one failure this ordering exists to prevent. A declaration is + evidence, a name shape is a guess. - the tool NAME, via the shared read vocabulary in :mod:`mureo.core.tool_names` (the same list and matcher the rollback - planner uses, single-sourced so the two cannot drift). - - The name check still matters after #517, which taught ``derive_semantics`` - the same vocabulary for tools that declare NO ``readOnlyHint``: a plugin is - free to declare ``readOnlyHint=False`` on a read-shaped name, and that - declaration is believed for auditing (over-recording is harmless) but must - not turn a listing call carrying a numeric budget-shaped FILTER argument - into an outright refusal. The error costs are asymmetric: platform - mutations are consistently verb-named (``create_`` / ``update_`` / - ``delete_`` / ``set_``), so a read-shaped name is almost never a mutation, - whereas a mutation-shaped name that is really a read costs only a wasted - scan of arguments that carry no budget. - - Annotation coverage on a real bridged surface is now known rather than - assumed (#517): of 85 tools on one Amazon manifest, 83 declare - ``readOnlyHint`` and 2 omit it — good enough to lead with the declaration, - not good enough to drop the name fallback. + planner uses, single-sourced so the two cannot drift) — consulted ONLY + for a tool that declared nothing, which is the case the fallback was + introduced for: a manifest snapshot carries no annotations at all, so a + bridged read whose arguments carry a numeric budget-shaped FILTER would + otherwise be refused outright. The error costs are asymmetric there: + platform mutations are consistently verb-named (``create_`` / + ``update_`` / ``delete_`` / ``set_``), so a read-shaped name is almost + never a mutation, whereas a mutation-shaped name that is really a read + costs only a wasted scan of arguments that carry no budget. + + For semantics produced by ``derive_semantics`` the undeclared read-shaped + case already arrives as ``mutating=False``, so the name check below is a + belt-and-braces guard for any semantics map NOT built by that function + rather than a load-bearing step on the normal path. + + Annotation coverage on a real bridged surface is known rather than assumed + (#517): of 85 tools on one Amazon manifest, 83 declare ``readOnlyHint`` + and 2 omit it — good enough to lead with the declaration, not good enough + to drop the name fallback. Best-effort: a registry failure must not take the server down. """ @@ -449,7 +489,11 @@ def _register_plugin_pattern_fallbacks( from mureo.policy.pattern_scan import register_pattern_fallback_tool for name, sem in semantics.items(): - if not sem.mutating or is_read_only_tool_name(name): + 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 try: register_pattern_fallback_tool(name) @@ -549,6 +593,7 @@ def _register_bridged_money_declarations( _register_plugin_budget_declarations(_PLUGIN_SEMANTICS) _register_plugin_bid_declarations(_PLUGIN_SEMANTICS) _register_bridged_money_declarations(_PLUGIN_SEMANTICS, _PLUGIN_DISPATCH) +_register_plugin_read_only_hints(_PLUGIN_SEMANTICS) _register_plugin_pattern_fallbacks(_PLUGIN_SEMANTICS) diff --git a/mureo/policy/declarations.py b/mureo/policy/declarations.py index 5e713e22..c2d06a96 100644 --- a/mureo/policy/declarations.py +++ b/mureo/policy/declarations.py @@ -19,6 +19,10 @@ - Their process-wide registries and register / lookup / reset helpers, populated by ``mureo.mcp.server`` from plugin tool metadata at import so the pure decision layer stays I/O-free and needs no plugin imports. +- The same for one tool's ``annotations.readOnlyHint`` + (:func:`register_read_only_hint` / :func:`declared_read_only_hint` / + :func:`reset_read_only_hints`) — so a pure decision that has to ask "is this + a read?" can prefer the tool's own DECLARATION over the shape of its name. - :func:`_declared_amount` and its numeric helpers (:func:`_saturate`, the :data:`_UNREADABLE` sentinel) — the single reader that turns one declared argument key or path into currency units, distinguishing "absent" from @@ -330,6 +334,39 @@ def reset_bid_declarations() -> None: _BID_DECLARATIONS.clear() +# Tool name → the tool's OWN ``annotations.readOnlyHint``. Populated by the +# MCP server from plugin tool metadata at import, exactly like the two money +# registries above, so the pure decision layer stays I/O-free. It holds only +# what a tool DECLARED: absence means "undeclared", never "read". +_READ_ONLY_HINTS: dict[str, bool] = {} + + +def register_read_only_hint(tool_name: str, read_only: bool) -> None: + """Bind ``tool_name``'s declared ``readOnlyHint`` (last registration wins). + + Only ever called for a tool that actually declared one. A pure decision + layer otherwise has nothing but the tool's NAME to go on, and a name shape + is a guess where a declaration is evidence — registering the declaration + lets the guess be demoted to a fallback. + """ + _READ_ONLY_HINTS[tool_name] = read_only + + +def declared_read_only_hint(tool_name: str) -> bool | None: + """``tool_name``'s DECLARED ``readOnlyHint``, or ``None`` when undeclared. + + ``None`` is "the tool said nothing" and must not be read as "read": the + caller falls back to the name vocabulary for that case, which is the only + signal left. + """ + return _READ_ONLY_HINTS.get(tool_name) + + +def reset_read_only_hints() -> None: + """Drop every hint registration (tests; a re-discovery re-registers).""" + _READ_ONLY_HINTS.clear() + + class _Unreadable: """Sentinel: a declared budget key is PRESENT but not a usable number. diff --git a/mureo/policy/learning_reset.py b/mureo/policy/learning_reset.py index a7636043..0bd8f37c 100644 --- a/mureo/policy/learning_reset.py +++ b/mureo/policy/learning_reset.py @@ -49,6 +49,7 @@ from mureo.core.strategy_reminder import is_mutating_builtin_tool from mureo.core.tool_names import is_read_only_tool_name +from mureo.policy.declarations import declared_read_only_hint from mureo.policy.learning_rules import ( Evidence, LearningState, @@ -162,11 +163,37 @@ def _is_mutation(tool_name: str) -> bool: cannot restart a learning period on any platform. Getting this right is what keeps the check quiet — a check that fires on ``campaigns_list`` is a check that gets ignored. + + For a tool mureo does not own, the NAME is a guess and the tool's own + ``annotations.readOnlyHint`` is a declaration, so the declaration wins in + both directions: a read-shaped name that declared ``readOnlyHint=False`` + is a mutation (and gets its learning-period verdict), a mutation-shaped + name that declared ``readOnlyHint=True`` is a read (and is not refused + spuriously). The name is consulted only when nothing was declared. This + mirrors :func:`mureo.mcp.plugin_semantics._is_read` — same precedence, so + the two surfaces cannot drift apart on "is this a read?". The declarations + reach this pure layer through the registry in + :mod:`mureo.policy.declarations`, populated by ``mureo.mcp.server``. + + What believing a declaration costs, stated rather than glossed: a plugin + that declares ``readOnlyHint=True`` on a real mutation is taken at its + word and escapes the ``block_learning_resets`` refusal, where the older + name-only rule would have caught a ``delete_``-shaped name. That is + accepted, for two reasons. Believing a declaration in one direction only + is the same name-beats-declaration inversion this ordering exists to + remove, and it would hard-refuse honest plugin reads whose names merely + look like mutations. And these guardrails defend the operator against an + agent's mistakes, not against an installed plugin that lies about itself: + such a plugin already escapes the money pattern-fallback scan the same way + (#517), and runs arbitrary code besides. """ if is_mutating_builtin_tool(tool_name): return True if tool_name.startswith(_BUILTIN_PREFIXES): return False + hint = declared_read_only_hint(tool_name) + if hint is not None: + return not hint return not is_read_only_tool_name(tool_name) diff --git a/mureo/policy/strategy_gate.py b/mureo/policy/strategy_gate.py index a1bd0135..474cd755 100644 --- a/mureo/policy/strategy_gate.py +++ b/mureo/policy/strategy_gate.py @@ -113,10 +113,13 @@ BudgetDeclaration, bid_declaration_for, budget_declaration_for, + declared_read_only_hint, register_bid_declaration, register_budget_declaration, + register_read_only_hint, reset_bid_declarations, reset_budget_declarations, + reset_read_only_hints, ) # The learning-period pre-flight (#548) — "is this change reset-triggering, @@ -163,6 +166,9 @@ "register_bid_declaration", "reset_budget_declarations", "reset_bid_declarations", + "declared_read_only_hint", + "register_read_only_hint", + "reset_read_only_hints", "_BUDGET_DECLARATIONS", "_BID_DECLARATIONS", "_UNREADABLE", diff --git a/tests/test_learning_reset_preflight.py b/tests/test_learning_reset_preflight.py index 5c75ba40..46c84c4a 100644 --- a/tests/test_learning_reset_preflight.py +++ b/tests/test_learning_reset_preflight.py @@ -16,7 +16,7 @@ import json from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, patch import pytest @@ -44,6 +44,9 @@ parse_guardrails, ) +if TYPE_CHECKING: + from collections.abc import Iterator + pytestmark = pytest.mark.unit @@ -535,6 +538,85 @@ async def test_would_block_agrees_with_the_gate(self, workspace: Path) -> None: # --------------------------------------------------------------------------- +class TestDeclaredReadOnlyHintBeatsTheName: + """For a plugin tool the NAME is a guess; ``readOnlyHint`` is a declaration. + + A plugin or bridge can register its own learning rules under a + ``tool_prefix``, so plugin tool names really do reach this classifier. With + only the name to go on it was wrong in both directions: a read-shaped name + that declares ``readOnlyHint=False`` got no learning-period notice and no + ``block_learning_resets`` refusal, and a mutation-shaped name that declares + ``readOnlyHint=True`` risked a spurious refusal. The declaration decides; + the name is the fallback for a tool that declared nothing. + """ + + @pytest.fixture(autouse=True) + def _clean_hints(self) -> Iterator[None]: + """Isolate the process-global hint registry WITHOUT destroying it. + + ``mureo.mcp.server`` populates it once at import from real plugin + discovery; a destructive clear would drop those registrations for the + rest of the pytest session. + """ + from mureo.policy.declarations import _READ_ONLY_HINTS, reset_read_only_hints + + saved = dict(_READ_ONLY_HINTS) + reset_read_only_hints() + yield + reset_read_only_hints() + _READ_ONLY_HINTS.update(saved) + + def test_a_declared_mutation_on_a_read_shaped_name_is_a_mutation(self) -> None: + from mureo.policy.declarations import register_read_only_hint + + tool = "acme-list_and_delete_campaigns" + register_read_only_hint(tool, False) + assessment = classify_change(tool, {}) + assert assessment.risk is not ResetRisk.NO_RESET + assert "Read-only" not in assessment.detail + + def test_a_declared_read_on_a_mutation_shaped_name_is_a_read(self) -> None: + from mureo.policy.declarations import register_read_only_hint + + tool = "acme-update_report_layout" + register_read_only_hint(tool, True) + assessment = classify_change(tool, {}) + assert assessment.risk is ResetRisk.NO_RESET + assert "Read-only" in assessment.detail + + @pytest.mark.parametrize( + ("tool", "expected"), + [ + ("acme-list_campaigns", ResetRisk.NO_RESET), + ("acme-update_campaign", ResetRisk.UNKNOWN), + ], + ) + def test_with_nothing_declared_the_name_still_decides( + self, tool: str, expected: ResetRisk + ) -> None: + """The pre-existing behaviour for an undeclared tool, both ways.""" + assert classify_change(tool, {}).risk is expected + + def test_a_hint_cannot_reclassify_a_builtin_tool(self) -> None: + """mureo owns its own tool names, so the pinned built-in classifier + stays authoritative — a stray registration must not turn a real + mutation into a read (or the reverse).""" + from mureo.policy.declarations import register_read_only_hint + + register_read_only_hint("google_ads_campaigns_update", True) + register_read_only_hint("google_ads_campaigns_list", False) + assert ( + classify_change( + "google_ads_campaigns_update", + {"campaign_id": "C1", "bidding_strategy": "TARGET_CPA"}, + ).risk + is ResetRisk.RESETS + ) + assert classify_change("google_ads_campaigns_list", {}).risk is ( + ResetRisk.NO_RESET + ) + + class TestPluginRegistration: def teardown_method(self) -> None: reset_platform_learning_rules() diff --git a/tests/test_mcp_plugin_semantics.py b/tests/test_mcp_plugin_semantics.py index e4a13df7..07c7d5bd 100644 --- a/tests/test_mcp_plugin_semantics.py +++ b/tests/test_mcp_plugin_semantics.py @@ -156,6 +156,46 @@ def test_an_explicit_hint_always_wins_over_the_name(self) -> None: ) assert read.mutating is False + def test_the_raw_declaration_survives_derivation(self) -> None: + """``mutating`` is the derived answer and cannot be un-derived, so a + consumer that must not let a name guess beat a DECLARATION (the + guardrail pattern-fallback registration, the learning-reset + classifier) needs the raw hint carried alongside it.""" + declared_read = derive_semantics( + _tool( + name="campaign_management-create_campaign", + annotations=ToolAnnotations(readOnlyHint=True), + ) + ) + assert declared_read.read_only_hint is True + assert declared_read.mutating is False + + declared_mutation = derive_semantics( + _tool( + name="billing-list_invoice_summaries", + annotations=ToolAnnotations(readOnlyHint=False), + ) + ) + assert declared_mutation.read_only_hint is False + assert declared_mutation.mutating is True + + def test_an_undeclared_hint_is_none_not_a_guess(self) -> None: + """``None`` is what tells the consumer the name was the only signal. + Annotations that OMIT the hint count as undeclared too — that is the + real shape the module docstring calls out (``destructiveHint`` only).""" + no_annotations = derive_semantics(_tool(name="billing-list_invoices")) + assert no_annotations.read_only_hint is None + assert no_annotations.mutating is False + + other_annotations = derive_semantics( + _tool( + name="campaign_management-create_campaign", + annotations=ToolAnnotations(destructiveHint=True), + ) + ) + assert other_annotations.read_only_hint is None + assert other_annotations.mutating is True + def test_the_fallback_uses_the_shared_read_vocabulary(self) -> None: """Same answer as the rollback planner and the guardrail pattern-fallback registration — three surfaces, one list.""" diff --git a/tests/test_strategy_gate_pattern_fallback.py b/tests/test_strategy_gate_pattern_fallback.py index 59299432..b3217a02 100644 --- a/tests/test_strategy_gate_pattern_fallback.py +++ b/tests/test_strategy_gate_pattern_fallback.py @@ -1323,8 +1323,9 @@ def _semantics_for(*names: str) -> dict[str, Any]: """Semantics for tools that declare NOTHING — no annotations, no meta. This is the shape a manifest snapshot produces, and it is exactly the case - that matters: ``derive_semantics`` defaults an undeclared tool to - *mutating*, so the name is the only signal available. + that matters: with no ``readOnlyHint`` to go on, ``derive_semantics`` has + nothing but the NAME to classify the tool by (a name it does not read as a + read falls through to the conservative *mutating* default). """ from mcp.types import Tool @@ -1341,15 +1342,33 @@ def _semantics_for(*names: str) -> dict[str, Any]: return {t.name: derive_semantics(t) for t in tools} +def _semantics_with_hint(read_only: bool, *names: str) -> dict[str, Any]: + """Semantics for tools that DECLARE ``readOnlyHint`` explicitly.""" + from mcp.types import Tool, ToolAnnotations + + from mureo.mcp.plugin_semantics import derive_semantics + + tools = [ + Tool( + name=name, + description="x", + inputSchema={"type": "object", "properties": {}}, + annotations=ToolAnnotations(readOnlyHint=read_only), + ) + for name in names + ] + return {t.name: derive_semantics(t) for t in tools} + + @pytest.mark.unit class TestReadNameExemption: """A read tool without ``readOnlyHint`` must not be denial-gated. - ``derive_semantics`` treats an undeclared tool as mutating (the right - default for auditing), and a manifest snapshot declares nothing — so every - read from a bridged surface arrived here as "mutating". Handing those to a - heuristic budget/bid scan can only produce FALSE DENIALS: a listing call - with a numeric budget-shaped *filter* argument would be refused. + A manifest snapshot declares nothing, so a bridged read arrives here with + the tool NAME as its only signal — and before #517 that meant it arrived as + "mutating". Handing those to a heuristic budget/bid scan can only produce + FALSE DENIALS: a listing call with a numeric budget-shaped *filter* + argument would be refused. The exemption is keyed on read-shaped NAMES rather than on a mutation allow-list because the error costs are asymmetric: platform mutations are @@ -1430,3 +1449,86 @@ def test_a_read_with_a_budget_shaped_filter_is_not_denied( finally: reset_runtime_context() sg._cache.clear() + + +@pytest.mark.unit +class TestDeclarationBeatsNameGuess: + """A name guess must never overturn the tool's own ``readOnlyHint``. + + The registration used to exempt every read-shaped NAME, declared or not. + So a plugin that correctly declared ``readOnlyHint=False`` on a tool like + ``list_and_delete_stale_campaigns`` silently lost its ``## Guardrails`` + budget/bid cap to a guess about its name — the one case where the author + had told mureo outright that the call moves money. + + The precedence has three legs, and all three are pinned here: a + declaration is believed in BOTH directions, and the name decides only for + a tool that declared nothing. + """ + + @pytest.mark.parametrize( + "name", + [ + "list_and_delete_stale_campaigns", + "campaign_management-list_and_pause_campaigns", + ], + ) + def test_a_declared_mutation_on_a_read_shaped_name_is_registered( + self, name: str + ) -> None: + from mureo.mcp.server import _register_plugin_pattern_fallbacks + + _register_plugin_pattern_fallbacks(_semantics_with_hint(False, name)) + assert has_pattern_fallback(name) is True + + def test_a_declared_read_on_a_read_shaped_name_is_not_registered(self) -> None: + from mureo.mcp.server import _register_plugin_pattern_fallbacks + + name = "campaign_management-list_campaigns" + _register_plugin_pattern_fallbacks(_semantics_with_hint(True, name)) + assert has_pattern_fallback(name) is False + + def test_a_declared_read_on_a_mutation_shaped_name_is_not_registered(self) -> None: + """The declaration is believed in that direction too: scanning a tool + its author called a read would only cost false denials.""" + from mureo.mcp.server import _register_plugin_pattern_fallbacks + + name = "update_campaign" + _register_plugin_pattern_fallbacks(_semantics_with_hint(True, name)) + assert has_pattern_fallback(name) is False + + def test_an_undeclared_read_shaped_name_is_still_not_registered(self) -> None: + """The third leg — the ``TestReadNameExemption`` guarantee, restated + here so the precedence is readable in one place.""" + from mureo.mcp.server import _register_plugin_pattern_fallbacks + + name = "campaign_management-list_campaigns" + _register_plugin_pattern_fallbacks(_semantics_for(name)) + assert has_pattern_fallback(name) is False + + def test_a_declared_mutation_with_a_read_shaped_name_is_denied( + self, tmp_path: Any, monkeypatch: Any + ) -> None: + """The defect this closes, end to end through the real gate: an + over-cap budget on a tool whose author declared it a mutation.""" + import mureo.policy.strategy_gate as sg + from mureo.core.runtime_context import reset_runtime_context + from mureo.mcp.server import _register_plugin_pattern_fallbacks + from mureo.policy.strategy_gate import StrategyPolicyGate + + (tmp_path / "STRATEGY.md").write_text( + "## Guardrails\n- max_daily_budget_per_campaign: 10000\n", + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + reset_runtime_context() + sg._cache.clear() + try: + name = "list_and_delete_stale_campaigns" + _register_plugin_pattern_fallbacks(_semantics_with_hint(False, name)) + decision = StrategyPolicyGate().evaluate(name, {"dailyBudget": 25_000}) + assert decision.allowed is False + assert "max_daily_budget_per_campaign" in (decision.reason or "") + finally: + reset_runtime_context() + sg._cache.clear()