Skip to content

Commit e002244

Browse files
feat: add opt-in enforce mode to the entity visibility filter (#2024)
* feat: add opt-in enforce mode to the entity visibility filter Fixes #2015. When "enforce": true, the visibility filter's hidden set becomes a refuse-on-contact read boundary across every tool: direct reads of a hidden entity are concealed as not-found before the tool runs, content reads whose arguments or output would surface a hidden entity_id are refused, and unscannable surfaces (sandbox execution, screenshots) are refused outright. Enforcement resolves the hidden set via a new strict resolver mode that fails closed on degraded data, with last-known-good fallbacks for both the config and the hidden set. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T2nScAXT4Q2tCECQusr2j4 * fix: close ha_search config-body leak and round-1 CI findings The e2e failure exposed a real gap: ha_search's config-body branch (automation/script/scene/helper/dashboard records) surfaced a hidden helper's entity_id, so the outbound scan refused the whole search. In enforce mode those records are collection reads and are now OMITTED via a scrub that shares the middleware's TTL-cached hidden set. Codex round 1: the last-known-good hidden set is no longer reused when the config key changed (a different denylist is a different policy — fail closed instead); a device-registry payload an active area/label dimension needs is validated under strict mode instead of failing open to empty maps; ha_config_set_dashboard(return_screenshot=true) is refused as an unscannable pixel surface like the get-side screenshot. Also: CodeQL quality findings (explicit string concat, unnecessary lambdas), haos_inaddon skip-ceiling bump for the new external_only e2e test, and the e2e cleanup now drops enforce before removing the probe so a mid-test failure cannot be masked by a refused cleanup call. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T2nScAXT4Q2tCECQusr2j4 * fix: scrub component-path config records in enforce mode Round-2 e2e showed the leak again on the component fast path: when the filter is active, ha_search still routes through ha_mcp_tools/search, whose search_visibility wire applies the hide dimensions to ENTITY results only — its config-body records can still carry a hidden entity_id, so the outbound scan refused the whole search. Mirror the legacy deep_search scrub after _shape_component_search_response, decrementing config_total_matches/count by the dropped records. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T2nScAXT4Q2tCECQusr2j4 * fix: share the hidden-set cache at module scope, not per middleware instance Round-3 CI pinned the remaining leak: the scrub reached the hidden set through a module singleton pointing at a STALE server instance's middleware — cold cache plus a closed httpx client ('Cannot send a request, as the client has been closed'), so active_hidden_regex fail-softed to None and the scrub never ran. A process can hold several server instances (the e2e suite does), so the singleton design was wrong: the TTL cache now lives at module scope shared by all middleware instances and scrub callers, and active_hidden_regex takes the CALLER's live client for any refresh. Same TTL / same-key last-known-good / fail-closed semantics, now instance-independent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T2nScAXT4Q2tCECQusr2j4 * fix: run inbound concealment before the approval queue; validate device entries Codex round 2. The enforcement middleware is split in two: the inbound conceal/refuse gate now registers BEFORE the read-only guard and PolicyMiddleware, so a call naming a hidden entity is concealed as not-found before its raw arguments can be stored in the approval queue (rendered in the settings UI) or answered with an approval-pending response that would confirm existence. The outbound result scan stays registered last (innermost, raw tool output). A wiring test pins the registration order. The strict device-registry validation now also checks per-entry shape: a degenerate success payload like {"result": [null]} passed the shape check but parsed to empty device maps, silently dropping device- inherited area/label denies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T2nScAXT4Q2tCECQusr2j4 * docs: state the camera exemption; fix boundary note and refusal list; pin wiring by call order Patch76 review round. The camera-image exemption is now stated rather than silent: FAQ and SECURITY.md scope the enforce boundary to HA entity data (a visible camera returns physical-world imagery; hide the camera itself when its view is sensitive) and the 'never flows' claim is qualified to tool reads. The FAQ refusal list gains ha_config_set_dashboard(return_screenshot), which the code already refused. The settings-UI boundary warning is scoped to 'without enforce mode' in the HTML fallback and all four locale catalogs so it no longer contradicts the enforce section below it. The middleware- order wiring test now records actual call order on a stubbed _initialize_server run instead of scanning source text, so an inline add_middleware appended after the outbound half fails the test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T2nScAXT4Q2tCECQusr2j4 * test: fold Patch76 re-review notes into the wiring test The module docstring now describes the recorded-call-order mechanism (the source-order wording described the approach this test replaced), and the tail assertion is a bare emptiness check so a middleware- registering helper that doesn't follow the _apply_ naming cannot slip in behind the outbound scan. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T2nScAXT4Q2tCECQusr2j4 --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent a29bd60 commit e002244

23 files changed

Lines changed: 2046 additions & 45 deletions

SECURITY.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,33 @@ they are the intended behavior. Restricting HA permissions is done in Home
9595
Assistant (e.g. by creating a non-admin user and generating a token for that
9696
user).
9797

98+
### Entity visibility enforce mode is best-effort concealment
99+
100+
The opt-in entity visibility filter's **enforce mode** (`"enforce": true`, see
101+
the [Entity visibility filter FAQ](docs/FAQ.md#enforce-mode)) makes a hidden set
102+
unreadable across all tools: direct reads of a hidden entity are refused with a
103+
generic not-found before the tool runs, and content reads (dashboards, templates,
104+
automations, traces, logs, files) that would surface a hidden entity_id are
105+
refused on contact. It fails closed when the registry cannot be loaded. This is a
106+
strong barrier against an agent *incidentally* surfacing a hidden entity, not a
107+
cryptographic guarantee: enforcement is a text scan for the hidden entity_id, so a
108+
Jinja template or sandbox-adjacent computation that *derives* a hidden entity's
109+
state without ever naming its entity_id cannot be caught. The guaranteed property
110+
is that a hidden entity's data never flows to the client through a tool read —
111+
existence concealment
112+
is best-effort: the concealment error is a canonical not-found, and per-tool
113+
not-found shapes vary (details text, suggestions, bulk reads that normally
114+
partial-succeed), so a prober comparing error shapes may infer that an id is
115+
hidden rather than absent. The boundary covers Home Assistant entity data, not
116+
physical-world imagery: a camera the filter does not hide stays readable
117+
(`ha_get_camera_image`), and its frames could incidentally show a display that
118+
renders a hidden entity's state — hide the camera itself (denylist or its area)
119+
when its view is sensitive. Enforce mode is not a
120+
defense against an adversarial prompt author deliberately trying to exfiltrate a
121+
hidden entity's state, and (like the default filter) it is not a substitute for
122+
Home Assistant's own permission model — restrict what the configured token can
123+
reach in HA for a hard boundary.
124+
98125
### OAuth Bearer token design
99126

100127
In OAuth mode, access and refresh tokens are HMAC-signed, stateless Bearer

docs/FAQ.md

Lines changed: 96 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -333,19 +333,25 @@ ChatGPT (web, including Codex Work Mode) caches a connector's tool list and some
333333

334334
By default the agent sees every entity. If auto-generated diagnostic or helper
335335
entities clutter search and overview results, you can hide a chosen set of them
336-
from the *collection* read tools (`ha_search`, `ha_get_overview`). This is
337-
**noise reduction, not access control** – a hidden entity is still returned by a
338-
direct `ha_get_state` / `ha_get_entity` on its `entity_id`, and still appears in
339-
automation, dashboard, and template content, so do not rely on it as a security
340-
boundary.
341-
342-
**Reads only – it does not gate control tools.** The filter scopes what the
343-
*collection read* tools return. It does **not** stop an agent from calling a
344-
service on a hidden `entity_id`: gating writes is a separate concern handled by
345-
the Tool Security Policies engine (which matches on a call's arguments), not by
346-
visibility. Visibility is deliberately read-scoping only, precisely because it
347-
is noise reduction and cannot be a security boundary (content-bearing reads such
348-
as automation and template bodies would leak hidden entities anyway).
336+
from the *collection* read tools (`ha_search`, `ha_get_overview`). In its default
337+
form this is **noise reduction, not access control** – a hidden entity is still
338+
returned by a direct `ha_get_state` / `ha_get_entity` on its `entity_id`, and
339+
still appears in automation, dashboard, and template content, so do not rely on
340+
the default filter as a security boundary. The opt-in **[Enforce mode](#enforce-mode)**
341+
below turns it into a genuine read barrier: with `"enforce": true`, direct reads
342+
of a hidden entity are concealed and content reads that would surface one are
343+
refused across every tool.
344+
345+
**Default form: reads only – it does not gate control tools.** Without enforce
346+
mode the filter only scopes what the *collection read* tools return. It does
347+
**not** stop an agent from calling a service on a hidden `entity_id`: gating
348+
writes is a separate concern handled by the Tool Security Policies engine (which
349+
matches on a call's arguments), not by visibility. In the default form,
350+
visibility is deliberately read-scoping only, precisely because it is noise
351+
reduction and cannot be a security boundary (content-bearing reads such as
352+
automation and template bodies would leak hidden entities anyway). Enforce mode
353+
changes this by also concealing hidden entities named in a write call's arguments
354+
(see below).
349355

350356
The easiest way to configure it is the **Entity Visibility** tab in the ha-mcp
351357
settings UI (enable toggle, category checkboxes, area/label fields, per-entity
@@ -368,7 +374,8 @@ directory (the same directory as `tool_policy.json`; `/data` in the add-on) with
368374
"allow_entity_ids": [],
369375
"allow_areas": [],
370376
"allow_labels": [],
371-
"respect_assist_exposure": false
377+
"respect_assist_exposure": false,
378+
"enforce": false
372379
}
373380
```
374381

@@ -409,12 +416,84 @@ it passes every active one.
409416
when it is *exposed*; an explicit un-expose cannot be observed there, so such an
410417
entity falls to its domain/device-class default and stays visible (fail-open).
411418

419+
#### Enforce mode
420+
421+
Set `"enforce": true` (or the **Enforce mode** toggle in the Entity Visibility
422+
tab) to turn the same hidden set into a genuine read barrier applied across
423+
**every** tool, not just `ha_search` / `ha_get_overview`. `enforce` is not a hide
424+
dimension — it does not change *which* entities are hidden, only how strongly the
425+
hiding is applied — so it is inert unless the filter is also `enabled` with at
426+
least one active hide dimension. What it covers:
427+
428+
- **Direct reads are concealed.** A call whose arguments name a hidden entity_id
429+
exactly (`ha_get_state`, `ha_get_history`, …) is refused *before the tool
430+
runs* with a canonical `ENTITY_NOT_FOUND`, so the entity's state and
431+
attributes never flow. Concealment of *existence* is best-effort: per-tool
432+
not-found shapes vary (a bulk `ha_get_state` normally partial-succeeds, and
433+
details/suggestions differ per tool), so a caller deliberately comparing
434+
error shapes may infer that an id is hidden rather than absent. Note this
435+
also means a bulk read that co-lists one hidden entity is refused as a whole
436+
— retry without the hidden id to read the rest.
437+
- **Collection reads omit** hidden entities, exactly as they do without enforce.
438+
In enforce mode this extends to `ha_search`'s configuration-body matches: an
439+
automation, script, scene, helper, or dashboard record that references a
440+
hidden entity is omitted from the config results (in the default soft mode
441+
such records still appear — that is the documented soft-filter behavior).
442+
- **Content reads are refused on contact.** A dashboard config, template result,
443+
automation/script body, trace, log, or file read whose output would surface a
444+
hidden entity_id is refused with a generic `ENTITY_VISIBILITY_ENFORCED` error
445+
that never names the matched id.
446+
- **Writes naming a hidden entity are concealed too.** The inbound argument scan
447+
applies to *every* tool, including service calls: a `ha_call_service` targeting
448+
a hidden entity_id is concealed as not-found, so an agent cannot confirm the
449+
entity by trying to control it.
450+
451+
What it deliberately **refuses** (their output cannot be text-scanned): sandbox
452+
code execution via `ha_manage_custom_tool` (`code` / `run_saved` — pure
453+
`list_saved` stays allowed) and screenshot/pixel output
454+
(`ha_get_dashboard_screenshot`, `ha_config_get_dashboard` with
455+
`include_screenshot`, or `ha_config_set_dashboard` with `return_screenshot`).
456+
457+
One image surface is deliberately **exempt**: `ha_get_camera_image`. A camera
458+
the filter does not hide returns physical-world imagery — a photograph, not a
459+
rendering of Home Assistant entity data — so its frames are not gated (a hidden
460+
camera is concealed like any other entity). The residual case is a visible
461+
camera whose view happens to include a display showing a hidden entity's state;
462+
if a camera can see something sensitive, hide the camera too (denylist or its
463+
area).
464+
465+
Enforce mode **fails closed**: if the entity registry (or the config file
466+
itself) cannot be loaded, the server falls back to the last good read from this
467+
session — and with none available, tool calls are refused rather than risk
468+
leaking a restricted entity. The hidden set is cached for ~30s, so an area/label
469+
membership change in Home Assistant can take up to that long to take effect for
470+
the area/label dimensions (a config edit in the settings UI applies on the next
471+
call).
472+
473+
Because refuse-on-contact applies to the *whole* hidden set, broad hide
474+
dimensions make refusals frequent: with the default `diagnostic`/`config`
475+
category excludes still active, any log, automation, or dashboard read that
476+
mentions a diagnostic entity is refused wholesale. Enforce mode works best with
477+
a *targeted* deny — the private areas, labels, or entity_ids you actually need
478+
concealed — rather than broad decluttering dimensions.
479+
480+
**Honest residual limits.** This is a strong barrier against *incidental*
481+
exposure, not a cryptographic guarantee. A Jinja template (or code) that *derives*
482+
a hidden entity's state without ever naming its entity_id — e.g.
483+
`{{ states | selectattr('state','eq','on') | list | count }}` — cannot be caught
484+
by a text scan. Treat enforce mode as robust protection against an agent stumbling
485+
onto hidden entities, not as a boundary against an adversarial prompt author who
486+
is deliberately trying to exfiltrate a hidden entity's state.
487+
412488
`version` drives optimistic-concurrency for the settings UI (it bumps
413489
on each save so two tabs can't clobber each other); when hand-editing the file,
414490
leave it as-is. The config is read live per request, so edits apply on the next
415-
call; a missing or invalid file leaves the filter off (and, when enabled but the
416-
registry read degrades, results are unfiltered with a `warnings` note rather than
417-
silently wrong).
491+
call. A missing file leaves the filter off; an *invalid* one leaves the filter
492+
off for search/overview (with a `warnings` note) while enforce-mode safety falls
493+
back to the session's last good config — with none, tool calls are refused until
494+
the file is fixed (see *Enforce mode* above). When the filter is enabled but the
495+
registry read degrades, search results are unfiltered with a `warnings` note
496+
rather than silently wrong.
418497

419498
---
420499

src/ha_mcp/errors.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,14 @@ class ErrorCode(StrEnum):
116116
# backup directory is unusable). Fail-closed: nothing was changed.
117117
BACKUP_CAPTURE_FAILED = "BACKUP_CAPTURE_FAILED"
118118

119+
# Entity visibility enforce mode (#2015). A read was refused because the
120+
# opt-in visibility filter's enforce mode is active and the request would
121+
# surface an entity the filter hides — either the tool's output references a
122+
# hidden entity, or the tool's output cannot be scanned (screenshots, sandbox
123+
# code). Direct reads of a hidden entity are concealed as ENTITY_NOT_FOUND
124+
# instead; this code marks the refuse-on-contact case. No data was returned.
125+
ENTITY_VISIBILITY_ENFORCED = "ENTITY_VISIBILITY_ENFORCED"
126+
119127

120128
# Default suggestions for common error codes
121129
DEFAULT_SUGGESTIONS: dict[ErrorCode, list[str]] = {

src/ha_mcp/server.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,15 @@ def _initialize_server(self) -> None:
251251

252252
self.mcp.add_middleware(LlmExposureMiddleware())
253253

254+
# Entity visibility enforce mode, INBOUND half (#2015) — always
255+
# installed, consults the live config per request (no-op unless
256+
# enforce is on with an active hide dimension). Must precede the
257+
# read-only guard and PolicyMiddleware: a call naming a hidden
258+
# entity is concealed as not-found BEFORE it can be stored verbatim
259+
# in the approval queue (rendered in the settings UI) or answered
260+
# with a read-only/approval response that would confirm existence.
261+
self._apply_visibility_inbound_middleware()
262+
254263
# Read Only Mode write blocker (discussion #1569) — always
255264
# installed, consults the live flag per call. Before
256265
# PolicyMiddleware so a write blocked by Read Only Mode never
@@ -269,6 +278,11 @@ def _initialize_server(self) -> None:
269278
# wraps the final tool surface (including the search proxies).
270279
self._apply_tool_security_policies()
271280

281+
# Entity visibility enforce mode, OUTBOUND half (#2015) — added LAST
282+
# so it is innermost: its result scan sees the raw tool output before
283+
# any other middleware transforms it.
284+
self._apply_visibility_outbound_middleware()
285+
272286
def _get_skills_dir(self) -> Path | None:
273287
"""Return the bundled skills directory if it exists.
274288
@@ -1125,6 +1139,35 @@ async def _list_all_tools() -> Any:
11251139

11261140
self.mcp.add_middleware(StrictBpsMiddleware(list_tools=_list_all_tools))
11271141

1142+
def _apply_visibility_inbound_middleware(self) -> None:
1143+
"""Install the enforce-mode INBOUND (conceal/refuse) middleware (#2015).
1144+
1145+
Always installed — self-no-ops at call time (loads the visibility
1146+
config per request and passes through unless enforce is on with an
1147+
active hide dimension), so a settings-UI toggle applies live in
1148+
standalone-HTTP/embedded mode like ``read_only_mode``. It needs the HA
1149+
client to fetch the registry/states for the hidden-set computation;
1150+
inject the lazy client accessor so no eager connection is made at
1151+
startup. Ordering (before read-only/policy) is owned by the caller.
1152+
"""
1153+
from .visibility.enforcement import VisibilityInboundEnforcement
1154+
1155+
self.mcp.add_middleware(
1156+
VisibilityInboundEnforcement(get_client=lambda: self.client)
1157+
)
1158+
1159+
def _apply_visibility_outbound_middleware(self) -> None:
1160+
"""Install the enforce-mode OUTBOUND (result-scan) middleware (#2015).
1161+
1162+
Same always-installed/self-no-op contract as the inbound half; the
1163+
caller registers it last so it is innermost and scans raw tool output.
1164+
"""
1165+
from .visibility.enforcement import VisibilityOutboundEnforcement
1166+
1167+
self.mcp.add_middleware(
1168+
VisibilityOutboundEnforcement(get_client=lambda: self.client)
1169+
)
1170+
11281171
# Shared action-phrased keyword block for retrieval. Some MCP clients
11291172
# (Claude Code, others) rank candidate tools by token-overlap between
11301173
# the user's natural-language query and each tool's `description`

src/ha_mcp/settings_ui/locales/de.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,11 @@
8282
"accessibility.colors.clear": "Benutzerdefinierte Farben löschen",
8383
"accessibility.reset": "Auf Standardwerte zurücksetzen",
8484
"visibility.intro": "Verstecke Entitäten mit geringem Wert (automatisch generierte Diagnose- oder Helfer-Entitäten oder eine explizite Liste) aus den Sammlungs-Lese-Tools <code>ha_search</code> und <code>ha_get_overview</code>, sodass Such- und Übersichtsergebnisse weniger überladen sind. Standardmäßig ausgeschaltet; Änderungen gelten beim nächsten Tool-Aufruf.",
85-
"visibility.boundary": "Rauschreduzierung, keine Zugriffskontrolle: Eine versteckte Entität wird weiterhin durch ein direktes Lesen ihrer entity_id zurückgegeben und erscheint weiterhin in Automatisierungs-, Dashboard- und Template-Inhalten. Verlasse dich nicht darauf als Sicherheitsgrenze.",
85+
"visibility.boundary": "Ohne den Erzwingungsmodus (unten) ist dies Rauschreduzierung, keine Zugriffskontrolle: Eine versteckte Entität wird weiterhin durch ein direktes Lesen ihrer entity_id zurückgegeben und erscheint weiterhin in Automatisierungs-, Dashboard- und Template-Inhalten. Verlasse dich nicht allein auf den Filter als Sicherheitsgrenze.",
8686
"visibility.enabled": "Entitäts-Sichtbarkeitsfilter aktivieren",
87+
"visibility.enforce.title": "Erzwingungsmodus",
88+
"visibility.enforce.help": "Erweitert den Filter vom Aufräumen von Suche und Übersicht auf jeden Werkzeug-Lesezugriff. Ein direkter Lesezugriff auf eine versteckte Entität liefert „nicht gefunden“, und ein Inhalts-Lesezugriff (Dashboard, Template, Automatisierung, Trace, Log oder Datei), der eine versteckte Entität offenlegen würde, wird verweigert. Bestmögliche Verschleierung gegen versehentliche Offenlegung, keine gehärtete Sicherheitsgrenze.",
89+
"visibility.enforce.only_enforced": "Versteckte Entitäten für alle Werkzeuge unlesbar machen (nicht nur für die Suche)",
8790
"visibility.categories.title": "Nach Kategorie verstecken",
8891
"visibility.categories.help": "Home Assistant markiert automatisch generierte Entitäten als Diagnose oder Konfiguration.",
8992
"visibility.categories.legend": "Zu versteckende Entitätskategorien",

src/ha_mcp/settings_ui/locales/en.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,11 @@
8282
"accessibility.colors.clear": "Clear custom colors",
8383
"accessibility.reset": "Reset to defaults",
8484
"visibility.intro": "Hide low-value entities (auto-generated diagnostic or helper entities, or an explicit list) from the collection read tools <code>ha_search</code> and <code>ha_get_overview</code>, so search and overview results are less cluttered. Off by default; changes apply on the next tool call.",
85-
"visibility.boundary": "Noise reduction, not access control: a hidden entity is still returned by a direct read of its entity_id and still appears in automation, dashboard, and template content. Do not rely on this as a security boundary.",
85+
"visibility.boundary": "Without enforce mode (below), this is noise reduction, not access control: a hidden entity is still returned by a direct read of its entity_id and still appears in automation, dashboard, and template content. Do not rely on the filter alone as a security boundary.",
8686
"visibility.enabled": "Enable entity visibility filter",
87+
"visibility.enforce.title": "Enforce mode",
88+
"visibility.enforce.help": "Extends the filter from decluttering search and overview to every tool read. A direct read of a hidden entity returns “not found”, and a content read (dashboard, template, automation, trace, log, or file) that would surface a hidden entity is refused. Best-effort concealment against incidental exposure, not a hardened security boundary.",
89+
"visibility.enforce.only_enforced": "Make hidden entities unreadable across all tools (not just search)",
8790
"visibility.categories.title": "Hide by category",
8891
"visibility.categories.help": "Home Assistant tags auto-generated entities as diagnostic or config.",
8992
"visibility.categories.legend": "Entity categories to hide",

0 commit comments

Comments
 (0)