Skip to content

Commit a756e3f

Browse files
committed
fix(policy): contains operator case-insensitive on list-membership branch
Pre-fix: case "contains": if isinstance(val, str) and isinstance(pv, str): return pv.lower() in val.lower() # CI return isinstance(val, (list, tuple, set)) and pv in val # case-SENSITIVE The string-in-string branch was already case-insensitive (matching the ``_ci``-equivalent treatment that ``eq`` / ``in`` / ``not_in`` apply), but the list-membership branch fell through to Python's default ``in`` operator. A rule listing ``["light.kitchen"]`` would not fire on an LLM passing ``["Light.Kitchen"]`` — silent gate failure. Bring it in line with the other string-op branches via per-element ``_ci``, which passes non-string entries through unchanged so mixed-type collections still get natural equality semantics. Caught by Gemini Code Assist on homeassistant-ai#1431; addressing inline rather than opening a follow-up because the policy module is now in master (homeassistant-ai#1421) and any reviewer running the suite would see the case-sensitivity asymmetry in the existing TestCaseInsensitive class.
1 parent 3e96694 commit a756e3f

2 files changed

Lines changed: 27 additions & 1 deletion

File tree

src/ha_mcp/policy/evaluator.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,14 @@ def _op_matches(val: Any, op: str, pv: Any) -> bool:
8484
case "contains":
8585
if isinstance(val, str) and isinstance(pv, str):
8686
return pv.lower() in val.lower()
87-
return isinstance(val, (list, tuple, set)) and pv in val
87+
# Mirror the case-insensitive treatment that ``eq`` / ``in`` /
88+
# ``not_in`` already apply: a rule listing ``["light.kitchen"]``
89+
# must match an LLM passing ``"Light.Kitchen"``. Per-element
90+
# ``_ci`` guards non-string entries so mixed-type collections
91+
# (e.g. ``[1, "two"]``) keep their natural equality semantics.
92+
return isinstance(val, (list, tuple, set)) and any(
93+
_ci(pv) == _ci(x) for x in val
94+
)
8895
case "gt":
8996
try:
9097
return bool(val > pv)

tests/src/unit/policy/test_evaluator.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,25 @@ def test_non_string_types_preserve_natural_equality(self):
195195
p = Predicate(path="args.x", op="eq", value="1")
196196
assert match_predicate(p, {"x": 1}) is False
197197

198+
def test_contains_list_membership_ignores_case(self):
199+
# ``contains`` against a list/tuple/set must mirror ``in`` /
200+
# ``not_in`` — a rule listing ``"light.kitchen"`` has to fire
201+
# when the LLM passes ``["Light.Kitchen"]``. Pre-fix this
202+
# branch was case-sensitive while every other op was CI.
203+
p = Predicate(path="args.entity_id", op="contains", value="light.kitchen")
204+
assert match_predicate(p, {"entity_id": ["Light.Kitchen"]}) is True
205+
assert (
206+
match_predicate(p, {"entity_id": ("LIGHT.KITCHEN", "other.thing")}) is True
207+
)
208+
assert match_predicate(p, {"entity_id": {"foo", "Light.Kitchen"}}) is True
209+
210+
def test_contains_list_membership_non_string_elements_preserve_equality(self):
211+
# Mixed-type collections must keep natural equality for the
212+
# non-string entries — _ci passes non-strings through unchanged.
213+
p = Predicate(path="args.x", op="contains", value=42)
214+
assert match_predicate(p, {"x": [1, 42, "three"]}) is True
215+
assert match_predicate(p, {"x": ["1", "42"]}) is False # int != "42"
216+
198217

199218
# --- wildcard path semantics (catch-all "any argument matches X") ---
200219
class TestWildcardPredicate:

0 commit comments

Comments
 (0)