Skip to content

Commit f586be2

Browse files
swissmoclaude
andcommitted
fix(bulk): address third round of selector review feedback
A comprehensive review flagged two blocking issues plus a long list of correctness, error-routing, and coverage gaps in the structural-selector feature. Fixed: Blocking: - Root admission for aggregate expansion was not domain-constrained: a scene assigned to a selected area was admitted as a root and expanded, pulling entities from anywhere in the house into the dispatch (HA's scene.entity_id attribute lists controlled targets, not structural members). Scenes are now excluded from aggregate-root admission on architectural grounds (not an integration-identity heuristic). - A dynamic selector call's approval could be consumed by a DIFFERENT invocation than the one that created it -- via a race (a concurrent call's find() observing "approved" before the original waiter resumes) or a later identical call reusing a stale approval within approval_ttl_minutes, dispatching against re-resolved topology. Dynamic entries are now bound to their creating invocation only. Also fixed: - A wildcard rule path with a literal tail (args.*.domain) was over-classified as needing resolved-operations data by the previous fix, regressing legitimate conditional selector rules back into an unconditional gate. Narrowed to paths that can actually reach the operations list. - Stringified JSON arguments (Claude Desktop stdio sends these) bypassed policy evaluation entirely: PolicyMiddleware reads raw context.message.arguments before the tool's own Pydantic coercion runs, so a rule targeting a nested field silently never matched. Normalized once, before evaluation and hashing. - _load_topology used a bare asyncio.gather; the first exception left the other four awaitables orphaned. Now mirrors tools_areas.py's return_exceptions=True + explicit re-raise pattern. - Registry/visibility infrastructure failures were raised as the same exception class as caller input errors, reaching the agent as VALIDATION_FAILED and inviting a pointless selector rewrite against an HA outage. Split into BulkSelectorInfrastructureError, routed through create_connection_error. - An unknown selector.domain (e.g. "lights") passed validation and resolved to nothing, blaming exclusions/areas that were correct. Domain existence is now checked against loaded states, and the empty-result message distinguishes "no entities of domain X" from "all matches were excluded or hidden". - VALIDATION_FAILED had no DEFAULT_SUGGESTIONS entry, so ~14 selector failure modes returned no actionable guidance. - _parse_bulk_operations lost its security rationale comments (include_input=False; why malformed rows are preserved) during an earlier extraction. Plus: NamedTuple returns instead of bare tuples, BulkSelectorResolution's operations as a computed property (was a separately-tracked list that could desync from resolved_entity_ids), _SELECTOR_KEYS derived from the TypedDict instead of a parallel literal, a logger for the ~14 selector failure modes, dispatch failures now attach the resolution to their error context, dangling-member/cycle errors name their aggregate and parameter, and the "exactly one of operations/selector" and "selector-only parameter" validation messages now name the actual offender instead of a collapsed generic message. Test coverage added: scene exclusion, aggregate exclusion (only leaf exclusion was tested before), unknown domain, domain-not-in-area vs all-excluded-or-hidden, whitespace-padded IDs, orphaned-task-free topology fetch failure, visibility-infrastructure routing, the wildcard over-classification, stringified-argument policy bypass (pure-function and end-to-end), the concurrent-approval race, and a real non-dry-run E2E dispatch that verifies the excluded entity's live state never moves (the only prior E2E test asserted the dry-run preview, never a real dispatch or real post-dispatch state). Deliberately deferred (documented in PR comment, not silently dropped): a handful of narrower test-coverage gaps (exact MAX_SELECTOR_ENTITIES=100 boundary, individual selector-only-parameter-rejection cases, malformed JSON body edge case) and a request to expose a hidden-vs-excluded breakdown finer than the current warnings message. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 5826100 commit f586be2

10 files changed

Lines changed: 1163 additions & 217 deletions

File tree

src/ha_mcp/errors.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,10 @@ class ErrorCode(StrEnum):
210210
"Check documentation for required fields",
211211
"Ensure all required parameters are provided",
212212
],
213+
ErrorCode.VALIDATION_FAILED: [
214+
"Check the parameter values and format against the tool documentation",
215+
"Review the message and details fields for the specific constraint that failed",
216+
],
213217
ErrorCode.VALIDATION_INVALID_JSON: [
214218
"Ensure the parameter is valid JSON",
215219
"Check for syntax errors in JSON",

src/ha_mcp/policy/evaluator.py

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from enum import StrEnum
77
from typing import Any
88

9+
from ..tools.util_helpers import _loads_if_json_container_str
910
from .model import Policy, Predicate, Rule
1011

1112
logger = logging.getLogger(__name__)
@@ -16,6 +17,56 @@ class Verdict(StrEnum):
1617
REQUIRE_APPROVAL = "require_approval"
1718

1819

20+
def normalize_stringified_containers(value: Any) -> Any:
21+
"""Recursively parse JSON-encoded object/array strings into real containers.
22+
23+
Some MCP client stacks (Claude Desktop stdio among them — see
24+
``tools/util_helpers.py``'s ``JSON_STRING_COERCION``) pass model-emitted
25+
stringified objects through unrepaired, e.g. sending
26+
``{"selector": "{\\"domain\\": \\"light\\"}"}`` instead of a nested
27+
object. Pydantic's ``JSON_STRING_COERCION`` ``BeforeValidator`` repairs
28+
this, but only when the tool's own parameter validation runs — INSIDE
29+
``call_next``, after policy evaluation. ``iter_path_values`` only
30+
descends into ``dict``/``list``, so a still-stringified value makes a
31+
predicate targeting a nested field (``args.selector.domain``,
32+
``args.operations.*.entity_id``) silently yield nothing: no rule
33+
matches, and — for ``ha_bulk_control`` — ``args.get("selector")`` still
34+
sees a truthy string, so the selector fail-safe's own dynamic-call
35+
detection still fires, but nothing inside it can be inspected either.
36+
Applying the same repair here, once, before evaluation and hashing,
37+
closes that gap for both plain predicate rules and the fail-safe, and
38+
makes ``compute_args_hash`` key on the same logical value regardless of
39+
which wire shape the client sent.
40+
41+
Malformed JSON that merely looks like a container is left as the raw
42+
string (not raised): policy evaluation is not the place to surface a
43+
JSON syntax error — the tool's own validation does that, with a
44+
properly attributed parameter name.
45+
"""
46+
if isinstance(value, str):
47+
try:
48+
return _loads_if_json_container_str(value)
49+
except ValueError:
50+
return value
51+
if isinstance(value, dict):
52+
return {key: normalize_stringified_containers(v) for key, v in value.items()}
53+
if isinstance(value, list):
54+
return [normalize_stringified_containers(v) for v in value]
55+
return value
56+
57+
58+
def has_dynamic_selector_targets(name: str, args: dict[str, Any]) -> bool:
59+
"""Return whether identical arguments can resolve to different targets later.
60+
61+
Single source of truth for the ``ha_bulk_control`` selector-mode predicate:
62+
both ``PolicyMiddleware`` (approval-sharing/remembering gates) and
63+
``evaluate()`` below (the operations fail-safe) must agree on exactly
64+
which calls are "dynamic", or a future rename/extension of this check in
65+
one place silently stops applying to the other.
66+
"""
67+
return name == "ha_bulk_control" and args.get("selector") is not None
68+
69+
1970
def iter_path_values(args: dict[str, Any], path: str) -> Iterator[Any]:
2071
"""Yield every value the dotted path resolves to.
2172
@@ -216,14 +267,10 @@ def evaluate(tool_name: str, args: dict[str, Any], policy: Policy) -> Verdict:
216267
# fields (e.g. args.selector.domain) already had its precise shot at matching
217268
# above, and broadening it here would defeat a deliberately conditional rule
218269
# (a rule scoped to selector.domain == "lock" must not gate a "light" call).
219-
if (
220-
tool_name == "ha_bulk_control"
221-
and args.get("selector") is not None
222-
and any(
223-
rule.tool_name in ("ha_bulk_control", "*")
224-
and _rule_needs_resolved_operations(rule)
225-
for rule in policy.rules
226-
)
270+
if has_dynamic_selector_targets(tool_name, args) and any(
271+
rule.tool_name in ("ha_bulk_control", "*")
272+
and _rule_needs_resolved_operations(rule)
273+
for rule in policy.rules
227274
):
228275
return Verdict.REQUIRE_APPROVAL
229276
return Verdict.ALLOW

src/ha_mcp/policy/middleware.py

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,13 @@
1515
from ..renamed_tools import current_tool_name
1616
from ..tools.helpers import raise_tool_error, safe_progress
1717
from .approval_queue import ApprovalQueue, PendingApproval, compute_args_hash
18-
from .evaluator import Verdict, evaluate, find_matching_rule
18+
from .evaluator import (
19+
Verdict,
20+
evaluate,
21+
find_matching_rule,
22+
has_dynamic_selector_targets,
23+
normalize_stringified_containers,
24+
)
1925
from .model import Policy, Rule
2026

2127
logger = logging.getLogger(__name__)
@@ -64,11 +70,6 @@ def _passes_ungated(name: str, args: dict[str, Any]) -> bool:
6470
return name in PROXY_META_TOOLS or _is_approval_management(name, args)
6571

6672

67-
def _has_dynamic_selector_targets(name: str, args: dict[str, Any]) -> bool:
68-
"""Return whether identical arguments can resolve to different targets later."""
69-
return name == "ha_bulk_control" and args.get("selector") is not None
70-
71-
7273
class PolicyMiddleware(Middleware):
7374
"""Gate tool calls against a Policy, blocking with progress heartbeats."""
7475

@@ -115,7 +116,15 @@ async def on_call_tool(
115116
# name, and ``evaluate`` returns ALLOW when nothing matches, so a gate
116117
# reading a stale name lets the call through.
117118
name = current_tool_name(context.message.name)
118-
args = context.message.arguments or {}
119+
# Normalize stringified JSON containers (a client like Claude Desktop
120+
# stdio can send a nested parameter, e.g. `selector`, as a JSON
121+
# string rather than an object) before any evaluation/hashing below
122+
# — the tool's own Pydantic coercion happens only later, inside
123+
# call_next, which is too late for a predicate path or the selector
124+
# fail-safe to see the real structure. This only affects the local
125+
# copy used for gating; `context` itself is untouched, so the actual
126+
# tool call still receives the client's original wire shape.
127+
args = normalize_stringified_containers(context.message.arguments or {})
119128

120129
if _passes_ungated(name, args):
121130
return await call_next(context)
@@ -125,15 +134,37 @@ async def on_call_tool(
125134

126135
rule = find_matching_rule(name, args, policy)
127136
args_hash = compute_args_hash(args)
128-
dynamic_targets = _has_dynamic_selector_targets(name, args)
137+
dynamic_targets = has_dynamic_selector_targets(name, args)
129138
remember_minutes = (
130139
0 if dynamic_targets else rule.remember_minutes if rule else 0
131140
)
132141

133142
if not dynamic_targets and self._queue.is_remembered(name, args_hash):
134143
return await call_next(context)
135144

136-
existing = self._queue.find(name, args_hash)
145+
# A dynamic selector call must never consume an entry it did not
146+
# itself create and is not itself still waiting on. Two reachable
147+
# ways an unguarded lookup here breaks the "approve once, dispatch
148+
# once, against fresh topology" invariant: (1) a race -- decide()
149+
# flips a waiter's own PendingApproval.decision before the waiter's
150+
# task is rescheduled, so a second, concurrent call's find() can
151+
# observe "approved" and consume-and-dispatch before the original
152+
# waiter wakes and does the same from its own reference, executing
153+
# the click twice; (2) a later, non-racy call -- the original call
154+
# times out and is told to re-call, the user approves afterwards,
155+
# and ANY later identical call within approval_ttl_minutes claims
156+
# that stale approval and dispatches against topology re-resolved
157+
# at that later moment -- exactly the reuse-across-a-time-gap this
158+
# PR disables `remember_minutes` to prevent, just via a different
159+
# mechanism. Binding a dynamic entry to its creating invocation
160+
# closes both: only the call that created a pending entry ever
161+
# observes it (via its own `pending` reference after its own wait,
162+
# below), so every other call -- concurrent or a later retry --
163+
# unconditionally mints its own independent entry and wait window.
164+
# The accepted cost is that a retry never silently rides an earlier
165+
# approval: each blocked call gets its own approval row, and only
166+
# approving the row for the CURRENTLY-blocked call has any effect.
167+
existing = None if dynamic_targets else self._queue.find(name, args_hash)
137168
if existing and existing.decision == "approved":
138169
self._queue.consume_and_maybe_remember(
139170
existing,

0 commit comments

Comments
 (0)