Skip to content

Commit d208856

Browse files
swissmoclaude
andcommitted
fix(bulk): close review round-4's 6 blocking gaps plus 11 smaller findings
Blocking: - Stop skipping scene entities in the operations-mode conflict gate: a scene dispatch really does cascade into its configured entities (scene.turn_on), unlike selector mode's aggregate-root question, which is why that skip existed there in the first place. The two now answer their own, different questions instead of sharing one wrong exemption. - Split bulk_selector's empty-result gate into three causes instead of two (empty aggregate / wrong-domain aggregate / hidden), each gated on "nothing in the match set is hidden" -- a hidden entity elsewhere in the same area no longer gets masked by an unrelated visible aggregate expanding to the wrong domain. - Fix the selector-only-parameter worked example rendering a duplicate 'action' key when action is itself the offending parameter. - Cap the group-conflict message's member list at 10 (+N more) instead of enumerating an instance-sized membership list in a fail-closed error whose remedy sentence is the entire value of the response. - Cover both _GroupConflict message branches with tests that would catch the two fields being swapped (previously only the unlisted-members branch was ever exercised). Smaller: - Bind topology fetch-failure labels directly to their named locals instead of a position-zipped tuple, so a future reordered gather() can't silently mislabel which registry failed. - _operation_common needs hash=False, not just compare=True: the frozen dataclass claimed to be hashable (mypy agreed) but raised at the first real hash() call, since a dict has none. - Give the args-too-deeply-nested fail-closed branch its own error code (POLICY_ARGS_TOO_DEEPLY_NESTED) instead of reusing POLICY_LOAD_FAILED, which already means a corrupt tool_policy.json -- a different failure with a different, caller-shaped remedy. - Log the dynamic pending-entry removal at INFO; it was the only silent state transition on the approval queue, and a late Approve click on it used to log as an "attacker probing tokens" WARNING for ordinary use. - Restore actionable suggestions on the group-safety check's own states-fetch failure, lost when the isinstance(states, list) guard was removed -- otherwise the agent sees a raw client string with no hint that the safety check itself is what failed. - Deep-copy operation parameters (both the per-row and resolution-level copies) instead of a shallow dict() that only stops top-level rebinding and still shares nested values like an rgb_color list. - Fix an inverted test docstring describing the guard it tests backwards. - Assert expires_in_seconds is absent from the dynamic pending-error body, not just that its message/suggestions text omits a countdown. - Extend the topology multi-failure test to all five registries instead of two, closing the remaining label-typo blind spot. - E2E test: use safe_call_tool for cleanup (so a cleanup failure can't mask a real assertion failure in finally), and discover real lights dynamically instead of hardcoding demo-platform entity IDs. Skipped: a CodeRabbit suggestion to replace the established MCPAssertions.call_tool_failure helper (used across 13 e2e files) with a manual safe_call_tool call in the new E2E test -- no functional benefit, and goes against the repo's own convention. Also skipped narrowing ServiceTools' client: Any typing (untested seam around a dict-shaped get_states() response) -- reviewer confirmed this is not a live gap, and the fix would mean a broader typing change across ServiceTools, register_service_tools, and registry.py. 10722 unit tests pass (up from 10716 by exactly the 6 new tests added here); the 26 remaining failures are the same pre-existing, Windows-platform-only failures already present before this change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 527e813 commit d208856

8 files changed

Lines changed: 497 additions & 93 deletions

File tree

src/ha_mcp/errors.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,16 @@ class ErrorCode(StrEnum):
100100
USER_DENIED = "USER_DENIED"
101101
POLICY_LOAD_FAILED = "POLICY_LOAD_FAILED"
102102

103+
# Distinct from POLICY_LOAD_FAILED above: the policy file itself loaded
104+
# fine, but this call's own arguments were too deeply nested to
105+
# evaluate against it safely (see normalize_stringified_containers'
106+
# RecursionError handling in middleware.py). A different failure with a
107+
# different, caller-shaped remedy ("reduce the nesting depth and
108+
# retry") -- sharing POLICY_LOAD_FAILED would make the two
109+
# indistinguishable to anything grouping on the code (a dashboard, an
110+
# operator grepping logs after "my policy broke").
111+
POLICY_ARGS_TOO_DEEPLY_NESTED = "POLICY_ARGS_TOO_DEEPLY_NESTED"
112+
103113
# Read Only Mode (discussion #1569). A write operation was blocked
104114
# because the server-wide Read Only Mode toggle is on.
105115
READ_ONLY_MODE = "READ_ONLY_MODE"

