Skip to content

Commit 7433941

Browse files
swissmoclaude
andcommitted
fix(bulk): address second round of selector review feedback
Codex's re-review of the structural-selector feature (PR #2246) surfaced four remaining gaps: - bulk_selector: a cross-domain aggregate (e.g. group.living_room_lights) assigned to a selected area was excluded from root candidates before membership expansion, so its ungrouped target-domain members were never discovered. - bulk_selector: a device-registry entry missing `id` was silently skipped by the strict visibility resolver, letting a device-inherited exclusion drop out of the hidden set instead of failing closed (mirrors the per-entry validation in visibility/enforcement.py). - policy/middleware: concurrent identical selector calls shared one pending approval via find_or_create, so a single approval click could release both waiters and dispatch the selector twice. - policy/evaluator: the selector fail-safe gated every ha_bulk_control selector call whenever any rule named the tool, even when that rule's predicates were fully selector-inspectable and simply didn't match — defeating a deliberately conditional rule. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent c62d9e4 commit 7433941

6 files changed

Lines changed: 214 additions & 12 deletions

File tree

src/ha_mcp/policy/evaluator.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,25 @@ def find_matching_rule(
142142
return None
143143

144144

145+
def _rule_needs_resolved_operations(rule: Rule) -> bool:
146+
"""Whether ``rule`` inspects fields only known after selector resolution.
147+
148+
A selector-mode ``ha_bulk_control`` call carries ``args.selector``, not
149+
``args.operations`` — the leaf targets don't exist yet, they're resolved
150+
inside the tool after this middleware runs. A rule predicate path rooted
151+
at ``args.operations`` can therefore never match a selector call via
152+
ordinary evaluation and needs the fail-safe below. A rule whose
153+
predicates only reference selector-inspectable fields (e.g.
154+
``args.selector.domain``) already got a fair, precise match attempt in
155+
``find_matching_rule`` and must not be broadened into an unconditional
156+
gate.
157+
"""
158+
return any(
159+
p.path == "args.operations" or p.path.startswith("args.operations.")
160+
for p in rule.when
161+
)
162+
163+
145164
def evaluate(tool_name: str, args: dict[str, Any], policy: Policy) -> Verdict:
146165
if find_matching_rule(tool_name, args, policy) is not None:
147166
return Verdict.REQUIRE_APPROVAL
@@ -165,12 +184,19 @@ def evaluate(tool_name: str, args: dict[str, Any], policy: Policy) -> Verdict:
165184
return Verdict.REQUIRE_APPROVAL
166185
# Structural selectors are resolved inside the tool, after this middleware.
167186
# A pre-existing rule that inspects args.operations.* therefore cannot inspect
168-
# the eventual leaf targets. Fail safe whenever an operator configured any
169-
# rule applicable to ha_bulk_control; selector-aware rules still match above.
187+
# the eventual leaf targets. Fail safe only for rules that actually depend on
188+
# that unresolved data — a rule fully expressed over selector-inspectable
189+
# fields (e.g. args.selector.domain) already had its precise shot at matching
190+
# above, and broadening it here would defeat a deliberately conditional rule
191+
# (a rule scoped to selector.domain == "lock" must not gate a "light" call).
170192
if (
171193
tool_name == "ha_bulk_control"
172194
and args.get("selector") is not None
173-
and any(rule.tool_name in ("ha_bulk_control", "*") for rule in policy.rules)
195+
and any(
196+
rule.tool_name in ("ha_bulk_control", "*")
197+
and _rule_needs_resolved_operations(rule)
198+
for rule in policy.rules
199+
)
174200
):
175201
return Verdict.REQUIRE_APPROVAL
176202
return Verdict.ALLOW

src/ha_mcp/policy/middleware.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -146,12 +146,23 @@ async def on_call_tool(
146146

147147
# find_or_create serialises the create — two concurrent calls with
148148
# the same args_hash share one pending entry, so the user only sees
149-
# one approval row and approving it releases every waiter.
150-
pending = await self._queue.find_or_create(
151-
name,
152-
args_hash,
153-
args,
154-
ttl_minutes=policy.approval_ttl_minutes,
149+
# one approval row and approving it releases every waiter. Dynamic
150+
# selector calls must NOT share a pending entry: two concurrent
151+
# identical calls can still resolve to different (or overlapping)
152+
# target sets by the time each one dispatches, so folding them onto
153+
# one approval would let a single click authorize more executions
154+
# than the user saw. Skip the sharing and always mint a fresh entry.
155+
pending = (
156+
self._queue.create(
157+
name, args_hash, args, ttl_minutes=policy.approval_ttl_minutes
158+
)
159+
if dynamic_targets
160+
else await self._queue.find_or_create(
161+
name,
162+
args_hash,
163+
args,
164+
ttl_minutes=policy.approval_ttl_minutes,
165+
)
155166
)
156167

157168
wait = (

src/ha_mcp/tools/bulk_selector.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,24 @@ def _registry_rows(result: Any, label: str) -> list[dict[str, Any]]:
6262
return rows
6363

6464

65+
def _validate_device_registry_rows(device_rows: list[dict[str, Any]]) -> None:
66+
"""Fail closed on a device row missing ``id``.
67+
68+
``visibility.resolver._parse_device_registry`` silently skips any device
69+
entry without an ``id`` even when the hidden-set resolver runs
70+
``strict=True`` (see ``_load_hidden_entities``), so a malformed row would
71+
otherwise let a device-derived exclusion (area/label inherited by its
72+
entities) drop out of the hidden set without warning. Mirrors the
73+
per-entry validation ``_refresh_hidden_set`` in
74+
``visibility/enforcement.py`` applies before calling the same strict
75+
resolver.
76+
"""
77+
if any(not row.get("id") for row in device_rows):
78+
raise BulkSelectorValidationError(
79+
"Home Assistant device registry returned a malformed entry"
80+
)
81+
82+
6583
def _string_list(selector: Mapping[str, Any], key: str) -> list[str]:
6684
"""Read one optional exact-ID list without accepting scalar coercion."""
6785
raw = selector.get(key, [])
@@ -308,6 +326,7 @@ async def resolve_bulk_selector(
308326
)
309327
entity_rows = _registry_rows(entity_result, "entity registry")
310328
device_rows = _registry_rows(device_result, "device registry")
329+
_validate_device_registry_rows(device_rows)
311330
selected_areas = _select_area_ids(
312331
_registry_rows(area_result, "area registry"),
313332
_registry_rows(floor_result, "floor registry"),
@@ -330,11 +349,20 @@ async def resolve_bulk_selector(
330349
hidden = await _load_hidden_entities(
331350
client, entity_result, states_result, device_result, entity_registry
332351
)
352+
# A root only needs to LIVE in the selected area; it does not need to be
353+
# of the target domain itself. A `group`/other-domain aggregate assigned
354+
# to the area (e.g. `group.living_room_lights`) is included here too, so
355+
# its membership expansion below can surface `light.*` leaves that have
356+
# no individual area assignment of their own. The target-domain filter
357+
# is re-applied to the expanded leaves further down.
333358
matching_roots = {
334359
entity_id
335-
for entity_id in states
336-
if entity_id.startswith(f"{domain}.")
337-
and _entity_area_id(entity_id, entity_registry, device_areas) in selected_areas
360+
for entity_id, state in states.items()
361+
if _entity_area_id(entity_id, entity_registry, device_areas) in selected_areas
362+
and (
363+
entity_id.startswith(f"{domain}.")
364+
or normalize_member_entity_ids(state.get("attributes")) is not None
365+
)
338366
}
339367
directly_hidden = matching_roots & hidden
340368
candidate_roots = sorted(matching_roots - hidden)

tests/src/unit/policy/test_evaluator.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,3 +479,40 @@ def test_operations_calls_keep_normal_predicate_semantics(self):
479479
)
480480
== Verdict.REQUIRE_APPROVAL
481481
)
482+
483+
def test_nonmatching_selector_only_rule_stays_allowed(self):
484+
"""A rule fully expressed over selector fields keeps its condition.
485+
486+
The fail-safe must only widen rules that depend on unresolved
487+
``args.operations`` data. A rule scoped to
488+
``args.selector.domain == "lock"`` already got a precise match
489+
attempt in ``find_matching_rule``; a "light" selector call must not
490+
be swept into approval just because a same-named rule exists.
491+
"""
492+
policy = Policy(
493+
rules=[
494+
Rule(
495+
tool_name="ha_bulk_control",
496+
when=[
497+
Predicate(path="args.selector.domain", op="eq", value="lock")
498+
],
499+
)
500+
]
501+
)
502+
503+
assert (
504+
evaluate(
505+
"ha_bulk_control",
506+
{"selector": {"domain": "light", "area_ids": ["salon"]}},
507+
policy,
508+
)
509+
== Verdict.ALLOW
510+
)
511+
assert (
512+
evaluate(
513+
"ha_bulk_control",
514+
{"selector": {"domain": "lock", "area_ids": ["salon"]}},
515+
policy,
516+
)
517+
== Verdict.REQUIRE_APPROVAL
518+
)

tests/src/unit/policy/test_middleware.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,43 @@ async def test_dynamic_bulk_selector_approval_is_not_remembered(queue):
140140
assert not queue.is_remembered("ha_bulk_control", args_hash)
141141

142142

143+
@pytest.mark.anyio
144+
async def test_concurrent_dynamic_selector_calls_get_independent_approvals(queue):
145+
"""Two concurrent identical selector calls must not share one pending entry.
146+
147+
Sharing (via ``find_or_create``'s dedup) would let a single approval
148+
click release both waiters, so one click could dispatch the selector
149+
twice — or against two different resolutions if topology changed
150+
between them. Each invocation must mint its own pending approval.
151+
"""
152+
args = {
153+
"selector": {"domain": "lock", "area_ids": ["entry"]},
154+
"action": "lock",
155+
}
156+
policy = Policy(rules=[Rule(tool_name="ha_bulk_control")])
157+
middleware = PolicyMiddleware(
158+
policy_provider=lambda: policy,
159+
queue=queue,
160+
wait_seconds=0,
161+
)
162+
call_next = AsyncMock()
163+
164+
async def call():
165+
with pytest.raises(ToolError):
166+
await middleware.on_call_tool(
167+
make_context("ha_bulk_control", dict(args)), call_next
168+
)
169+
170+
async with anyio.create_task_group() as tg:
171+
tg.start_soon(call)
172+
tg.start_soon(call)
173+
174+
pending = queue.list_pending()
175+
assert len(pending) == 2
176+
assert pending[0].token != pending[1].token
177+
call_next.assert_not_awaited()
178+
179+
143180
@pytest.mark.anyio
144181
async def test_pre_approved_entry_consumed_and_call_proceeds(queue):
145182
pol = Policy(rules=[Rule(tool_name="ha_call_service")])

tests/src/unit/test_bulk_selector.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,69 @@ async def test_directly_hidden_matching_root_is_counted(
233233
assert result.hidden_entity_count == 1
234234

235235

236+
@pytest.mark.asyncio
237+
async def test_cross_domain_aggregate_root_expands_into_target_domain_leaves() -> None:
238+
"""An area-assigned aggregate of a different domain still expands.
239+
240+
``group.living_room_lights`` is not itself a ``light.*`` entity, so it
241+
must not be excluded from the root candidates just because its own
242+
domain doesn't match the selector. Its ungrouped ``light.*`` members
243+
(no individual area assignment) must still be discovered via membership
244+
expansion, then filtered to the requested domain.
245+
"""
246+
client = SelectorClient(
247+
states=[
248+
_state("group.living_room_lights", ["light.ceiling", "light.lamp"]),
249+
_state("light.ceiling"),
250+
_state("light.lamp"),
251+
],
252+
entities=[
253+
{"entity_id": "group.living_room_lights", "area_id": "salon"},
254+
{"entity_id": "light.ceiling", "area_id": None},
255+
{"entity_id": "light.lamp", "area_id": None},
256+
],
257+
)
258+
259+
result = await resolve_bulk_selector(
260+
client,
261+
{"domain": "light", "area_ids": ["salon"]},
262+
action="off",
263+
parameters=None,
264+
timeout_seconds=None,
265+
validate_first=True,
266+
)
267+
268+
assert result.resolved_entity_ids == ["light.ceiling", "light.lamp"]
269+
270+
271+
@pytest.mark.asyncio
272+
async def test_device_registry_entry_missing_id_fails_closed() -> None:
273+
"""A malformed device row must fail closed, not silently drop a device.
274+
275+
``visibility.resolver._parse_device_registry`` skips a device entry
276+
without an ``id`` even under the strict resolver, so a well-formed
277+
device registry is what keeps a device-derived hidden dimension from
278+
silently dropping out. This mirrors the per-entry validation
279+
``_refresh_hidden_set`` in ``visibility/enforcement.py`` applies before
280+
calling the same strict resolver.
281+
"""
282+
client = SelectorClient(
283+
states=[_state("light.one")],
284+
entities=[{"entity_id": "light.one", "area_id": "salon"}],
285+
devices=[{"area_id": "salon"}],
286+
)
287+
288+
with pytest.raises(BulkSelectorValidationError, match="device registry"):
289+
await resolve_bulk_selector(
290+
client,
291+
{"domain": "light", "area_ids": ["salon"]},
292+
action="off",
293+
parameters=None,
294+
timeout_seconds=None,
295+
validate_first=True,
296+
)
297+
298+
236299
@pytest.mark.asyncio
237300
async def test_action_is_normalized_case_insensitively() -> None:
238301
"""Action normalization accepts surrounding whitespace and mixed case."""

0 commit comments

Comments
 (0)