This commit is contained in:
Home Assistant Version Control
2026-09-02 17:25:42 +00:00
parent ce1a5a5fe9
commit 96984f8a7c
20 changed files with 426 additions and 293 deletions
+1 -1
View File
@@ -51,7 +51,7 @@
},
{
"id": "6ad5d0d7a4c9435a85476e49d22d4df4",
"url": "/hacsfiles/versatile-thermostat-ui-card/versatile-thermostat-ui-card.js?hacstag=714354847320",
"url": "/hacsfiles/versatile-thermostat-ui-card/versatile-thermostat-ui-card.js?hacstag=714354847330",
"type": "module"
},
{
+1 -1
View File
@@ -25,7 +25,7 @@ DOMAIN = "ha_mcp_tools"
# in CI. The
# capability negotiation — not this version — gates each WS command (see
# ``websocket_api.CAPABILITIES``).
COMPONENT_VERSION = "2.1.0"
COMPONENT_VERSION = "2.1.1"
# Config-entry discriminator (``entry.data[CONF_ENTRY_TYPE]``). A missing value
# means "tools" so the pre-existing services entry keeps working across the
+1 -1
View File
@@ -22,5 +22,5 @@
"requirements": [
"ruamel.yaml>=0.18.0"
],
"version": "2.1.0"
"version": "2.1.1"
}
+35 -50
View File
@@ -29,7 +29,7 @@ mapping); the ``ha_auth`` bearer check + discovery documents mirror the add-on's
``auth_native.py`` + the ``ha_auth`` subset of ``oauth.py``; the ``legacy``
provider + its root ``/authorize`` + ``/token`` views live in
:mod:`oauth_legacy`, ported from the ``legacy`` subset of the add-on's
``oauth.py``. The seven RFC 8414 / RFC 9728 discovery views below are shared by
``oauth.py``. The six RFC 8414 / RFC 9728 discovery views below are shared by
``ha_auth``, ``legacy``, and ``none`` (which serves a distinct auto-approve
authorization-server document pointing at :mod:`oauth_autoapprove`'s endpoints)
— see :func:`active_auth_mode`.
@@ -387,39 +387,6 @@ def _none_mode_authorization_server_document(base: str) -> dict[str, Any]:
}
class _ProtectedResourceMetadataView(HomeAssistantView):
"""RFC 9728 Protected Resource Metadata."""
requires_auth = False
cors_allowed = True
url = f"{OAUTH_BASE}/protected-resource"
name = "ha_mcp_tools:oauth:protected-resource"
def __init__(self, hass: HomeAssistant) -> None:
"""Bind the view to the HA instance; liveness is resolved per request."""
self._hass = hass
async def get(self, request: web.Request) -> web.Response:
"""Serve the protected-resource document for the bearer-gated modes only.
SECURITY (#1976 review): this ANONYMOUS, fixed (guessable) path exposes
``resource: <base>/api/webhook/<id>``. In none mode the webhook id is the
SOLE credential, so serving it here would leak it to any unauthenticated
GET. Serve only for ``ha_auth``/``legacy`` (where the id is not a secret
and the 401 ``WWW-Authenticate`` pointer legitimately directs a client
here); 404 otherwise. The PATH-SCOPED well-known view still serves in none
mode — its caller must already know the id (it is a route parameter).
"""
if active_auth_mode(self._hass) not in (WEBHOOK_AUTH_HA, WEBHOOK_AUTH_LEGACY):
return _json_not_found()
webhook_id = _active_webhook_id(self._hass)
if webhook_id is None:
return _json_not_found()
return web.json_response(
_protected_resource_document(webhook_id, _build_base_url(request))
)
class _AuthorizationServerMetadataView(HomeAssistantView):
"""RFC 8414 Authorization Server Metadata.
@@ -450,12 +417,23 @@ class _AuthorizationServerMetadataView(HomeAssistantView):
class _WellKnownProtectedResourceView(HomeAssistantView):
"""RFC 9728 §3.1 path-scoped Protected Resource Metadata.
"""RFC 9728 §3.1 path-scoped Protected Resource Metadata — the ONLY
protected-resource document this integration serves.
Same document as :class:`_ProtectedResourceMetadataView`, served at the
well-known location derived from the webhook resource URL — claude.ai's
first fallback probe when the 401's ``resource_metadata`` pointer is
missing. The webhook id is a ROUTE PARAMETER (not baked into the path at
Served at the well-known location derived from the webhook resource URL
(``/.well-known/oauth-protected-resource/api/webhook/<id>``), which is also
where the webhook's 401 ``resource_metadata`` challenge points, and which is
claude.ai's first fallback probe when that pointer is missing.
SECURITY (#1976 review): there is deliberately NO fixed-path variant of this
document. A fixed, guessable path handed ``resource: <base>/api/webhook/<id>``
to any anonymous GET while ``ha_auth``/``legacy`` was on, and a later switch
back to ``none`` mode — where the webhook id is the SOLE credential —
promoted that already-published value into the credential. This view's
caller must already hold the id (it is a route parameter), so the document
discloses nothing in any mode.
The webhook id is a ROUTE PARAMETER (not baked into the path at
registration): a remove + re-add of the entry mints a new webhook id in the
same HA session, and the bound view must serve whichever id is currently
live (404 for any other). Standalone view (not a subclass of the plain
@@ -496,10 +474,11 @@ class _WellKnownAuthorizationServerMetadataView(_AuthorizationServerMetadataView
def _metadata_views(hass: HomeAssistant) -> list[HomeAssistantView]:
"""Build the seven discovery-document views, shared by ha_auth and legacy
(mode-agnostic — each view resolves the active mode per request)."""
"""Build the six discovery-document views, shared by ha_auth and legacy
(mode-agnostic — each view resolves the active mode per request): the
path-scoped protected-resource document, the authorization-server document,
and the four RFC 8414 / OIDC well-known authorization-server locations."""
views: list[HomeAssistantView] = [
_ProtectedResourceMetadataView(hass),
_AuthorizationServerMetadataView(hass),
_WellKnownProtectedResourceView(hass),
]
@@ -528,7 +507,7 @@ def _metadata_views(hass: HomeAssistant) -> list[HomeAssistantView]:
def _register_metadata_views(hass: HomeAssistant) -> None:
"""Register the seven discovery views at most once per HA session.
"""Register the six discovery views at most once per HA session.
aiohttp cannot unregister a bound view, so a reload / re-enable / re-add /
ha_auth<->legacy mode switch must all reuse the already-bound views — they
@@ -551,15 +530,21 @@ def _register_metadata_views(hass: HomeAssistant) -> None:
hass.data[_OAUTH_VIEWS_REGISTERED_KEY] = True
def _build_unauthorized_response(request: web.Request) -> web.Response:
def _build_unauthorized_response(request: web.Request, webhook_id: str) -> web.Response:
"""Build the 401 + ``WWW-Authenticate`` challenge MCP clients use to discover.
Per RFC 9728 §5.1 / MCP spec, the ``resource_metadata`` parameter points to
the protected-resource metadata URL where the client finds the authorization
server.
Per RFC 9728 §5.1 / MCP 2026-07-28 Authorization Server Discovery, the
``resource_metadata`` parameter points to the protected-resource metadata
URL where the client finds the authorization server.
"""
base = _build_base_url(request)
metadata_url = f"{base}{OAUTH_BASE}/protected-resource"
# RFC 9728 §3.1 path-scoped location. The pointer names the id, but this
# 401 is only produced on a request TO /api/webhook/<id>, so the caller
# already holds it; there is no fixed-path document to point at (see
# _WellKnownProtectedResourceView).
metadata_url = (
f"{base}/.well-known/oauth-protected-resource/api/webhook/{webhook_id}"
)
return web.Response(
status=401,
text="Unauthorized",
@@ -592,10 +577,10 @@ async def _check_webhook_auth(
if resource_server is not None and not await resource_server.validate_request(
request
):
return _build_unauthorized_response(request)
return _build_unauthorized_response(request, cfg["webhook_id"])
oauth_provider: LegacyOAuthProvider | None = cfg.get("oauth_provider")
if oauth_provider is not None and not oauth_provider.validate_bearer(request):
return _build_unauthorized_response(request)
return _build_unauthorized_response(request, cfg["webhook_id"])
return None
@@ -49,7 +49,7 @@ from __future__ import annotations
import logging
import secrets
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
from urllib.parse import urlencode, urlparse
import aiohttp
from aiohttp import web
@@ -303,13 +303,23 @@ class AutoApproveAuthorizeView(HomeAssistantView):
if forward_id != client_id:
params.popall("client_id", None)
params["client_id"] = forward_id
import yarl
# Keep the browser hop relative, matching the token leg. Browsers cannot
# be made to send X-Forwarded-Host, so this is consistency rather than a
# vulnerability fix.
target = yarl.URL("/auth/authorize").with_query(params)
return web.Response(status=302, headers={"Location": str(target)})
# Percent-encode the query instead of handing the params to yarl: yarl
# legally leaves ":" and "/" literal inside query values (RFC 3986
# permits both in the query component), so a loopback client's callback
# forwards as ``redirect_uri=http://127.0.0.1:1234/callback``. Reverse
# proxies shipping a generic "block common exploits" ruleset -- Nginx
# Proxy Manager enables one per host with a checkbox -- match
# ``[a-zA-Z0-9_]=http://`` and answer 403 before core ever sees the
# request, stranding every native-app client behind such a proxy.
# Full encoding is semantically identical and survives those filters.
query = urlencode(list(params.items()))
target = f"/auth/authorize?{query}" if query else "/auth/authorize"
return web.Response(status=302, headers={"Location": target})
async def post(self, request: web.Request) -> web.Response:
"""Handle a legacy-mode consent submission on the scoped route."""
@@ -29,11 +29,11 @@
"title": "Nástroje souborů a YAML HA-MCP",
"description": "Nakonfigurujte privilegované služby pro úpravu souborů a YAML používané volitelnými nástroji souborů/YAML ha-mcp. Tato nastavení se také zobrazují v uživatelském rozhraní nastavení serveru ha-mcp a aplikují se okamžitě, bez restartu. Nepřepisovatelná úroveň zákazu stále blokuje citlivé cesty (např. .storage) a klíče hranice důvěry (homeassistant, http, frontend, lovelace).",
"data": {
"allowed_dirs": "Další adresáře souborů",
"allowed_dirs": "Další cesta k souborům",
"extra_yaml_keys": "Další klíče pro zápis YAML"
},
"data_description": {
"allowed_dirs": "Adresáře vzhledem ke konfiguračnímu adresáři, každému je uděleno právo čtení a zápisu (např. pyscript). Průchod a položky mimo konfiguraci jsou zahozeny.",
"allowed_dirs": "Cesty relativní k adresáři konfigurace, včetně přesných názvů souborů, jako je sensor.yaml, nebo cesty pod /share, /media, /ssl a /backup. Každá položka má uděleno oprávnění pro čtení a zápis pro danou cestu a vše pod ní; každou cestu zadejte jako samostatnou položku seznamu. Adresářový posun (traversal) a jiné nepovolené cesty budou ignorovány.",
"extra_yaml_keys": "Klíče nejvyšší úrovně, které ha_config_set_yaml může zapisovat navíc k vestavěným, pro integrace s prioritou YAML v této instalaci (např. alert2). Klíče hranice důvěry jsou zahozeny."
}
},
@@ -29,11 +29,11 @@
"title": "HA-MCP File & YAML Tools",
"description": "Konfiguriere die privilegierten Datei- und YAML-Bearbeitungsdienste, die von den opt-in Datei/YAML-Tools von ha-mcp verwendet werden. Diese Einstellungen erscheinen auch in der eigenen Einstellungs-UI des ha-mcp-Servers und werden live wirksam, ohne Neustart. Eine nicht überschreibbare Deny-Grenze blockiert weiterhin sensible Pfade (z. B. .storage) und Vertrauensgrenzen-Keys (homeassistant, http, frontend, lovelace).",
"data": {
"allowed_dirs": "Zusätzliche Datei-Verzeichnisse",
"allowed_dirs": "Zusätzliche Dateipfade",
"extra_yaml_keys": "Zusätzliche YAML-Schreib-Keys"
},
"data_description": {
"allowed_dirs": "Verzeichnisse relativ zum Konfigurationsverzeichnis, jeweils mit Lese- und Schreibzugriff (z. B. pyscript). Traversal- und Einträge außerhalb des Konfig-Verzeichnisses werden verworfen.",
"allowed_dirs": "Pfade relativ zum Konfigurationsverzeichnis, einschließlich genauer Dateinamen wie sensor.yaml, oder Pfade unter /share, /media, /ssl und /backup. Jedem Eintrag wird Lese- und Schreibzugriff für diesen Pfad und alles darunter gewährt; gib jeden Pfad als separaten Listeneintrag an. Verzeichnistraversierung und andere nicht erlaubte Pfade werden verworfen.",
"extra_yaml_keys": "Top-Level-Keys, die ha_config_set_yaml zusätzlich zu den eingebauten schreiben darf, für YAML-first-Integrationen auf dieser Installation (z. B. alert2). Vertrauensgrenzen-Keys werden verworfen."
}
},
@@ -29,11 +29,11 @@
"title": "HA-MCP Dosieraj & YAML Iloj",
"description": "Agordu la privilegiajn dosierajn kaj YAML-redaktajn servojn uzatajn de la laŭvolaj dosieraj/YAML-iloj de ha-mcp. Ĉi tiuj agordoj ankaŭ aperas en la propra agorda UI de la ha-mcp servilo kaj aplikiĝas tuj, sen rekomenco. Ne-superregebla malpermesa limo ankoraŭ blokas sentemajn vojojn (ekz. .storage) kaj fid-limajn ŝlosilojn (homeassistant, http, frontend, lovelace).",
"data": {
"allowed_dirs": "Ekstraj dosierujoj",
"allowed_dirs": "Kromaj dosiervojoj",
"extra_yaml_keys": "Ekstraj YAML-skribaj ŝlosiloj"
},
"data_description": {
"allowed_dirs": "Dosierujoj rilataj al la agorda dosierujo, ĉiu kun legado kaj skribado permesita (ekz. pyscript). Trairo kaj ekster-agordaj eniroj estas forĵetitaj.",
"allowed_dirs": "Vojoj relative al la agorda dosierujo, inkluzive de ekzakta dosiernomo kiel sensor.yaml, aŭ vojoj sub /share, /media, /ssl, kaj /backup. Ĉiu eniro ricevas leg- kaj skrib-permeson por tiu vojo kaj ĉio sub ĝi; enigu ĉiun vojon kiel apartan listan eron. Trairado kaj aliaj nepermesitaj vojoj estas forigitaj.",
"extra_yaml_keys": "Ĉefnivelaj ŝlosiloj, kiujn ha_config_set_yaml povas skribi krom la enkonstruitaj, por YAML-unuaj integriĝoj en ĉi tiu instalaĵo (ekz. alert2). Fid-limaj ŝlosiloj estas forĵetitaj."
}
},
@@ -29,11 +29,11 @@
"title": "Herramientas de archivos y YAML de HA-MCP",
"description": "Configura los servicios privilegiados de edición de archivos y YAML que usan las herramientas opcionales de archivos/YAML de ha-mcp. Estos ajustes aparecen también en la propia interfaz de ajustes del servidor ha-mcp y se aplican en caliente, sin reiniciar. Un mínimo de denegación no anulable sigue bloqueando rutas sensibles (p. ej. .storage) y claves de la frontera de confianza (homeassistant, http, frontend, lovelace).",
"data": {
"allowed_dirs": "Directorios de archivos adicionales",
"allowed_dirs": "Rutas de archivos adicionales",
"extra_yaml_keys": "Claves YAML de escritura adicionales"
},
"data_description": {
"allowed_dirs": "Directorios relativos al directorio de configuración, cada uno con permiso de lectura y escritura (p. ej. pyscript). Se descartan los recorridos de ruta y las entradas fuera de la configuración.",
"allowed_dirs": "Rutas relativas al directorio de configuración, incluidos nombres de archivo exactos como sensor.yaml, o rutas bajo /share, /media, /ssl y /backup. A cada entrada se le concede lectura y escritura para esa ruta y todo lo que esté debajo de ella; introduce cada ruta como un elemento de lista separado. Se descartarán los recorridos de directorios y otras rutas no permitidas.",
"extra_yaml_keys": "Claves de primer nivel que ha_config_set_yaml puede escribir además de las integradas, para integraciones basadas en YAML de esta instalación (p. ej. alert2). Las claves de la frontera de confianza se descartan."
}
},
@@ -29,11 +29,11 @@
"title": "Outils Fichier & YAML HA-MCP",
"description": "Configurez les services privilégiés d'édition de fichiers et de YAML utilisés par les outils fichier/YAML optionnels de ha-mcp. Ces paramètres apparaissent également dans l'interface de configuration du serveur ha-mcp et s'appliquent en direct, sans redémarrage. Un plancher de refus non contournable bloque toujours les chemins sensibles (ex. .storage) et les clés de limite de confiance (homeassistant, http, frontend, lovelace).",
"data": {
"allowed_dirs": "Répertoires de fichiers supplémentaires",
"allowed_dirs": "Chemins de fichiers supplémentaires",
"extra_yaml_keys": "Clés d'écriture YAML supplémentaires"
},
"data_description": {
"allowed_dirs": "Répertoires relatifs au répertoire de configuration, chacun avec accès en lecture et écriture (ex. pyscript). Les traversées et les entrées hors du répertoire de configuration sont ignorées.",
"allowed_dirs": "Chemins relatifs au répertoire de configuration, incluant les noms de fichiers exacts tels que sensor.yaml, ou les chemins sous /share, /media, /ssl et /backup. Chaque entrée bénéficie d'un accès en lecture et en écriture pour ce chemin et tout ce qu'il contient ; saisissez chaque chemin comme un élément de liste séparé. Les parcours de répertoires (traversal) et autres chemins non autorisés sont ignorés.",
"extra_yaml_keys": "Clés de premier niveau que ha_config_set_yaml peut écrire en plus de celles intégrées, pour les intégrations YAML-first de cette installation (ex. alert2). Les clés de limite de confiance sont ignorées."
}
},
@@ -29,11 +29,11 @@
"title": "Strumenti file e YAML di HA-MCP",
"description": "Configura i servizi privilegiati di modifica di file e YAML usati dagli strumenti file/YAML opzionali di ha-mcp. Queste impostazioni compaiono anche nell'interfaccia delle impostazioni del server ha-mcp e si applicano subito, senza riavvio. Una soglia di divieto non aggirabile continua a bloccare i percorsi sensibili (per esempio .storage) e le chiavi del confine di fiducia (homeassistant, http, frontend, lovelace).",
"data": {
"allowed_dirs": "Directory di file aggiuntive",
"allowed_dirs": "Percorsi file extra",
"extra_yaml_keys": "Chiavi YAML scrivibili aggiuntive"
},
"data_description": {
"allowed_dirs": "Directory relative alla directory di configurazione, ciascuna con permesso di lettura e scrittura (per esempio pyscript). I percorsi che risalgono l'albero e le voci fuori dalla configurazione vengono scartati.",
"allowed_dirs": "Percorsi relativi alla directory di configurazione, inclusi nomi di file esatti come sensor.yaml, o percorsi sotto /share, /media, /ssl e /backup. A ciascuna voce viene concesso l'accesso in lettura e scrittura per quel percorso e per tutto ciò che si trova al di sotto; inserisci ogni percorso come elemento di elenco separato. I percorsi di attraversamento e altri non consentiti vengono scartati.",
"extra_yaml_keys": "Chiavi di primo livello che ha_config_set_yaml può scrivere oltre a quelle integrate, per le integrazioni basate su YAML di questa installazione (per esempio alert2). Le chiavi del confine di fiducia vengono scartate."
}
},
@@ -29,11 +29,11 @@
"title": "HA-MCP 파일 및 YAML 도구",
"description": "ha-mcp의 선택형 파일/YAML 도구가 사용하는, 권한이 필요한 파일 및 YAML 편집 서비스를 구성합니다. 이 설정은 ha-mcp 서버 자체의 설정 UI에도 나타나며 다시 시작하지 않아도 즉시 적용됩니다. 재정의할 수 없는 기본 차단 규칙은 민감한 경로(예: .storage)와 신뢰 경계 키(homeassistant, http, frontend, lovelace)를 계속 차단합니다.",
"data": {
"allowed_dirs": "추가 파일 디렉터리",
"allowed_dirs": "추가 파일 경로",
"extra_yaml_keys": "추가 YAML 쓰기 허용 키"
},
"data_description": {
"allowed_dirs": "설정 디렉터리 기준 상대 디렉터리이며 각각 읽기와 쓰기가 허용됩니다(예: pyscript). 경로 탈출과 설정 디렉터리 밖의 항목은 무시됩니다.",
"allowed_dirs": "sensor.yaml 같은 정확한 파일 이름을 포함하여 config 디렉토리 상대 경로, 또는 /share, /media, /ssl, /backup 아래의 경로입니다. 각 항목에는 해당 경로 및 그 하위 경로에 대한 읽기 및 쓰기 권한이 부여되며, 각 경로는 별도의 목록 항목으로 입력하세요. 상위 디렉토리 순회 및 허용되지 않는 경로는 제외됩니다.",
"extra_yaml_keys": "이 설치본에서 YAML 우선으로 동작하는 통합 구성요소(예: alert2)를 위해, ha_config_set_yaml이 기본 허용 키 외에 추가로 쓸 수 있는 최상위 키입니다. 신뢰 경계 키는 무시됩니다."
}
},
@@ -29,11 +29,11 @@
"title": "HA-MCP Bestands- & YAML-hulpmiddelen",
"description": "Configureer de geprivilegieerde bestands- en YAML-bewerkingsservices die worden gebruikt door de opt-in bestands-/YAML-hulpmiddelen van ha-mcp. Deze instellingen verschijnen ook in de eigen instellingen-UI van de ha-mcp server en worden direct toegepast, zonder herstart. Een niet-overbrugbare blokkade weigert nog steeds gevoelige paden (bijv. .storage) en trust-boundary sleutels (homeassistant, http, frontend, lovelace).",
"data": {
"allowed_dirs": "Extra bestandsmappen",
"allowed_dirs": "Extra bestandspaden",
"extra_yaml_keys": "Extra YAML-schrijfsleutels"
},
"data_description": {
"allowed_dirs": "Mappen relatief aan de configuratiemap, elk met lees- en schrijfrechten (bijv. pyscript). Traversale en buiten-configuratie ingangen worden genegeerd.",
"allowed_dirs": "Paden relatief ten opzichte van deconfiguratiemap, inclusief exacte bestandsnamen zoals sensor.yaml, of paden onder /share, /media, /ssl en /backup. Elk opgegeven pad en alles daaronder krijgt lees- en schrijfrechten; voer elk pad in als een apart lijstitem. Padtraversatie en andere niet-toegestane paden worden genegeerd.",
"extra_yaml_keys": "Top-level sleutels die ha_config_set_yaml mag schrijven naast de ingebouwde, voor YAML-first integraties op deze installatie (bijv. alert2). Trust-boundary sleutels worden genegeerd."
}
},
@@ -29,11 +29,11 @@
"title": "Narzędzia plików i YAML HA-MCP",
"description": "Skonfiguruj uprzywilejowane usługi edycji plików i YAML używane przez opcjonalne narzędzia plików/YAML ha-mcp. Te ustawienia pojawiają się również w interfejsie ustawień serwera ha-mcp i są stosowane na żywo, bez restartu. Nienadpisywalna blokada nadal blokuje wrażliwe ścieżki (np. .storage) i klucze graniczne zaufania (homeassistant, http, frontend, lovelace).",
"data": {
"allowed_dirs": "Dodatkowe katalogi plików",
"allowed_dirs": "Dodatkowe ścieżki plików",
"extra_yaml_keys": "Dodatkowe klucze zapisu YAML"
},
"data_description": {
"allowed_dirs": "Katalogi względem katalogu konfiguracyjnego, każdy z uprawnieniami do odczytu i zapisu (np. pyscript). Wpisy poza konfiguracją i przechodzenie przez katalogi są odrzucane.",
"allowed_dirs": "Ścieżki względne do katalogu konfiguracyjnego, w tym dokładne nazwy plików, takie jak sensor.yaml, lub ścieżki w katalogach /share, /media, /ssl i /backup. Każdy wpis otrzymuje uprawnienia do odczytu i zapisu dla danej ścieżki oraz wszystkiego, co się w niej znajduje; wpisz każdą ścieżkę jako oddzielny element listy. Przechodzenie w górę drzewa katalogów (traversal) i inne niedozwolone ścieżki są odrzucane.",
"extra_yaml_keys": "Klucze najwyższego poziomu, które ha_config_set_yaml może zapisywać oprócz wbudowanych, dla integracji opartych na YAML w tej instalacji (np. alert2). Klucze graniczne zaufania są odrzucane."
}
},
@@ -29,11 +29,11 @@
"title": "Файловые инструменты и инструменты YAML HA-MCP",
"description": "Настройка привилегированных служб для работы с файлами и редактирования YAML, которые используются отключёнными по умолчанию файловыми инструментами и инструментами YAML в ha-mcp. Эти параметры также отображаются в собственном интерфейсе настроек сервера ha-mcp и применяются сразу, без перезапуска. Неотключаемый базовый запрет по-прежнему блокирует чувствительные пути (например, .storage) и ключи границы доверия (homeassistant, http, frontend, lovelace).",
"data": {
"allowed_dirs": "Дополнительные файловые каталоги",
"allowed_dirs": "Дополнительные пути к файлам",
"extra_yaml_keys": "Дополнительные YAML-ключи для записи"
},
"data_description": {
"allowed_dirs": "Каталоги относительно каталога конфигурации, каждому предоставляется доступ на чтение и запись (например, pyscript). Записи с обходом пути и вне каталога конфигурации отбрасываются.",
"allowed_dirs": "Пути относительно каталога конфигурации, включая точные имена файлов, такие как sensor.yaml, или пути в директориях /share, /media, /ssl и /backup. Для каждого элемента предоставляется доступ на чтение и запись к самому пути и всему, что находится в нем; вводите каждый путь как отдельный элемент списка. Обход каталогов и другие запрещенные пути отбрасываются.",
"extra_yaml_keys": "Верхнеуровневые ключи, которые ha_config_set_yaml может записывать вдобавок к встроенным, — для YAML-ориентированных интеграций на этой установке (например, alert2). Ключи границы доверия отбрасываются."
}
},
@@ -29,11 +29,11 @@
"title": "HA-MCP Fil- & YAML-verktyg",
"description": "Konfigurera de privilegierade fil- och YAML-redigeringstjänsterna som används av ha-mcp:s valfria fil-/YAML-verktyg. Dessa inställningar visas också i ha-mcp-serverns eget inställningsgränssnitt och tillämpas direkt, utan omstart. En icke-överskridbar spärr blockerar fortfarande känsliga sökvägar (t.ex. .storage) och nycklar för förtroendegränser (homeassistant, http, frontend, lovelace).",
"data": {
"allowed_dirs": "Extra filkataloger",
"allowed_dirs": "Extra filvägar",
"extra_yaml_keys": "Extra YAML-skrivnycklar"
},
"data_description": {
"allowed_dirs": "Kataloger i förhållande till konfigurationskatalogen, var och en beviljas läs- och skrivrättigheter (t.ex. pyscript). Genomgång och poster utanför konfigurationen ignoreras.",
"allowed_dirs": "Sökvägar relativt konfigurationskatalogen, inklusive exakta filnamn som sensor.yaml, eller sökvägar under /share, /media, /ssl och /backup. Varje post ges läs- och skrivbehörighet för den sökvägen och allt under den; ange varje sökväg som ett separat listobjekt. Sökordsbläddring (traversal) och andra otillåtna sökvägar tas bort.",
"extra_yaml_keys": "Toppnivånycklar som ha_config_set_yaml får skriva utöver de inbyggda, för YAML-först-integrationer på denna installation (t.ex. alert2). Nycklar för förtroendegränser ignoreras."
}
},
@@ -29,11 +29,11 @@
"title": "HA-MCP 文件与 YAML 工具",
"description": "配置 ha-mcp 可选文件/YAML 工具所使用的特权文件和 YAML 编辑服务。这些设置也会出现在 ha-mcp 服务器自身的设置界面中,并实时生效,无需重启。不可覆盖的拒绝底线仍会阻止敏感路径(例如 .storage)和信任边界键(homeassistant、http、frontend、lovelace)。",
"data": {
"allowed_dirs": "额外的文件目录",
"allowed_dirs": "额外文件路径",
"extra_yaml_keys": "额外的 YAML 可写键"
},
"data_description": {
"allowed_dirs": "相对于配置目录的目录,每个目录都授予读写权限(例如 pyscript)。路径穿越以及配置目录之外的条目会被丢弃。",
"allowed_dirs": "相对于配置目录的路径,包括诸如 sensor.yaml 的确切文件名,或 /share、/media、/ssl 和 /backup 下的路径。每个条目均被授予对该路径及其中所有内容的读写权限;每个路径请作为一个单独的列表项输入。将丢弃目录遍历及其他不允许的路径。",
"extra_yaml_keys": "除内置键外,ha_config_set_yaml 还可写入的顶层键,适用于本安装中以 YAML 为主的集成(例如 alert2)。信任边界键会被丢弃。"
}
},
+92 -17
View File
@@ -5647,11 +5647,23 @@ async def _call_service_prep(
domain, so it holds no matter which path reaches this function.
2. **ServiceNotFound** before dispatch, so an unknown service is a clean
``SERVICE_NOT_FOUND`` and never a landed-but-unreported write.
3. Pre-state capture for each ``entity_id`` (a synchronous in-memory read).
3. Pre-state capture for each ``entity_id`` (a synchronous in-memory read). A
target whose captured state is ``None`` — absent from the state machine, so
it structurally cannot ever emit a ``state_changed`` for this dispatch — is
excluded from the wait entirely (:func:`_confirmable_entity_ids`); waiting on
it would only burn the full ``timeout`` to learn what the pre-state already
proved. An ``"unavailable"`` target stays IN the wait (unlike a nonexistent
one, it can legitimately reconnect and transition mid-dispatch — excluding it
too would silently miss that), so ``should_confirm`` itself stays keyed off
the full ``entity_ids`` (intent to confirm), not the narrower confirmable
subset — a ``validate_first=False`` caller that intentionally skips the
not-found/unavailable error mapping still needs ``partial=True`` on a
genuinely-excluded target, not a bare unconfirmed-but-not-partial result.
4. Register the expected-aware ``EVENT_STATE_CHANGED`` waiter BEFORE the dispatch
(D5) so a fast entity's event can't arrive before the listener exists. The
waiter confirms only on reaching the server's ``expected_state`` hint (skipping
intermediate/noise events); a ``None`` hint keeps any-first-event confirmation.
(D5) so a fast entity's event can't arrive before the listener exists, scoped
to only the confirmable targets from step 3. The waiter confirms only on
reaching the server's ``expected_state`` hint (skipping intermediate/noise
events); a ``None`` hint keeps any-first-event confirmation.
5. Fire exactly ONE ``async_call`` (``blocking=True``); flip ``dispatched``
immediately after so a post-dispatch problem is never retried as a failed
call (D3/D9).
@@ -5695,22 +5707,32 @@ async def _call_service_prep(
wait = msg.get("wait", True)
timeout = msg.get("timeout", CALL_SERVICE_DEFAULT_TIMEOUT)
return_response = msg.get("return_response", False)
should_confirm = bool(wait and entity_ids)
# The server's confirmation HINT (``_SERVICE_TO_STATE.get(service)``), applied to
# every confirmation target. Absent / None keeps any-first-event confirmation.
expected_state = msg.get("expected_state")
expected_by_entity = dict.fromkeys(entity_ids, expected_state)
# should_confirm stays keyed off the full entity_ids (intent to confirm) — see
# the docstring's step 3 for why this must NOT narrow to the confirmable subset.
should_confirm = bool(wait and entity_ids)
# 3. Pre-state capture (synchronous in-memory reads, guarded against drift).
pre = {eid: _state_as_dict(_state_get(hass, eid)) for eid in entity_ids}
# Only a target whose pre-dispatch state proves it can possibly report a
# confirming event is worth actually waiting on (see
# ``_confirmable_entity_ids``) — a target absent from the state machine can
# never emit one, so waiting on it is certain to burn the full ``timeout`` for
# no new information; the server reads the certain ``None`` old_state straight
# off the transition instead.
confirmable_entity_ids = _confirmable_entity_ids(entity_ids, pre)
# 4. Register-before-fire (D5): only when there is something to confirm.
# 4. Register-before-fire (D5): only when there is something worth confirming.
evt: Any = None
captured: dict[str, Any] = {}
unsub: Any = None
if should_confirm:
if should_confirm and confirmable_entity_ids:
evt, captured, unsub = _register_transition_waiter(
hass, set(entity_ids), expected_by_entity
hass, set(confirmable_entity_ids), expected_by_entity
)
# 5. Dispatch exactly once. 6. Immediate-match + bounded wait. 7. Build the diff.
@@ -5729,9 +5751,13 @@ async def _call_service_prep(
return_response=return_response,
)
dispatched = True
if should_confirm:
# ``evt`` is None when nothing was worth waiting on (should_confirm was
# True but every target was excluded as unconfirmable) — there is then
# nothing that could ever confirm, so skip the wait outright rather than
# awaiting an event that was never registered to fire.
if should_confirm and evt is not None:
await _await_confirmation(
hass, entity_ids, expected_by_entity, captured, evt, timeout
hass, confirmable_entity_ids, expected_by_entity, captured, evt, timeout
)
result = _build_call_service_result(
hass,
@@ -5764,6 +5790,26 @@ async def _call_service_prep(
return {"result": result}
def _confirmable_entity_ids(entity_ids: list[str], pre: Mapping[str, Any]) -> list[str]:
"""Targets whose pre-dispatch state proves they can possibly confirm.
A target absent from the state machine (``pre[eid] is None``) can never emit a
confirming ``state_changed`` for this dispatch — HA no-ops a service call for
an entity id that matches nothing, and nothing will register that id mid-call
either. Excluding it from the wait lets the server read the certain outcome
straight off the transition's ``None`` ``old_state`` instead of burning the
full timeout to learn nothing new.
Deliberately NOT excluded: a target whose captured state is ``"unavailable"``.
Unlike a nonexistent id, an unavailable entity can legitimately reconnect and
transition during the blocking dispatch (the very case ``ENTITY_UNAVAILABLE``
exists to distinguish from a real failure would itself go undetected if the
listener were never registered) — so it stays in the wait and is judged by
whether it actually confirmed, not excluded upfront.
"""
return [eid for eid in entity_ids if pre.get(eid) is not None]
def _guard_call_service_target(hass: HomeAssistant, domain: str, service: str) -> None:
"""Pre-dispatch refusals for ``call_service`` — raise BEFORE any dispatch.
@@ -5953,6 +5999,9 @@ def _build_call_service_result(
_call_service_transition(eid, pre.get(eid), _post_state(hass, eid, captured))
for eid in entity_ids
]
# Against the FULL entity_ids, not just the confirmable subset: an excluded
# (nonexistent) target can never land in captured, so this naturally stays
# False whenever one is present — exactly right, since it never confirmed.
confirmed = bool(should_confirm and set(entity_ids) <= set(captured))
result: dict[str, Any] = {
"domain": domain,
@@ -6155,19 +6204,36 @@ def _bulk_op_record(
``pre`` is the synchronous in-memory pre-state per target; ``expected_by_entity``
maps every target to this op's confirmation hint (``_SERVICE_TO_STATE``) so the
waiter + immediate-match key off it; ``dispatched`` / ``error`` / ``response``
start empty and are filled during dispatch; ``should_confirm`` is true only when
the batch is waiting AND this op names targets to confirm.
start empty and are filled during dispatch. ``confirmable_entity_ids`` excludes
ONLY a target whose captured pre-state is ``None`` (nonexistent) — it can never
emit a confirming event, so it is never worth the shared wait (see
``_confirmable_entity_ids``). Deliberately NOT excluded from it: a target
already ``"unavailable"``, which can legitimately reconnect and confirm
mid-dispatch. ``should_confirm`` stays intent-level (``bool(wait and
entity_ids)`` — the FULL list, not the confirmable subset): a
``validate_first=False`` caller that skips the not-found/unavailable error
mapping still needs ``partial=True`` on a genuinely-excluded target, not a
bare unconfirmed-but-not-partial result.
"""
entity_ids = list(op.get("entity_ids") or [])
expected_state = op.get("expected_state")
pre = {eid: _state_as_dict(_state_get(hass, eid)) for eid in entity_ids}
confirmable_entity_ids = _confirmable_entity_ids(entity_ids, pre)
return {
"domain": op["domain"],
"service": op["service"],
"service_data": op.get("service_data") or {},
"entity_ids": entity_ids,
"confirmable_entity_ids": confirmable_entity_ids,
"expected_by_entity": dict.fromkeys(entity_ids, expected_state),
# Intent-level (full entity_ids), NOT the confirmable subset — mirrors
# ``_call_service_prep``'s should_confirm: a validate_first=False caller
# that intentionally skips the not-found/unavailable error mapping still
# needs partial=True on a genuinely-excluded target, not a bare
# unconfirmed-but-not-partial result. confirmable_entity_ids scopes ONLY
# which targets are actually worth registering a listener / waiting for.
"should_confirm": bool(wait and entity_ids),
"pre": {eid: _state_as_dict(_state_get(hass, eid)) for eid in entity_ids},
"pre": pre,
"evt": None,
"captured": {},
"dispatched": False,
@@ -6210,9 +6276,9 @@ def _bulk_register_all(hass: HomeAssistant, ops: list[dict[str, Any]]) -> list[A
unsubs: list[Any] = []
try:
for op in ops:
if op["should_confirm"]:
if op["should_confirm"] and op["confirmable_entity_ids"]:
evt, captured, unsub = _register_transition_waiter(
hass, set(op["entity_ids"]), op["expected_by_entity"]
hass, set(op["confirmable_entity_ids"]), op["expected_by_entity"]
)
op["evt"] = evt
op["captured"] = captured
@@ -6267,7 +6333,10 @@ def _bulk_match_immediate(hass: HomeAssistant, ops: list[dict[str, Any]]) -> Non
for op in ops:
if op["should_confirm"] and op["dispatched"]:
_match_immediate(
hass, op["entity_ids"], op["expected_by_entity"], op["captured"]
hass,
op["confirmable_entity_ids"],
op["expected_by_entity"],
op["captured"],
)
@@ -6289,7 +6358,10 @@ async def _bulk_wait_all(ops: list[dict[str, Any]], timeout: float) -> None:
for op in ops
if op["should_confirm"]
and op["dispatched"]
and not (set(op["entity_ids"]) <= set(op["captured"]))
# evt is None when nothing was worth waiting on for this op (every
# target was excluded as unconfirmable) — nothing could ever set it.
and op["evt"] is not None
and not (set(op["confirmable_entity_ids"]) <= set(op["captured"]))
]
if not waiters:
return
@@ -6330,6 +6402,9 @@ def _build_bulk_op_result(hass: HomeAssistant, op: Mapping[str, Any]) -> dict[st
)
for eid in entity_ids
]
# Against the FULL entity_ids, not just the confirmable subset: an excluded
# (nonexistent) target can never land in captured, so this naturally stays
# False whenever one is present — exactly right, since it never confirmed.
confirmed = bool(should_confirm and dispatched and set(entity_ids) <= set(captured))
result: dict[str, Any] = {
"domain": op["domain"],
File diff suppressed because one or more lines are too long