src/ha_mcp/policy/middleware.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ async def on_call_tool(
154154
)
155155
raise_tool_error(
156156
create_error_response(
157-
ErrorCode.POLICY_LOAD_FAILED,
157+
ErrorCode.POLICY_ARGS_TOO_DEEPLY_NESTED,
158158
"This call's arguments are nested too deeply to "
159159
"evaluate safely against the security policy.",
160160
suggestions=[
@@ -287,6 +287,23 @@ def _finalize_timed_out_pending(
287287
"""
288288
if dynamic_targets:
289289
self._queue.remove(pending.token)
290+
# INFO, matching the reissue log below: silently removing this
291+
# is otherwise the only state transition in this file that
292+
# leaves no trace, on a queue whose whole purpose is auditable
293+
# human approval. Without it, a user who clicks Approve a
294+
# moment after this call's wait window closed -- an ordinary
295+
# sequence, not an attack -- lands on
296+
# ApprovalQueue.approve's "unknown token" WARNING, which is
297+
# explicitly documented there as meaning a UI bug, a stale
298+
# tab, OR an attacker probing tokens. This line is what lets
299+
# an operator tell those apart.
300+
logger.info(
301+
"policy middleware: dynamic pending token %s removed after "
302+
"its single-use wait window closed for tool=%s; a late "
303+
"Approve click on it will log as an unknown token",
304+
pending.token,
305+
name,
306+
)
290307
return pending
291308
if self._queue.get(pending.token) is None:
292309
old_token = pending.token

src/ha_mcp/tools/bulk_selector.py

Lines changed: 96 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import asyncio
6+
import copy
67
import logging
78
from collections.abc import Mapping
89
from dataclasses import dataclass, field
@@ -121,18 +122,25 @@ class BulkSelectorResolution:
121122
expanded_group_ids: tuple[str, ...]
122123
hidden_entity_count: int
123124
warnings: tuple[str, ...] = field(default_factory=tuple)
124-
# repr=False only (compare stays at its True default): `_operation_common`
125-
# can carry a lock or alarm code (selector mode's `parameters` argument,
126-
# e.g. `lock.open` with a keypad code), so the default dataclass __repr__
127-
# would otherwise write it to logs/tracebacks anywhere a resolution is
128-
# printed or logged unredacted. It is NOT compare-excludable, though: it
129-
# holds `action`, so two resolutions over the identical entity set but
130-
# OPPOSITE actions must not compare equal, and `compare=False` would make
131-
# them -- there is no dispatch-irrelevant reading of this field despite
132-
# its name. The trade-off this accepts (unchanged from before repr=False
133-
# was added) is that the frozen dataclass stays unhashable, since a plain
134-
# `dict` has no __hash__; nothing hashes a resolution today.
135-
_operation_common: dict[str, Any] = field(default_factory=dict, repr=False)
125+
# repr=False: `_operation_common` can carry a lock or alarm code
126+
# (selector mode's `parameters` argument, e.g. `lock.open` with a
127+
# keypad code), so the default dataclass __repr__ would otherwise write
128+
# it to logs/tracebacks anywhere a resolution is printed or logged
129+
# unredacted. It stays compare=True (the default): it holds `action`,
130+
# so two resolutions over the identical entity set but OPPOSITE actions
131+
# must not compare equal, and `compare=False` would make them -- there
132+
# is no dispatch-irrelevant reading of this field despite its name.
133+
# hash=False, though: `@dataclass(frozen=True)` generates a real
134+
# __hash__ from every compare=True field, and a plain `dict` has none
135+
# -- left at its default, `hash(resolution)` would type-check (mypy
136+
# sees a real __hash__, isinstance(r, Hashable) is True) and then raise
137+
# `TypeError: unhashable type: 'dict'` at runtime the first time
138+
# anything actually hashes one. hash=False excludes just this field
139+
# from __hash__ while keeping it in __eq__, so the class is genuinely
140+
# hashable (hash/eq stay consistent) instead of merely claiming to be.
141+
_operation_common: dict[str, Any] = field(
142+
default_factory=dict, repr=False, hash=False
143+
)
136144

137145
@property
138146
def operations(self) -> list[dict[str, Any]]:
@@ -149,10 +157,14 @@ def operations(self) -> list[dict[str, Any]]:
149157
row = {"entity_id": entity_id, **self._operation_common}
150158
if "parameters" in row:
151159
# `**self._operation_common` only shallow-copies: every row
152-
# would otherwise share the SAME "parameters" dict object,
153-
# so an in-place mutation of one row's parameters (e.g. by
154-
# the dispatcher) would silently leak into every other row.
155-
row["parameters"] = dict(row["parameters"])
160+
# would otherwise share the SAME "parameters" dict object
161+
# (and the same nested values inside it, e.g. an
162+
# `rgb_color` list), so an in-place mutation of one row's
163+
# parameters -- even a nested one, like
164+
# `params["rgb_color"][0] = 0` -- would otherwise leak into
165+
# every other row. Deep, not shallow: a `dict(...)` copy
166+
# would only stop top-level key rebinding.
167+
row["parameters"] = copy.deepcopy(row["parameters"])
156168
operations.append(row)
157169
return operations
158170

@@ -402,15 +414,6 @@ def _validate_selector(selector: Mapping[str, Any], action: str) -> _ValidatedSe
402414
# ACTUAL registry that failed ("the device registry fetch failed") instead
403415
# of a generic "a topology fetch failed" that gives an operator nothing to
404416
# search HA's own logs for.
405-
_TOPOLOGY_FETCH_LABELS = (
406-
"states",
407-
"entity registry",
408-
"device registry",
409-
"area registry",
410-
"floor registry",
411-
)
412-
413-
414417
async def _load_topology(client: Any) -> _Topology:
415418
"""Load the HA state and registry views used by one resolution.
416419
@@ -440,9 +443,26 @@ async def _load_topology(client: Any) -> _Topology:
440443
return_exceptions=True,
441444
)
442445
states, entities, devices, areas, floors = results
446+
# Labels paired with their named local, not zipped against `results` by
447+
# position: a `zip(strict=True)` against a separate label tuple only
448+
# catches a LENGTH change on reorder, not the reorder itself -- if a
449+
# future edit swaps two `gather()` lines and correctly updates the
450+
# unpack above to match, a position-based label tuple would silently
451+
# keep naming the OLD order, confidently blaming the wrong registry in
452+
# the WARNING below during exactly the outage this logging exists to
453+
# diagnose. Binding each label directly to its already-correct local
454+
# makes a mislabel impossible without an edit that is visibly wrong on
455+
# this line itself.
456+
labeled_results = (
457+
("states", states),
458+
("entity registry", entities),
459+
("device registry", devices),
460+
("area registry", areas),
461+
("floor registry", floors),
462+
)
443463
failures = [
444464
(label, result)
445-
for label, result in zip(_TOPOLOGY_FETCH_LABELS, results, strict=True)
465+
for label, result in labeled_results
446466
if isinstance(result, BaseException)
447467
]
448468
if failures:
@@ -474,7 +494,22 @@ def _require_domain_known(domain: str, states: Mapping[str, Any]) -> None:
474494

475495
def _all_excluded_or_hidden_message(*, excluded_count: int, hidden_count: int) -> str:
476496
"""Build the final empty-result message with a visibility-safe count
477-
breakdown (never entity IDs) when available."""
497+
breakdown (never entity IDs) when available.
498+
499+
Only ever called once ``resolve_bulk_selector``'s own empty-aggregate
500+
and wrong-domain gates have both already ruled themselves out (see the
501+
``not directly_hidden`` guards above this function's one call site), so
502+
by construction at least one count here is non-zero -- refuse the
503+
zero/zero case outright rather than let a future regression upstream
504+
silently render a confident "excluded or hidden" claim with no
505+
evidence behind it.
506+
"""
507+
if not excluded_count and not hidden_count:
508+
raise AssertionError(
509+
"_all_excluded_or_hidden_message called with both counts zero -- "
510+
"resolve_bulk_selector's empty-result gates should have already "
511+
"raised a more specific error for this case"
512+
)
478513
counts = []
479514
if excluded_count:
480515
counts.append(f"{excluded_count} excluded")
@@ -728,22 +763,37 @@ async def resolve_bulk_selector(
728763
selected_leaves = {
729764
entity_id for entity_id in expanded_leaves if entity_id.startswith(f"{domain}.")
730765
}
731-
if expanded_leaves and not selected_leaves:
766+
# Both branches below are gated on `not directly_hidden`: `candidate_roots`
767+
# already subtracts `hidden`, so if ANY matching root is hidden, some of
768+
# the "wrong domain" or "empty aggregate" evidence below could really be
769+
# explained by that hidden entity having been the one that mattered --
770+
# e.g. one visible root expands to a different domain while a SEPARATE,
771+
# hidden root would have matched directly. `directly_hidden` is the more
772+
# actionable fact in that case, so neither branch fires and both counts
773+
# fall through together into `_all_excluded_or_hidden_message` below.
774+
# `directly_hidden` empty additionally guarantees `candidate_roots` is
775+
# non-empty here (matching_roots was already confirmed non-empty above,
776+
# and nothing in it is hidden), so neither branch needs its own
777+
# `candidate_roots` check.
778+
if not directly_hidden and not expanded_leaves:
779+
# A matched aggregate (or every one of several) has literally no
780+
# members -- `normalize_member_entity_ids` returned `[]`, not
781+
# `None`, so it WAS admitted as a root, but its own expansion
782+
# contributed nothing. Distinct from "wrong domain" below: no leaf
783+
# of ANY domain resulted, so there is nothing to blame on a
784+
# different domain either.
785+
raise BulkSelectorValidationError(
786+
f"No entities of domain '{domain}' exist in the selected area(s) -- "
787+
"the matched aggregate(s) have no members"
788+
)
789+
if not directly_hidden and expanded_leaves and not selected_leaves:
732790
# A fourth, distinct empty-result cause: matching_roots was
733791
# non-empty (a non-scene aggregate of a DIFFERENT domain qualified
734792
# as a root, e.g. a `group.living_room` whose members are all
735793
# `switch.*`), so expansion ran and produced leaves, but none of
736794
# them were the target domain -- neither exclusion nor visibility
737795
# ever entered into it. Caught here, before either subtraction
738796
# below, so it can never be misreported as "excluded or hidden".
739-
#
740-
# Gated on `expanded_leaves` (not just `not selected_leaves`):
741-
# `candidate_roots` already subtracts `hidden` above, so when EVERY
742-
# matching root in the area is hidden, `candidate_roots` -- and
743-
# therefore `expanded_leaves` -- is itself empty, and this branch
744-
# must NOT fire for that case: those entities are real, are in the
745-
# area, and are simply hidden, which is exactly what the
746-
# `_all_excluded_or_hidden_message` branch below exists to report.
747797
raise BulkSelectorValidationError(
748798
f"No entities of domain '{domain}' exist in the selected area(s) -- "
749799
"a matched aggregate's members are all a different domain"
@@ -770,13 +820,16 @@ async def resolve_bulk_selector(
770820
"validate_first": validate_first,
771821
}
772822
if parameters is not None:
773-
# Copy, not the caller's own object by reference: the per-row copy
774-
# in BulkSelectorResolution.operations protects each DISPATCH row
775-
# from cross-row mutation, but does nothing about the resolution's
776-
# own stored copy -- without this, a caller that still holds
777-
# `parameters` and mutates it after this call returns would silently
778-
# rewrite the "frozen" resolution's own payload too.
779-
operation_common["parameters"] = dict(parameters)
823+
# Deep copy, not the caller's own object by reference (and not a
824+
# shallow `dict(...)`, which stops only top-level rebinding and
825+
# still shares nested values like an `rgb_color` list): the
826+
# per-row copy in BulkSelectorResolution.operations protects each
827+
# DISPATCH row from cross-row mutation, but does nothing about the
828+
# resolution's own stored copy -- without this, a caller that
829+
# still holds `parameters` and mutates it (at any depth) after
830+
# this call returns would silently rewrite the "frozen"
831+
# resolution's own payload too.
832+
operation_common["parameters"] = copy.deepcopy(parameters)
780833
if timeout_seconds is not None:
781834
operation_common["timeout_seconds"] = timeout_seconds
782835
excluded_and_hidden = effective_excluded & hidden

0 commit comments

Comments
 (0)